generated from nhcarrigan/template
feat: productivity suite — task loop, workflow, theming, docs & more #197
@@ -11,3 +11,6 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Coverage reports
|
||||
/coverage
|
||||
|
||||
# PRD task files (user-generated data, not source code)
|
||||
hikari-tasks.json
|
||||
|
||||
+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.
|
||||
@@ -606,6 +606,62 @@ 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())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SavedFileInfo {
|
||||
pub path: String,
|
||||
@@ -2337,6 +2393,160 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+106
-9
@@ -158,6 +158,16 @@ pub struct HikariConfig {
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
impl Default for HikariConfig {
|
||||
@@ -201,6 +211,9 @@ impl Default for HikariConfig {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,6 +254,10 @@ fn default_background_image_opacity() -> f32 {
|
||||
0.3
|
||||
}
|
||||
|
||||
fn default_task_loop_commit_prefix() -> String {
|
||||
"feat".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BudgetAction {
|
||||
@@ -258,6 +275,18 @@ pub enum Theme {
|
||||
#[serde(rename = "high-contrast")]
|
||||
HighContrast,
|
||||
Custom,
|
||||
Dracula,
|
||||
Catppuccin,
|
||||
Nord,
|
||||
Solarized,
|
||||
#[serde(rename = "solarized-light")]
|
||||
SolarizedLight,
|
||||
#[serde(rename = "catppuccin-latte")]
|
||||
CatppuccinLatte,
|
||||
#[serde(rename = "gruvbox-light")]
|
||||
GruvboxLight,
|
||||
#[serde(rename = "rose-pine-dawn")]
|
||||
RosePineDawn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
@@ -320,6 +349,9 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -363,6 +395,9 @@ mod tests {
|
||||
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,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
@@ -377,22 +412,84 @@ mod tests {
|
||||
deserialized.greeting_custom_prompt,
|
||||
Some("Hello!".to_string())
|
||||
);
|
||||
assert!(deserialized.task_loop_auto_commit);
|
||||
assert_eq!(deserialized.task_loop_commit_prefix, "fix");
|
||||
assert!(deserialized.task_loop_include_summary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_serialization() {
|
||||
let dark = Theme::Dark;
|
||||
let light = Theme::Light;
|
||||
let high_contrast = Theme::HighContrast;
|
||||
|
||||
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
||||
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
||||
assert_eq!(serde_json::to_string(&Theme::Dark).unwrap(), "\"dark\"");
|
||||
assert_eq!(serde_json::to_string(&Theme::Light).unwrap(), "\"light\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&high_contrast).unwrap(),
|
||||
serde_json::to_string(&Theme::HighContrast).unwrap(),
|
||||
"\"high-contrast\""
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&Theme::Custom).unwrap(), "\"custom\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::Dracula).unwrap(),
|
||||
"\"dracula\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::Catppuccin).unwrap(),
|
||||
"\"catppuccin\""
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&Theme::Nord).unwrap(), "\"nord\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::Solarized).unwrap(),
|
||||
"\"solarized\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::SolarizedLight).unwrap(),
|
||||
"\"solarized-light\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::CatppuccinLatte).unwrap(),
|
||||
"\"catppuccin-latte\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::GruvboxLight).unwrap(),
|
||||
"\"gruvbox-light\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::RosePineDawn).unwrap(),
|
||||
"\"rose-pine-dawn\""
|
||||
);
|
||||
}
|
||||
|
||||
let custom = Theme::Custom;
|
||||
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
|
||||
#[test]
|
||||
fn test_theme_deserialization() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"dracula\"").unwrap(),
|
||||
Theme::Dracula
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"catppuccin\"").unwrap(),
|
||||
Theme::Catppuccin
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"nord\"").unwrap(),
|
||||
Theme::Nord
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"solarized\"").unwrap(),
|
||||
Theme::Solarized
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"solarized-light\"").unwrap(),
|
||||
Theme::SolarizedLight
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"catppuccin-latte\"").unwrap(),
|
||||
Theme::CatppuccinLatte
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"gruvbox-light\"").unwrap(),
|
||||
Theme::GruvboxLight
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"rose-pine-dawn\"").unwrap(),
|
||||
Theme::RosePineDawn
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ pub fn run() {
|
||||
validate_directory,
|
||||
list_skills,
|
||||
check_for_updates,
|
||||
fetch_changelog,
|
||||
save_temp_file,
|
||||
register_temp_file,
|
||||
get_temp_files,
|
||||
@@ -220,6 +221,7 @@ pub fn run() {
|
||||
save_draft,
|
||||
delete_draft,
|
||||
delete_all_drafts,
|
||||
scan_project,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+392
@@ -148,6 +148,398 @@
|
||||
--hljs-meta: #cccccc;
|
||||
}
|
||||
|
||||
[data-theme="dracula"] {
|
||||
--bg-primary: #282a36;
|
||||
--bg-secondary: #1e1f29;
|
||||
--bg-terminal: #191a21;
|
||||
--bg-hover: #44475a;
|
||||
--bg-code: #282a36;
|
||||
--accent-primary: #bd93f9;
|
||||
--accent-secondary: #ff79c6;
|
||||
--text-primary: #f8f8f2;
|
||||
--text-secondary: #6272a4;
|
||||
--text-tertiary: #44475a;
|
||||
--border-color: #44475a;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #8be9fd;
|
||||
--terminal-tool: #bd93f9;
|
||||
--terminal-tool-name: #caa9fa;
|
||||
--terminal-error: #ff5555;
|
||||
|
||||
/* Syntax highlighting colors (Dracula) */
|
||||
--hljs-keyword: #ff79c6;
|
||||
--hljs-string: #f1fa8c;
|
||||
--hljs-number: #bd93f9;
|
||||
--hljs-comment: #6272a4;
|
||||
--hljs-function: #50fa7b;
|
||||
--hljs-type: #8be9fd;
|
||||
--hljs-variable: #ffb86c;
|
||||
--hljs-meta: #94a3b8;
|
||||
}
|
||||
|
||||
[data-theme="catppuccin"] {
|
||||
--bg-primary: #1e1e2e;
|
||||
--bg-secondary: #181825;
|
||||
--bg-terminal: #11111b;
|
||||
--bg-hover: #313244;
|
||||
--bg-code: #1e1e2e;
|
||||
--accent-primary: #cba6f7;
|
||||
--accent-secondary: #f5c2e7;
|
||||
--text-primary: #cdd6f4;
|
||||
--text-secondary: #a6adc8;
|
||||
--text-tertiary: #6c7086;
|
||||
--border-color: #313244;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #89dceb;
|
||||
--terminal-tool: #cba6f7;
|
||||
--terminal-tool-name: #d9b3ff;
|
||||
--terminal-error: #f38ba8;
|
||||
|
||||
/* Syntax highlighting colors (Catppuccin Mocha) */
|
||||
--hljs-keyword: #cba6f7;
|
||||
--hljs-string: #a6e3a1;
|
||||
--hljs-number: #fab387;
|
||||
--hljs-comment: #6c7086;
|
||||
--hljs-function: #89b4fa;
|
||||
--hljs-type: #89dceb;
|
||||
--hljs-variable: #fab387;
|
||||
--hljs-meta: #a6adc8;
|
||||
}
|
||||
|
||||
[data-theme="nord"] {
|
||||
--bg-primary: #2e3440;
|
||||
--bg-secondary: #3b4252;
|
||||
--bg-terminal: #242933;
|
||||
--bg-hover: #434c5e;
|
||||
--bg-code: #2e3440;
|
||||
--accent-primary: #88c0d0;
|
||||
--accent-secondary: #81a1c1;
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary: #d8dee9;
|
||||
--text-tertiary: #4c566a;
|
||||
--border-color: #434c5e;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #88c0d0;
|
||||
--terminal-tool: #b48ead;
|
||||
--terminal-tool-name: #c7a8c9;
|
||||
--terminal-error: #bf616a;
|
||||
|
||||
/* Syntax highlighting colors (Nord) */
|
||||
--hljs-keyword: #81a1c1;
|
||||
--hljs-string: #a3be8c;
|
||||
--hljs-number: #b48ead;
|
||||
--hljs-comment: #4c566a;
|
||||
--hljs-function: #88c0d0;
|
||||
--hljs-type: #8fbcbb;
|
||||
--hljs-variable: #d08770;
|
||||
--hljs-meta: #616e88;
|
||||
}
|
||||
|
||||
[data-theme="solarized"] {
|
||||
--bg-primary: #002b36;
|
||||
--bg-secondary: #073642;
|
||||
--bg-terminal: #00212b;
|
||||
--bg-hover: #094656;
|
||||
--bg-code: #002b36;
|
||||
--accent-primary: #268bd2;
|
||||
--accent-secondary: #2aa198;
|
||||
--text-primary: #fdf6e3;
|
||||
--text-secondary: #93a1a1;
|
||||
--text-tertiary: #657b83;
|
||||
--border-color: #094656;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #2aa198;
|
||||
--terminal-tool: #6c71c4;
|
||||
--terminal-tool-name: #9395d0;
|
||||
--terminal-error: #dc322f;
|
||||
|
||||
/* Syntax highlighting colors (Solarized Dark) */
|
||||
--hljs-keyword: #859900;
|
||||
--hljs-string: #2aa198;
|
||||
--hljs-number: #d33682;
|
||||
--hljs-comment: #586e75;
|
||||
--hljs-function: #268bd2;
|
||||
--hljs-type: #b58900;
|
||||
--hljs-variable: #cb4b16;
|
||||
--hljs-meta: #657b83;
|
||||
}
|
||||
|
||||
[data-theme="solarized-light"] {
|
||||
--bg-primary: #fdf6e3;
|
||||
--bg-secondary: #eee8d5;
|
||||
--bg-terminal: #f9f3d7;
|
||||
--bg-hover: #d8d1be;
|
||||
--bg-code: #eee8d5;
|
||||
--accent-primary: #268bd2;
|
||||
--accent-secondary: #2aa198;
|
||||
--text-primary: #657b83;
|
||||
--text-secondary: #839496;
|
||||
--text-tertiary: #93a1a1;
|
||||
--border-color: #cfc9b5;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #268bd2;
|
||||
--terminal-tool: #6c71c4;
|
||||
--terminal-tool-name: #8f94cc;
|
||||
--terminal-error: #dc322f;
|
||||
|
||||
/* Syntax highlighting colors (Solarized Light) */
|
||||
--hljs-keyword: #859900;
|
||||
--hljs-string: #2aa198;
|
||||
--hljs-number: #d33682;
|
||||
--hljs-comment: #93a1a1;
|
||||
--hljs-function: #268bd2;
|
||||
--hljs-type: #b58900;
|
||||
--hljs-variable: #cb4b16;
|
||||
--hljs-meta: #657b83;
|
||||
}
|
||||
|
||||
[data-theme="catppuccin-latte"] {
|
||||
--bg-primary: #eff1f5;
|
||||
--bg-secondary: #e6e9ef;
|
||||
--bg-terminal: #dce0e8;
|
||||
--bg-hover: #ccd0da;
|
||||
--bg-code: #e6e9ef;
|
||||
--accent-primary: #8839ef;
|
||||
--accent-secondary: #ea76cb;
|
||||
--text-primary: #4c4f69;
|
||||
--text-secondary: #6c6f85;
|
||||
--text-tertiary: #9ca0b0;
|
||||
--border-color: #bcc0cc;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #209fb5;
|
||||
--terminal-tool: #8839ef;
|
||||
--terminal-tool-name: #a259f1;
|
||||
--terminal-error: #d20f39;
|
||||
|
||||
/* Syntax highlighting colors (Catppuccin Latte) */
|
||||
--hljs-keyword: #8839ef;
|
||||
--hljs-string: #40a02b;
|
||||
--hljs-number: #fe640b;
|
||||
--hljs-comment: #8c8fa1;
|
||||
--hljs-function: #1e66f5;
|
||||
--hljs-type: #209fb5;
|
||||
--hljs-variable: #fe640b;
|
||||
--hljs-meta: #5c5f77;
|
||||
}
|
||||
|
||||
[data-theme="gruvbox-light"] {
|
||||
--bg-primary: #fbf1c7;
|
||||
--bg-secondary: #ebdbb2;
|
||||
--bg-terminal: #f9f5d7;
|
||||
--bg-hover: #d5c4a1;
|
||||
--bg-code: #ebdbb2;
|
||||
--accent-primary: #458588;
|
||||
--accent-secondary: #689d6a;
|
||||
--text-primary: #3c3836;
|
||||
--text-secondary: #665c54;
|
||||
--text-tertiary: #7c6f64;
|
||||
--border-color: #bdae93;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #458588;
|
||||
--terminal-tool: #b16286;
|
||||
--terminal-tool-name: #c37aa0;
|
||||
--terminal-error: #cc241d;
|
||||
|
||||
/* Syntax highlighting colors (Gruvbox Light) */
|
||||
--hljs-keyword: #d65d0e;
|
||||
--hljs-string: #98971a;
|
||||
--hljs-number: #b16286;
|
||||
--hljs-comment: #928374;
|
||||
--hljs-function: #458588;
|
||||
--hljs-type: #d79921;
|
||||
--hljs-variable: #af3a03;
|
||||
--hljs-meta: #7c6f64;
|
||||
}
|
||||
|
||||
[data-theme="rose-pine-dawn"] {
|
||||
--bg-primary: #faf4ed;
|
||||
--bg-secondary: #fffaf3;
|
||||
--bg-terminal: #f2e9e1;
|
||||
--bg-hover: #dfdad9;
|
||||
--bg-code: #fffaf3;
|
||||
--accent-primary: #907aa9;
|
||||
--accent-secondary: #d7827e;
|
||||
--text-primary: #575279;
|
||||
--text-secondary: #797593;
|
||||
--text-tertiary: #9893a5;
|
||||
--border-color: #cecacd;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #56949f;
|
||||
--terminal-tool: #907aa9;
|
||||
--terminal-tool-name: #a48abf;
|
||||
--terminal-error: #b4637a;
|
||||
|
||||
/* Syntax highlighting colors (Rosé Pine Dawn) */
|
||||
--hljs-keyword: #286983;
|
||||
--hljs-string: #56949f;
|
||||
--hljs-number: #ea9d34;
|
||||
--hljs-comment: #9893a5;
|
||||
--hljs-function: #907aa9;
|
||||
--hljs-type: #d7827e;
|
||||
--hljs-variable: #b4637a;
|
||||
--hljs-meta: #797593;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,9 @@
|
||||
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);
|
||||
@@ -730,7 +733,7 @@
|
||||
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
|
||||
<button
|
||||
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'
|
||||
? '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)]'}"
|
||||
@@ -739,7 +742,7 @@
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
? '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)]'}"
|
||||
@@ -748,7 +751,7 @@
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
? '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)]'}"
|
||||
@@ -758,7 +761,7 @@
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
? '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)]'}"
|
||||
@@ -767,6 +770,96 @@
|
||||
Custom
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Custom Theme Editor -->
|
||||
|
||||
@@ -1,54 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { HELP_PAGES, nextPage, prevPage, isFirstPage, isLastPage } from "./docs/helpPages";
|
||||
import DocsGettingStarted from "./docs/DocsGettingStarted.svelte";
|
||||
import DocsKeyboardShortcuts from "./docs/DocsKeyboardShortcuts.svelte";
|
||||
import DocsChatInput from "./docs/DocsChatInput.svelte";
|
||||
import DocsFileEditor from "./docs/DocsFileEditor.svelte";
|
||||
import DocsGitPanel from "./docs/DocsGitPanel.svelte";
|
||||
import DocsThemeCustomisation from "./docs/DocsThemeCustomisation.svelte";
|
||||
import DocsModelConfig from "./docs/DocsModelConfig.svelte";
|
||||
import DocsSessionManagement from "./docs/DocsSessionManagement.svelte";
|
||||
import DocsTaskLoop from "./docs/DocsTaskLoop.svelte";
|
||||
import DocsPanelsTools from "./docs/DocsPanelsTools.svelte";
|
||||
import DocsTroubleshooting from "./docs/DocsTroubleshooting.svelte";
|
||||
import DocsChangelog from "./docs/DocsChangelog.svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
items: [
|
||||
"Enter your Claude API key in Settings (gear icon)",
|
||||
"Set your working directory and click Connect",
|
||||
"Start chatting with Hikari - your AI assistant!",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Key Features",
|
||||
items: [
|
||||
"🗂️ File Management: Hikari can read, write, and edit files in your project",
|
||||
"💻 Terminal Access: Execute commands and run scripts",
|
||||
"🔍 Code Search: Find files and search through code",
|
||||
"🌐 Web Access: Fetch information from the web",
|
||||
"📊 MCP Servers: Connect to external tools via Model Context Protocol",
|
||||
"📁 Multi-tab Support: Work on multiple conversations simultaneously",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Available Commands",
|
||||
items: [
|
||||
"Type naturally - Hikari understands context!",
|
||||
"Ask to read, create, or modify files",
|
||||
"Request code explanations or debugging help",
|
||||
"Have Hikari run tests or build commands",
|
||||
"Search for specific functions or patterns",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tips & Tricks",
|
||||
items: [
|
||||
"💡 Use the stats panel to track your usage",
|
||||
"🎯 Be specific about file paths and requirements",
|
||||
"🔒 Grant tool permissions as needed for security",
|
||||
"📌 Pin important conversations for quick access",
|
||||
"🎨 Customize your theme and preferences in Settings",
|
||||
"⌨️ Check the keyboard icon for available shortcuts",
|
||||
],
|
||||
},
|
||||
const PAGE_COMPONENTS = [
|
||||
DocsGettingStarted,
|
||||
DocsKeyboardShortcuts,
|
||||
DocsChatInput,
|
||||
DocsFileEditor,
|
||||
DocsGitPanel,
|
||||
DocsThemeCustomisation,
|
||||
DocsModelConfig,
|
||||
DocsSessionManagement,
|
||||
DocsTaskLoop,
|
||||
DocsPanelsTools,
|
||||
DocsTroubleshooting,
|
||||
DocsChangelog,
|
||||
];
|
||||
|
||||
let currentPageIndex = $state(0);
|
||||
|
||||
const currentComponent = $derived(PAGE_COMPONENTS[currentPageIndex]);
|
||||
const atFirst = $derived(isFirstPage(currentPageIndex));
|
||||
const atLast = $derived(isLastPage(currentPageIndex, HELP_PAGES.length));
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
|
||||
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInputFocused) return;
|
||||
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
currentPageIndex = nextPage(currentPageIndex, HELP_PAGES.length);
|
||||
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
currentPageIndex = prevPage(currentPageIndex);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
@@ -56,17 +71,21 @@
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<!-- Dialog -->
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl w-full max-w-3xl h-[80vh] flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="help-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<h2 id="help-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
How to Use Hikari Desktop
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] shrink-0"
|
||||
>
|
||||
<h2 id="help-title" class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Help & Documentation
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
@@ -84,32 +103,77 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1 p-6 space-y-6">
|
||||
{#each sections as section (section.title)}
|
||||
<div>
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-3">{section.title}</h3>
|
||||
<ul class="space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
{#each section.items as item (item)}
|
||||
<li class="flex items-start">
|
||||
<span class="text-[var(--accent-primary)] mr-2 mt-0.5">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Body: sidebar + content -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Sidebar TOC -->
|
||||
<nav
|
||||
class="w-44 shrink-0 border-r border-[var(--border-color)] overflow-y-auto py-2"
|
||||
aria-label="Documentation pages"
|
||||
>
|
||||
{#each HELP_PAGES as page, i (page.id)}
|
||||
<button
|
||||
onclick={() => (currentPageIndex = i)}
|
||||
class="w-full text-left px-4 py-2 text-sm transition-colors {i === currentPageIndex
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--accent-primary)] font-medium'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}"
|
||||
aria-current={i === currentPageIndex ? "page" : undefined}
|
||||
>
|
||||
{page.title}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<p class="text-sm text-[var(--text-tertiary)]">
|
||||
<strong>Need more help?</strong> Join our Discord community for support and updates!
|
||||
</p>
|
||||
<!-- Page content -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<svelte:component this={currentComponent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: prev / page indicator / next -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-3 border-t border-[var(--border-color)] shrink-0"
|
||||
>
|
||||
<button
|
||||
onclick={() => (currentPageIndex = prevPage(currentPageIndex))}
|
||||
disabled={atFirst}
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm rounded transition-colors
|
||||
{atFirst
|
||||
? 'text-[var(--text-tertiary)] cursor-not-allowed'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
Page {currentPageIndex + 1} of {HELP_PAGES.length}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onclick={() => (currentPageIndex = nextPage(currentPageIndex, HELP_PAGES.length))}
|
||||
disabled={atLast}
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm rounded transition-colors
|
||||
{atLast
|
||||
? 'text-[var(--text-tertiary)] cursor-not-allowed'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}"
|
||||
>
|
||||
Next
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Ensure the panel appears above other content */
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
@@ -125,26 +189,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
.overflow-y-auto {
|
||||
nav {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
import DraftPanel from "$lib/components/DraftPanel.svelte";
|
||||
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
||||
import { draftsStore } from "$lib/stores/drafts";
|
||||
import { injectTextStore } from "$lib/stores/projectContext";
|
||||
import type { Attachment } from "$lib/types/messages";
|
||||
|
||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||
@@ -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() {
|
||||
inputValue = "";
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
|
||||
@@ -108,15 +108,19 @@
|
||||
return processed;
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
// Two-stage reactive rendering:
|
||||
// Stage 1 — only re-runs when `content` changes (expensive: marked + hljs + spoilers)
|
||||
let parsedHtml = $derived.by(() => {
|
||||
try {
|
||||
const html = marked.parse(text) as string;
|
||||
const withSpoilers = processSpoilers(html);
|
||||
return highlightSearchMatches(withSpoilers, searchQuery);
|
||||
const html = marked.parse(content) as string;
|
||||
return processSpoilers(html);
|
||||
} catch {
|
||||
return text;
|
||||
return content;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stage 2 — re-runs when search changes; skips re-parsing markdown entirely
|
||||
let renderedHtml = $derived(highlightSearchMatches(parsedHtml, searchQuery));
|
||||
|
||||
function handleSpoilerClick(event: Event) {
|
||||
const target = event.target as HTMLElement;
|
||||
@@ -191,7 +195,7 @@
|
||||
role="presentation"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
|
||||
{@html renderMarkdown(content)}
|
||||
{@html renderedHtml}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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
|
||||
* - [ ] Links open in the system browser via the Tauri opener
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Mirror functions from Markdown.svelte for isolated testing
|
||||
|
||||
function processSpoilers(html: string): string {
|
||||
const codeBlockPlaceholders: string[] = [];
|
||||
|
||||
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||
codeBlockPlaceholders.push(match);
|
||||
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
processed = processed.replace(
|
||||
/\|\|(.+?)\|\|/g,
|
||||
'<span class="spoiler" role="button" tabindex="0">$1</span>'
|
||||
);
|
||||
|
||||
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return codeBlockPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
function highlightSearchMatches(html: string, query: string): string {
|
||||
if (!query) return html;
|
||||
|
||||
const codeBlockPlaceholders: string[] = [];
|
||||
const tagPlaceholders: string[] = [];
|
||||
|
||||
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||
codeBlockPlaceholders.push(match);
|
||||
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
processed = processed.replace(/<[^>]+>/g, (match) => {
|
||||
tagPlaceholders.push(match);
|
||||
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
|
||||
|
||||
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return tagPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return codeBlockPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("processSpoilers", () => {
|
||||
it("wraps ||text|| in a spoiler span", () => {
|
||||
const result = processSpoilers("<p>||secret||</p>");
|
||||
expect(result).toContain('<span class="spoiler"');
|
||||
expect(result).toContain("secret");
|
||||
});
|
||||
|
||||
it("adds role=button and tabindex to spoiler spans", () => {
|
||||
const result = processSpoilers("<p>||hidden||</p>");
|
||||
expect(result).toContain('role="button"');
|
||||
expect(result).toContain('tabindex="0"');
|
||||
});
|
||||
|
||||
it("leaves content without spoiler markers unchanged", () => {
|
||||
const html = "<p>Normal text here</p>";
|
||||
expect(processSpoilers(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles multiple spoilers in the same string", () => {
|
||||
const result = processSpoilers("<p>||a|| and ||b||</p>");
|
||||
const matches = result.match(/class="spoiler"/g);
|
||||
expect(matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not apply spoiler syntax inside code blocks", () => {
|
||||
const html = "<pre><code>||not a spoiler||</code></pre>";
|
||||
const result = processSpoilers(html);
|
||||
expect(result).not.toContain('class="spoiler"');
|
||||
expect(result).toContain("||not a spoiler||");
|
||||
});
|
||||
|
||||
it("does not apply spoiler syntax inside inline code", () => {
|
||||
const html = "<p>Example: <code>||inline||</code></p>";
|
||||
const result = processSpoilers(html);
|
||||
expect(result).not.toContain('class="spoiler"');
|
||||
});
|
||||
|
||||
it("handles spoilers adjacent to code blocks correctly", () => {
|
||||
const html = "<pre><code>code</code></pre><p>||revealed||</p>";
|
||||
const result = processSpoilers(html);
|
||||
expect(result).toContain('<span class="spoiler"');
|
||||
expect(result).toContain("<pre><code>code</code></pre>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlightSearchMatches", () => {
|
||||
it("returns unchanged html when query is empty string", () => {
|
||||
const html = "<p>hello world</p>";
|
||||
expect(highlightSearchMatches(html, "")).toBe(html);
|
||||
});
|
||||
|
||||
it("wraps matched text in a mark element", () => {
|
||||
const result = highlightSearchMatches("<p>hello world</p>", "hello");
|
||||
expect(result).toContain('<mark class="search-highlight">hello</mark>');
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const result = highlightSearchMatches("<p>Hello World</p>", "hello");
|
||||
expect(result).toContain('<mark class="search-highlight">Hello</mark>');
|
||||
});
|
||||
|
||||
it("highlights all occurrences", () => {
|
||||
const result = highlightSearchMatches("<p>cat and cat</p>", "cat");
|
||||
const matches = result.match(/<mark class="search-highlight">/g);
|
||||
expect(matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not highlight inside code blocks", () => {
|
||||
const html = "<pre><code>hello inside code</code></pre>";
|
||||
const result = highlightSearchMatches(html, "hello");
|
||||
expect(result).not.toContain('<mark class="search-highlight">');
|
||||
expect(result).toContain("hello inside code");
|
||||
});
|
||||
|
||||
it("does not corrupt HTML tags", () => {
|
||||
const result = highlightSearchMatches('<p class="foo">hello</p>', "hello");
|
||||
expect(result).toContain('<p class="foo">');
|
||||
expect(result).toContain('<mark class="search-highlight">hello</mark>');
|
||||
});
|
||||
|
||||
it("escapes regex special characters in the query", () => {
|
||||
const result = highlightSearchMatches("<p>price: $1.00</p>", "$1");
|
||||
expect(result).toContain('<mark class="search-highlight">$1</mark>');
|
||||
});
|
||||
|
||||
it("highlights text outside code blocks whilst leaving code intact", () => {
|
||||
const html = "<pre><code>match here</code></pre><p>match here too</p>";
|
||||
const result = highlightSearchMatches(html, "match");
|
||||
expect(result).toContain("<pre><code>match here</code></pre>");
|
||||
expect(result).toContain('<mark class="search-highlight">match</mark>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,584 @@
|
||||
<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 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 { 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);
|
||||
|
||||
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>
|
||||
|
||||
<!-- 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 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -9,27 +9,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
import { onMount } from "svelte";
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
import AboutPanel from "./AboutPanel.svelte";
|
||||
import HelpPanel from "./HelpPanel.svelte";
|
||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
import { 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 { PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import {
|
||||
generateContextInjection,
|
||||
@@ -37,12 +22,10 @@
|
||||
sanitizeForJson,
|
||||
} from "$lib/utils/conversationUtils";
|
||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
|
||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||
import NavMenu from "./NavMenu.svelte";
|
||||
import { taskLoopStore } from "$lib/stores/taskLoop";
|
||||
|
||||
let connectionStatus: ConnectionStatus = $state("disconnected");
|
||||
let workingDirectory = $state("");
|
||||
@@ -50,23 +33,9 @@
|
||||
let isConnecting = $state(false);
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let appVersion = $state("");
|
||||
let showStats = $state(false);
|
||||
let showAbout = $state(false);
|
||||
let showHelp = $state(false);
|
||||
let showKeyboardShortcuts = $state(false);
|
||||
let showSessionHistory = $state(false);
|
||||
let 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 showWorkspaceTrust = $state(false);
|
||||
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
model: null,
|
||||
api_key: null,
|
||||
@@ -115,6 +84,9 @@
|
||||
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 streamerModeActive = $state(false);
|
||||
@@ -122,14 +94,13 @@
|
||||
streamerModeActive = value;
|
||||
});
|
||||
|
||||
let editorVisible = $state(false);
|
||||
editorStore.isEditorVisible.subscribe((value) => {
|
||||
editorVisible = value;
|
||||
});
|
||||
|
||||
function toggleEditor() {
|
||||
editorStore.toggleEditor();
|
||||
}
|
||||
const loopStatus = $derived(taskLoopStore.loopStatus);
|
||||
const loopTasks = $derived(taskLoopStore.tasks);
|
||||
const loopCurrentIndex = $derived(taskLoopStore.currentTaskIndex);
|
||||
const loopCompletedCount = $derived(
|
||||
$loopTasks.filter((t) => t.status === "completed" || t.status === "failed").length
|
||||
);
|
||||
const loopTotalCount = $derived($loopTasks.length);
|
||||
|
||||
onMount(async () => {
|
||||
appVersion = await getVersion();
|
||||
@@ -185,7 +156,8 @@
|
||||
working_dir: targetDir,
|
||||
model: currentConfig.model || null,
|
||||
api_key: currentConfig.api_key || null,
|
||||
custom_instructions: currentConfig.custom_instructions || null,
|
||||
custom_instructions:
|
||||
(currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
@@ -296,10 +268,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAchievements() {
|
||||
onToggleAchievements();
|
||||
}
|
||||
|
||||
async function handleCompactConversation() {
|
||||
const activeId = get(conversationsStore.activeConversationId);
|
||||
if (!activeId) return;
|
||||
@@ -345,7 +313,8 @@
|
||||
working_dir: workingDirectory || selectedDirectory,
|
||||
model: currentConfig.model || null,
|
||||
api_key: currentConfig.api_key || null,
|
||||
custom_instructions: currentConfig.custom_instructions || null,
|
||||
custom_instructions:
|
||||
(currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
@@ -445,312 +414,42 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if streamerModeActive}
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
|
||||
title="Streamer mode active (Ctrl+Shift+S to toggle)"
|
||||
></div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (showProfile = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover 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
|
||||
class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]"
|
||||
>
|
||||
{progress.unlocked}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showSessionHistory = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Session History"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (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 $loopStatus === "running" || $loopStatus === "paused"}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full border shrink-0 {$loopStatus === 'running'
|
||||
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 animate-pulse'
|
||||
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'}"
|
||||
title="Task loop {$loopStatus}"
|
||||
>
|
||||
Loop {$loopStatus === "running" ? "▶" : "⏸"}
|
||||
{loopCompletedCount +
|
||||
($loopStatus === "running" && $loopCurrentIndex >= 0 ? 1 : 0)}/{loopTotalCount}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<NavMenu
|
||||
{connectionStatus}
|
||||
{workingDirectory}
|
||||
{selectedDirectory}
|
||||
{isSummarising}
|
||||
{onToggleCompact}
|
||||
{onToggleAchievements}
|
||||
onCompactConversation={handleCompactConversation}
|
||||
onStartFreshWithContext={handleStartFreshWithContext}
|
||||
/>
|
||||
|
||||
{#if appVersion}
|
||||
<span class="text-xs text-gray-600">v{appVersion}</span>
|
||||
{/if}
|
||||
|
||||
{#if showStats}
|
||||
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
|
||||
<StatsDisplay
|
||||
onRequestSummary={handleCompactConversation}
|
||||
onStartFreshWithContext={handleStartFreshWithContext}
|
||||
{isSummarising}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if connectionStatus === "connected"}
|
||||
<button
|
||||
onclick={handleDisconnect}
|
||||
@@ -770,63 +469,6 @@
|
||||
</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}
|
||||
<WorkspaceTrustModal
|
||||
hookInfo={pendingHookInfo}
|
||||
@@ -843,18 +485,11 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Make all icon buttons shrink but not grow */
|
||||
/* Make all buttons shrink but not grow */
|
||||
.status-bar button {
|
||||
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 */
|
||||
@media (max-width: 768px) {
|
||||
.status-bar {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - Shows a text label for the connection state
|
||||
* - Provides connect/disconnect buttons
|
||||
* - Contains the working directory input and browse button
|
||||
* - Houses all toolbar action buttons (settings, stats, panels, etc.)
|
||||
* - Renders the NavMenu component for all toolbar actions
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Green dot and "Connected" label when Claude is running
|
||||
@@ -18,6 +18,7 @@
|
||||
* - [ ] 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";
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
<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,
|
||||
},
|
||||
});
|
||||
} 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,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
@@ -12,15 +13,27 @@
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||
|
||||
// Virtual windowing constants — keeps the DOM lean during long sessions
|
||||
const WINDOW_SIZE = 150; // max lines rendered at once
|
||||
const LOAD_CHUNK = 50; // how many older lines to load when scrolling up
|
||||
const AVG_LINE_HEIGHT = 60; // rough px estimate per line, used for top spacer
|
||||
|
||||
let terminalElement: HTMLDivElement;
|
||||
let shouldAutoScroll = true;
|
||||
let lines: TerminalLine[] = [];
|
||||
let currentSearchQuery = "";
|
||||
let currentConversationId: string | null = null;
|
||||
let isRestoringScroll = false;
|
||||
let windowStart = 0;
|
||||
let isLoadingMore = false;
|
||||
let isSwitchingConversation = false;
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
searchQuery.subscribe((value) => {
|
||||
currentSearchQuery = value;
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
currentSearchQuery = value;
|
||||
}, 150);
|
||||
});
|
||||
|
||||
let hidePaths = false;
|
||||
@@ -48,18 +61,42 @@
|
||||
|
||||
currentConversationId = newId;
|
||||
|
||||
// Restore scroll position for the new conversation after DOM updates
|
||||
// Guard the $: reactive auto-scroll block from firing with stale `lines`
|
||||
// (the old conversation's data) during the switch. Without this, Svelte's
|
||||
// reactive system can re-run the window-advance block before `terminalLines`
|
||||
// has recomputed for the new conversation, overriding our correct windowStart.
|
||||
isSwitchingConversation = true;
|
||||
|
||||
// Read the new conversation's lines directly from the store — the derived
|
||||
// `terminalLines` store (and thus `lines`) may not have recomputed yet when
|
||||
// this subscriber fires, so using `lines` here would give stale data.
|
||||
const newConvLines = get(claudeStore.conversations).get(newId)?.terminalLines ?? [];
|
||||
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||
if (savedPosition === -1) {
|
||||
// Will auto-scroll: pin the window to the tail of the new conversation
|
||||
shouldAutoScroll = true;
|
||||
windowStart = Math.max(0, newConvLines.length - WINDOW_SIZE);
|
||||
} else {
|
||||
// Will restore a specific position: always start from the top of history
|
||||
shouldAutoScroll = false;
|
||||
windowStart = 0;
|
||||
}
|
||||
|
||||
// Block the scroll handler during the entire DOM transition — scroll events
|
||||
// can fire mid-tick when the content changes, and handleScroll would see
|
||||
// scrollTop not at the bottom yet and set shouldAutoScroll = false, breaking
|
||||
// autoscroll for the new conversation permanently.
|
||||
isRestoringScroll = true;
|
||||
|
||||
// Restore scroll position for the new conversation after DOM updates.
|
||||
// Clear the switch guard first so the $: block can react to new lines
|
||||
// arriving after the switch settles.
|
||||
await tick();
|
||||
isSwitchingConversation = false;
|
||||
if (terminalElement) {
|
||||
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||
isRestoringScroll = true;
|
||||
if (savedPosition === -1) {
|
||||
// Auto-scroll to bottom
|
||||
shouldAutoScroll = true;
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
} else {
|
||||
// Restore to saved position
|
||||
shouldAutoScroll = false;
|
||||
terminalElement.scrollTop = savedPosition;
|
||||
}
|
||||
// Small delay to prevent the scroll handler from overriding our restore
|
||||
@@ -69,10 +106,30 @@
|
||||
}
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
async function handleScroll() {
|
||||
if (!terminalElement || isRestoringScroll) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
||||
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
||||
|
||||
// Load older lines when the user scrolls near the top of the visible window.
|
||||
// Use windowStart * AVG_LINE_HEIGHT (the spacer height) as the baseline so
|
||||
// we trigger at the top of the rendered content, not the absolute container top.
|
||||
if (scrollTop < windowStart * AVG_LINE_HEIGHT + 300 && windowStart > 0 && !isLoadingMore) {
|
||||
isLoadingMore = true;
|
||||
const prevScrollHeight = terminalElement.scrollHeight;
|
||||
const prevScrollTop = terminalElement.scrollTop;
|
||||
|
||||
windowStart = Math.max(0, windowStart - LOAD_CHUNK);
|
||||
|
||||
await tick();
|
||||
|
||||
if (terminalElement) {
|
||||
// Compensate for the new items pushing content down
|
||||
terminalElement.scrollTop =
|
||||
prevScrollTop + (terminalElement.scrollHeight - prevScrollHeight);
|
||||
}
|
||||
isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
@@ -138,6 +195,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Visible slice — only render lines within the current window
|
||||
$: visibleLines = lines.slice(windowStart, windowStart + WINDOW_SIZE);
|
||||
|
||||
// Height of the invisible spacer above the visible window
|
||||
$: topSpacerHeight = windowStart * AVG_LINE_HEIGHT;
|
||||
|
||||
// Advance the window forward when auto-scrolling and new lines overflow it.
|
||||
// Skip during conversation switches — `lines` may still hold the previous
|
||||
// conversation's data, which would push windowStart past the new conv's end.
|
||||
$: if (shouldAutoScroll && !isSwitchingConversation && lines.length > windowStart + WINDOW_SIZE) {
|
||||
windowStart = Math.max(0, lines.length - WINDOW_SIZE);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (currentSearchQuery && lines.length > 0) {
|
||||
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -179,6 +249,7 @@
|
||||
if (terminalElement) {
|
||||
terminalElement.removeEventListener("copy", handleCopy);
|
||||
}
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
|
||||
// Copy message content to clipboard
|
||||
@@ -238,7 +309,13 @@
|
||||
Waiting for Claude... Type a message below to start!
|
||||
</div>
|
||||
{: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 showThinking}
|
||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||
@@ -428,6 +505,15 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-older-indicator {
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
padding: 0.25rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.terminal-header-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,27 @@ function truncateToolContent(content: string): string {
|
||||
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||
}
|
||||
|
||||
// Virtual windowing helpers — mirror the logic in Terminal.svelte
|
||||
|
||||
const WINDOW_SIZE = 150;
|
||||
const LOAD_CHUNK = 50;
|
||||
const AVG_LINE_HEIGHT = 60;
|
||||
|
||||
/** Returns the windowStart index when auto-scrolling to the bottom. */
|
||||
function autoScrollWindowStart(linesLength: number, windowSize: number): number {
|
||||
return Math.max(0, linesLength - windowSize);
|
||||
}
|
||||
|
||||
/** Returns the new windowStart after loading LOAD_CHUNK older messages. */
|
||||
function olderWindowStart(currentStart: number, chunkSize: number): number {
|
||||
return Math.max(0, currentStart - chunkSize);
|
||||
}
|
||||
|
||||
/** Returns the height in pixels of the invisible top spacer. */
|
||||
function topSpacerHeight(windowStart: number, avgLineHeight: number): number {
|
||||
return windowStart * avgLineHeight;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getLineClass", () => {
|
||||
@@ -262,3 +283,86 @@ describe("truncateToolContent", () => {
|
||||
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,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;
|
||||
}
|
||||
@@ -1471,11 +1471,14 @@ export const achievementsByRarity = derived(achievementsStore, ($store) => {
|
||||
return byRarity;
|
||||
});
|
||||
|
||||
export const achievementProgress = derived(achievementsStore, ($store) => ({
|
||||
unlocked: $store.totalUnlocked,
|
||||
total: Object.keys($store.achievements).length,
|
||||
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
|
||||
}));
|
||||
export const achievementProgress = derived(achievementsStore, ($store) => {
|
||||
const total = Object.keys($store.achievements).length;
|
||||
return {
|
||||
unlocked: $store.totalUnlocked,
|
||||
total,
|
||||
percentage: Math.round(($store.totalUnlocked / total) * 100),
|
||||
};
|
||||
});
|
||||
|
||||
// Initialize achievement listener
|
||||
export async function initAchievementsListener() {
|
||||
|
||||
@@ -217,6 +217,9 @@ describe("config store", () => {
|
||||
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,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -273,6 +276,9 @@ describe("config store", () => {
|
||||
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,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
@@ -337,6 +343,62 @@ describe("config store", () => {
|
||||
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", () => {
|
||||
const colors: CustomThemeColors = {
|
||||
bg_primary: "#1a1a2e",
|
||||
@@ -828,6 +890,9 @@ describe("config store", () => {
|
||||
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,
|
||||
};
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
|
||||
@@ -2,7 +2,19 @@ import { writable, derived } from "svelte/store";
|
||||
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 interface CustomThemeColors {
|
||||
@@ -65,6 +77,10 @@ export interface HikariConfig {
|
||||
// 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;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -115,6 +131,9 @@ const defaultConfig: HikariConfig = {
|
||||
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,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
|
||||
@@ -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,325 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
findNextPendingIndex,
|
||||
countByStatus,
|
||||
buildTaskPrompt,
|
||||
buildAutoCommitPrompt,
|
||||
normalizeToUnixPath,
|
||||
isTaskBlocked,
|
||||
getReadyTasks,
|
||||
computeWaves,
|
||||
type TaskLoopTask,
|
||||
} from "./taskLoop";
|
||||
|
||||
const makeTask = (
|
||||
id: string,
|
||||
status: TaskLoopTask["status"] = "pending",
|
||||
dependsOn?: string[]
|
||||
): TaskLoopTask => ({
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
prompt: `Do the thing for ${id}`,
|
||||
priority: "medium",
|
||||
status,
|
||||
dependsOn,
|
||||
});
|
||||
|
||||
describe("findNextPendingIndex", () => {
|
||||
it("returns -1 for an empty list", () => {
|
||||
expect(findNextPendingIndex([])).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns 0 when the first task is pending", () => {
|
||||
const tasks = [makeTask("1", "pending"), makeTask("2", "pending")];
|
||||
expect(findNextPendingIndex(tasks)).toBe(0);
|
||||
});
|
||||
|
||||
it("skips completed and failed tasks to find the next pending", () => {
|
||||
const tasks = [
|
||||
makeTask("1", "completed"),
|
||||
makeTask("2", "failed"),
|
||||
makeTask("3", "pending"),
|
||||
makeTask("4", "pending"),
|
||||
];
|
||||
expect(findNextPendingIndex(tasks)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns -1 when all tasks are completed", () => {
|
||||
const tasks = [makeTask("1", "completed"), makeTask("2", "completed")];
|
||||
expect(findNextPendingIndex(tasks)).toBe(-1);
|
||||
});
|
||||
|
||||
it("skips running tasks", () => {
|
||||
const tasks = [makeTask("1", "running"), makeTask("2", "pending")];
|
||||
expect(findNextPendingIndex(tasks)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countByStatus", () => {
|
||||
it("returns 0 for an empty list", () => {
|
||||
expect(countByStatus([], "pending")).toBe(0);
|
||||
});
|
||||
|
||||
it("counts only tasks with the specified status", () => {
|
||||
const tasks = [
|
||||
makeTask("1", "pending"),
|
||||
makeTask("2", "completed"),
|
||||
makeTask("3", "pending"),
|
||||
makeTask("4", "failed"),
|
||||
];
|
||||
expect(countByStatus(tasks, "pending")).toBe(2);
|
||||
expect(countByStatus(tasks, "completed")).toBe(1);
|
||||
expect(countByStatus(tasks, "failed")).toBe(1);
|
||||
expect(countByStatus(tasks, "running")).toBe(0);
|
||||
});
|
||||
|
||||
it("counts all tasks when all have the same status", () => {
|
||||
const tasks = [makeTask("1", "completed"), makeTask("2", "completed")];
|
||||
expect(countByStatus(tasks, "completed")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskPrompt", () => {
|
||||
it("includes the task number and total", () => {
|
||||
const task = makeTask("1");
|
||||
const result = buildTaskPrompt(task, 1, 5);
|
||||
expect(result).toContain("1/5");
|
||||
});
|
||||
|
||||
it("includes the task title", () => {
|
||||
const task = makeTask("abc");
|
||||
const result = buildTaskPrompt(task, 2, 3);
|
||||
expect(result).toContain("Task abc");
|
||||
});
|
||||
|
||||
it("includes the task prompt", () => {
|
||||
const task = makeTask("x");
|
||||
const result = buildTaskPrompt(task, 1, 1);
|
||||
expect(result).toContain("Do the thing for x");
|
||||
});
|
||||
|
||||
it("labels the output as an automated task loop entry", () => {
|
||||
const task = makeTask("1");
|
||||
const result = buildTaskPrompt(task, 1, 1);
|
||||
expect(result).toContain("Automated Task Loop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAutoCommitPrompt", () => {
|
||||
const task = makeTask("task-1");
|
||||
|
||||
it("includes the git add and commit commands", () => {
|
||||
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
|
||||
expect(result).toContain("git add -A");
|
||||
expect(result).toContain("git commit -m");
|
||||
});
|
||||
|
||||
it("uses the provided prefix in the commit message", () => {
|
||||
const result = buildAutoCommitPrompt(task, "fix", false, "2026-03-07T12:00:00");
|
||||
expect(result).toContain("fix:");
|
||||
});
|
||||
|
||||
it("includes the task title in the commit message", () => {
|
||||
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
|
||||
expect(result).toContain("Task task-1");
|
||||
});
|
||||
|
||||
it("includes the task id in the commit body", () => {
|
||||
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
|
||||
expect(result).toContain("task-1");
|
||||
});
|
||||
|
||||
it("includes the session timestamp in the commit body", () => {
|
||||
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
|
||||
expect(result).toContain("2026-03-07T12:00:00");
|
||||
});
|
||||
|
||||
it("does not include SUMMARY.md instructions when includeSummary is false", () => {
|
||||
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
|
||||
expect(result).not.toContain("SUMMARY.md");
|
||||
});
|
||||
|
||||
it("includes SUMMARY.md instructions when includeSummary is true", () => {
|
||||
const result = buildAutoCommitPrompt(task, "feat", true, "2026-03-07T12:00:00");
|
||||
expect(result).toContain("SUMMARY.md");
|
||||
});
|
||||
|
||||
it("mentions non-blocking failure handling", () => {
|
||||
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
|
||||
expect(result).toContain("do not retry");
|
||||
});
|
||||
|
||||
it("escapes double quotes in the task title", () => {
|
||||
const quotedTask = makeTask("q1");
|
||||
quotedTask.title = 'Fix "quoted" title';
|
||||
const result = buildAutoCommitPrompt(quotedTask, "fix", false, "2026-03-07T12:00:00");
|
||||
expect(result).toContain('\\"quoted\\"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeToUnixPath", () => {
|
||||
it("converts a WSL UNC path with wsl.localhost to a Unix path", () => {
|
||||
expect(
|
||||
normalizeToUnixPath("\\\\wsl.localhost\\Ubuntu\\home\\naomi\\code\\temp\\file.json")
|
||||
).toBe("/home/naomi/code/temp/file.json");
|
||||
});
|
||||
|
||||
it("converts a WSL UNC path with wsl$ (legacy) to a Unix path", () => {
|
||||
expect(normalizeToUnixPath("\\\\wsl$\\Ubuntu\\home\\naomi\\file.json")).toBe(
|
||||
"/home/naomi/file.json"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles forward-slash UNC paths produced by some tools", () => {
|
||||
expect(normalizeToUnixPath("//wsl.localhost/Ubuntu/home/naomi/file.json")).toBe(
|
||||
"/home/naomi/file.json"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a plain Unix path unchanged", () => {
|
||||
expect(normalizeToUnixPath("/home/naomi/code/temp/file.json")).toBe(
|
||||
"/home/naomi/code/temp/file.json"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an empty string unchanged", () => {
|
||||
expect(normalizeToUnixPath("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTaskBlocked", () => {
|
||||
it("returns false when dependsOn is empty", () => {
|
||||
const task = makeTask("a", "pending", []);
|
||||
expect(isTaskBlocked(task, [task])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when dependsOn is undefined", () => {
|
||||
const task = makeTask("a", "pending");
|
||||
expect(isTaskBlocked(task, [task])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when all dependencies are completed", () => {
|
||||
const dep = makeTask("dep", "completed");
|
||||
const task = makeTask("a", "pending", ["dep"]);
|
||||
expect(isTaskBlocked(task, [dep, task])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when a dependency has failed", () => {
|
||||
const dep = makeTask("dep", "failed");
|
||||
const task = makeTask("a", "pending", ["dep"]);
|
||||
expect(isTaskBlocked(task, [dep, task])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when a dependency is blocked", () => {
|
||||
const dep = makeTask("dep", "blocked");
|
||||
const task = makeTask("a", "pending", ["dep"]);
|
||||
expect(isTaskBlocked(task, [dep, task])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when a dependency is still pending (not yet failed)", () => {
|
||||
const dep = makeTask("dep", "pending");
|
||||
const task = makeTask("a", "pending", ["dep"]);
|
||||
expect(isTaskBlocked(task, [dep, task])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when dependency ID does not exist in task list", () => {
|
||||
const task = makeTask("a", "pending", ["nonexistent"]);
|
||||
expect(isTaskBlocked(task, [task])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getReadyTasks", () => {
|
||||
it("returns empty array when task list is empty", () => {
|
||||
expect(getReadyTasks([], 3)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all pending tasks with no deps when under limit", () => {
|
||||
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
|
||||
expect(getReadyTasks(tasks, 5)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("respects the concurrency limit", () => {
|
||||
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
|
||||
expect(getReadyTasks(tasks, 2)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("skips tasks whose dependencies are not completed", () => {
|
||||
const tasks = [makeTask("a", "pending"), makeTask("b", "pending", ["a"])];
|
||||
// b depends on a which is pending, not completed — so only a is ready
|
||||
expect(getReadyTasks(tasks, 5)).toEqual([0]);
|
||||
});
|
||||
|
||||
it("includes task when all its dependencies are completed", () => {
|
||||
const tasks = [makeTask("a", "completed"), makeTask("b", "pending", ["a"])];
|
||||
expect(getReadyTasks(tasks, 5)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("skips running, completed, failed, and blocked tasks", () => {
|
||||
const tasks = [
|
||||
makeTask("a", "running"),
|
||||
makeTask("b", "completed"),
|
||||
makeTask("c", "failed"),
|
||||
makeTask("d", "blocked"),
|
||||
makeTask("e", "pending"),
|
||||
];
|
||||
expect(getReadyTasks(tasks, 5)).toEqual([4]);
|
||||
});
|
||||
|
||||
it("returns empty when limit is 0", () => {
|
||||
const tasks = [makeTask("a", "pending")];
|
||||
expect(getReadyTasks(tasks, 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeWaves", () => {
|
||||
it("returns empty array for empty task list", () => {
|
||||
expect(computeWaves([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("puts all independent tasks in a single wave", () => {
|
||||
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
|
||||
expect(computeWaves(tasks)).toEqual([[0, 1, 2]]);
|
||||
});
|
||||
|
||||
it("creates one wave per task for a linear chain", () => {
|
||||
const tasks = [
|
||||
makeTask("a", "pending"),
|
||||
makeTask("b", "pending", ["a"]),
|
||||
makeTask("c", "pending", ["b"]),
|
||||
];
|
||||
expect(computeWaves(tasks)).toEqual([[0], [1], [2]]);
|
||||
});
|
||||
|
||||
it("handles diamond dependency: A → B,C → D", () => {
|
||||
// A has no deps, B and C depend on A, D depends on B and C
|
||||
const tasks = [
|
||||
makeTask("a", "pending"),
|
||||
makeTask("b", "pending", ["a"]),
|
||||
makeTask("c", "pending", ["a"]),
|
||||
makeTask("d", "pending", ["b", "c"]),
|
||||
];
|
||||
const waves = computeWaves(tasks);
|
||||
expect(waves).toHaveLength(3);
|
||||
expect(waves[0]).toEqual([0]);
|
||||
expect(waves[1]).toEqual([1, 2]);
|
||||
expect(waves[2]).toEqual([3]);
|
||||
});
|
||||
|
||||
it("groups circular dependencies into a final overflow wave", () => {
|
||||
// a→b, b→a — circular; c has no deps so goes in wave 0
|
||||
const tasks = [
|
||||
makeTask("a", "pending", ["b"]),
|
||||
makeTask("b", "pending", ["a"]),
|
||||
makeTask("c", "pending"),
|
||||
];
|
||||
const waves = computeWaves(tasks);
|
||||
// c goes in wave 0, then a+b get dumped in overflow
|
||||
expect(waves[0]).toEqual([2]);
|
||||
expect(waves[1]).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("ignores unknown dependency IDs (treats them as satisfied)", () => {
|
||||
const tasks = [makeTask("a", "pending", ["nonexistent"])];
|
||||
expect(computeWaves(tasks)).toEqual([[0]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { PrdTask, PrdFile } from "./prd";
|
||||
|
||||
export type TaskStatus = "pending" | "running" | "completed" | "failed" | "blocked";
|
||||
export type LoopStatus = "idle" | "running" | "paused" | "stopped";
|
||||
|
||||
export interface TaskLoopTask extends PrdTask {
|
||||
status: TaskStatus;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
/** Returns the index of the first pending task, or -1 if none. */
|
||||
export function findNextPendingIndex(tasks: TaskLoopTask[]): number {
|
||||
return tasks.findIndex((t) => t.status === "pending");
|
||||
}
|
||||
|
||||
/** Counts tasks with the given status. */
|
||||
export function countByStatus(tasks: TaskLoopTask[], status: TaskStatus): number {
|
||||
return tasks.filter((t) => t.status === status).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a task is blocked — i.e. any of its `dependsOn` IDs refer to a
|
||||
* task that has failed or is already blocked.
|
||||
*/
|
||||
export function isTaskBlocked(task: TaskLoopTask, allTasks: TaskLoopTask[]): boolean {
|
||||
if (!task.dependsOn || task.dependsOn.length === 0) return false;
|
||||
return task.dependsOn.some((depId) => {
|
||||
const dep = allTasks.find((t) => t.id === depId);
|
||||
return dep !== undefined && (dep.status === "failed" || dep.status === "blocked");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns indices of tasks that are ready to start: status is `pending` and all
|
||||
* `dependsOn` tasks are `completed`. Respects `limit` (concurrency cap).
|
||||
*/
|
||||
export function getReadyTasks(tasks: TaskLoopTask[], limit: number): number[] {
|
||||
const ready: number[] = [];
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
if (ready.length >= limit) break;
|
||||
const task = tasks[i];
|
||||
if (task.status !== "pending") continue;
|
||||
const depsAllDone =
|
||||
!task.dependsOn ||
|
||||
task.dependsOn.length === 0 ||
|
||||
task.dependsOn.every((depId) => {
|
||||
const dep = tasks.find((t) => t.id === depId);
|
||||
return dep === undefined || dep.status === "completed";
|
||||
});
|
||||
if (depsAllDone) {
|
||||
ready.push(i);
|
||||
}
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups task indices into waves for UI display. Tasks with no pending dependencies
|
||||
* form wave 0; tasks whose deps are all in earlier waves form the next wave, etc.
|
||||
* Circular dependencies are collected into a final "overflow" wave.
|
||||
*/
|
||||
export function computeWaves(tasks: TaskLoopTask[]): number[][] {
|
||||
const waves: number[][] = [];
|
||||
const assigned = new Set<number>();
|
||||
|
||||
// Build an id→index map
|
||||
const idToIndex = new Map<string, number>();
|
||||
tasks.forEach((t, i) => idToIndex.set(t.id, i));
|
||||
|
||||
let remaining = tasks.map((_, i) => i).filter((i) => !assigned.has(i));
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const wave: number[] = [];
|
||||
for (const i of remaining) {
|
||||
const task = tasks[i];
|
||||
const depsAllAssigned =
|
||||
!task.dependsOn ||
|
||||
task.dependsOn.length === 0 ||
|
||||
task.dependsOn.every((depId) => {
|
||||
const depIdx = idToIndex.get(depId);
|
||||
return depIdx === undefined || assigned.has(depIdx);
|
||||
});
|
||||
if (depsAllAssigned) {
|
||||
wave.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (wave.length === 0) {
|
||||
// Circular dependency — dump all remaining into a single wave
|
||||
waves.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
wave.forEach((i) => assigned.add(i));
|
||||
waves.push(wave);
|
||||
remaining = remaining.filter((i) => !assigned.has(i));
|
||||
}
|
||||
|
||||
return waves;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises a file-picker path to a Unix path.
|
||||
*
|
||||
* On Windows/WSL the dialog returns a UNC path like:
|
||||
* \\wsl.localhost\Ubuntu\home\naomi\code\temp\hikari-tasks.json
|
||||
* which the WSL-side Claude process cannot use as a working directory.
|
||||
* This converts that to /home/naomi/code/temp/hikari-tasks.json.
|
||||
*/
|
||||
export function normalizeToUnixPath(path: string): string {
|
||||
// Matches both \\wsl.localhost\<distro>\... and \\wsl$\<distro>\... (legacy)
|
||||
const wslUncMatch = /^[/\\][/\\]wsl(?:\.localhost|\$)?[/\\][^/\\]+(.*)$/i.exec(path);
|
||||
if (wslUncMatch) {
|
||||
return wslUncMatch[1].replaceAll("\\", "/");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the prompt sent to Claude after a task completes to commit the changes.
|
||||
* If `includeSummary` is true, Claude is also asked to write/append to SUMMARY.md first.
|
||||
*/
|
||||
export function buildAutoCommitPrompt(
|
||||
task: TaskLoopTask,
|
||||
prefix: string,
|
||||
includeSummary: boolean,
|
||||
sessionTimestamp: string
|
||||
): string {
|
||||
const escapedTitle = task.title.replaceAll('"', '\\"');
|
||||
const commitMsg = `${prefix}: ${escapedTitle}\\n\\nAuto-committed by Hikari Task Loop\\nTask ID: ${task.id}\\nLoop session: ${sessionTimestamp}`;
|
||||
|
||||
const gitCommands = `git add -A && git commit -m "${commitMsg}"`;
|
||||
|
||||
const summaryRequest = includeSummary
|
||||
? `\n\nBefore committing, please write or append to \`SUMMARY.md\` in the working directory with:\n- What was implemented\n- Key decisions made\n- Files changed\n- Any caveats or follow-up work\n\nInclude SUMMARY.md in the commit.\n`
|
||||
: "";
|
||||
|
||||
return `[Auto-commit] Please run the following in the current working directory:${summaryRequest}
|
||||
|
||||
\`\`\`bash
|
||||
${gitCommands}
|
||||
\`\`\`
|
||||
|
||||
If this fails (e.g. nothing to commit, no git repository), acknowledge it briefly and do not retry.`;
|
||||
}
|
||||
|
||||
/** Builds the prompt sent to Claude Code for an automated task. */
|
||||
export function buildTaskPrompt(
|
||||
task: TaskLoopTask,
|
||||
taskNumber: number,
|
||||
totalTasks: number
|
||||
): string {
|
||||
return `[Automated Task Loop — Task ${taskNumber}/${totalTasks}]\n\n**${task.title}**\n\n${task.prompt}`;
|
||||
}
|
||||
|
||||
function createTaskLoopStore() {
|
||||
const tasks = writable<TaskLoopTask[]>([]);
|
||||
const loopStatus = writable<LoopStatus>("idle");
|
||||
const currentTaskIndex = writable<number>(-1);
|
||||
const sourceFile = writable<string>("");
|
||||
const concurrencyLimit = writable<number>(3);
|
||||
|
||||
async function loadFile(path: string): Promise<void> {
|
||||
const content = await invoke<string>("read_file_content", { path });
|
||||
const data = JSON.parse(content) as PrdFile;
|
||||
const loopTasks: TaskLoopTask[] = data.tasks.map((t) => ({ ...t, status: "pending" }));
|
||||
tasks.set(loopTasks);
|
||||
sourceFile.set(path);
|
||||
loopStatus.set("idle");
|
||||
currentTaskIndex.set(-1);
|
||||
}
|
||||
|
||||
function setTaskStatus(index: number, status: TaskStatus): void {
|
||||
tasks.update((current) => {
|
||||
const result = [...current];
|
||||
if (result[index]) {
|
||||
result[index] = { ...result[index], status };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function setTaskConversationId(index: number, conversationId: string): void {
|
||||
tasks.update((current) => {
|
||||
const result = [...current];
|
||||
if (result[index]) {
|
||||
result[index] = { ...result[index], conversationId };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function setLoopStatus(status: LoopStatus): void {
|
||||
loopStatus.set(status);
|
||||
}
|
||||
|
||||
function setCurrentTaskIndex(index: number): void {
|
||||
currentTaskIndex.set(index);
|
||||
}
|
||||
|
||||
function setConcurrencyLimit(limit: number): void {
|
||||
concurrencyLimit.set(Math.max(1, limit));
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
tasks.set([]);
|
||||
loopStatus.set("idle");
|
||||
currentTaskIndex.set(-1);
|
||||
sourceFile.set("");
|
||||
}
|
||||
|
||||
return {
|
||||
tasks: { subscribe: tasks.subscribe },
|
||||
loopStatus: { subscribe: loopStatus.subscribe },
|
||||
currentTaskIndex: { subscribe: currentTaskIndex.subscribe },
|
||||
sourceFile: { subscribe: sourceFile.subscribe },
|
||||
concurrencyLimit: { subscribe: concurrencyLimit.subscribe },
|
||||
loadFile,
|
||||
setTaskStatus,
|
||||
setTaskConversationId,
|
||||
setLoopStatus,
|
||||
setCurrentTaskIndex,
|
||||
setConcurrencyLimit,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const taskLoopStore = createTaskLoopStore();
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildDiscussPrompt,
|
||||
buildVerifyPrompt,
|
||||
canAdvancePhase,
|
||||
canGoBack,
|
||||
getPhaseLabel,
|
||||
generateCriterionId,
|
||||
type WorkflowState,
|
||||
type VerifyCriterion,
|
||||
} from "./workflow";
|
||||
|
||||
// ─── buildDiscussPrompt ───────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDiscussPrompt", () => {
|
||||
it("includes the description in the output", () => {
|
||||
const prompt = buildDiscussPrompt("Build a user authentication system");
|
||||
expect(prompt).toContain("Build a user authentication system");
|
||||
});
|
||||
|
||||
it("references CONTEXT.md", () => {
|
||||
const prompt = buildDiscussPrompt("some project");
|
||||
expect(prompt).toContain("CONTEXT.md");
|
||||
});
|
||||
|
||||
it("mentions acceptance criteria section", () => {
|
||||
const prompt = buildDiscussPrompt("some project");
|
||||
expect(prompt.toLowerCase()).toContain("acceptance criteria");
|
||||
});
|
||||
|
||||
it("returns a non-empty string for any description", () => {
|
||||
expect(buildDiscussPrompt("a").length).toBeGreaterThan(0);
|
||||
expect(buildDiscussPrompt("very long description ".repeat(20)).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildVerifyPrompt ────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildVerifyPrompt", () => {
|
||||
it("references VERIFY.md", () => {
|
||||
const prompt = buildVerifyPrompt([]);
|
||||
expect(prompt).toContain("VERIFY.md");
|
||||
});
|
||||
|
||||
it("handles empty criteria list gracefully", () => {
|
||||
const prompt = buildVerifyPrompt([]);
|
||||
expect(prompt.length).toBeGreaterThan(0);
|
||||
expect(prompt).not.toContain("undefined");
|
||||
});
|
||||
|
||||
it("includes all criteria text in the output", () => {
|
||||
const criteria: VerifyCriterion[] = [
|
||||
{ id: "c1", text: "Login must work", status: "pending" },
|
||||
{ id: "c2", text: "Tests must pass", status: "pending" },
|
||||
];
|
||||
const prompt = buildVerifyPrompt(criteria);
|
||||
expect(prompt).toContain("Login must work");
|
||||
expect(prompt).toContain("Tests must pass");
|
||||
});
|
||||
|
||||
it("numbers criteria starting from 1", () => {
|
||||
const criteria: VerifyCriterion[] = [{ id: "c1", text: "First criterion", status: "pending" }];
|
||||
const prompt = buildVerifyPrompt(criteria);
|
||||
expect(prompt).toContain("1. First criterion");
|
||||
});
|
||||
|
||||
it("includes all criteria when multiple are provided", () => {
|
||||
const criteria: VerifyCriterion[] = [
|
||||
{ id: "c1", text: "Alpha", status: "pending" },
|
||||
{ id: "c2", text: "Beta", status: "pending" },
|
||||
{ id: "c3", text: "Gamma", status: "pending" },
|
||||
];
|
||||
const prompt = buildVerifyPrompt(criteria);
|
||||
expect(prompt).toContain("1. Alpha");
|
||||
expect(prompt).toContain("2. Beta");
|
||||
expect(prompt).toContain("3. Gamma");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canAdvancePhase ──────────────────────────────────────────────────────────
|
||||
|
||||
function makeState(overrides: Partial<WorkflowState> = {}): WorkflowState {
|
||||
return {
|
||||
version: 1,
|
||||
currentPhase: 1,
|
||||
quickMode: false,
|
||||
discuss: { description: "", contextCaptured: false },
|
||||
plan: { tasksApproved: false },
|
||||
execute: { completed: false },
|
||||
verify: { criteria: [], verificationComplete: false, report: "" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("canAdvancePhase", () => {
|
||||
describe("phase 1 (Discuss)", () => {
|
||||
it("returns false when context not captured and not quick mode", () => {
|
||||
const state = makeState({ currentPhase: 1 });
|
||||
expect(canAdvancePhase(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when context is captured", () => {
|
||||
const state = makeState({
|
||||
currentPhase: 1,
|
||||
discuss: { description: "test", contextCaptured: true },
|
||||
});
|
||||
expect(canAdvancePhase(state)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true in quick mode even without context captured", () => {
|
||||
const state = makeState({ currentPhase: 1, quickMode: true });
|
||||
expect(canAdvancePhase(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase 2 (Plan)", () => {
|
||||
it("returns false when plan not approved", () => {
|
||||
const state = makeState({ currentPhase: 2 });
|
||||
expect(canAdvancePhase(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when plan is approved", () => {
|
||||
const state = makeState({ currentPhase: 2, plan: { tasksApproved: true } });
|
||||
expect(canAdvancePhase(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase 3 (Execute)", () => {
|
||||
it("returns false when execution not completed", () => {
|
||||
const state = makeState({ currentPhase: 3 });
|
||||
expect(canAdvancePhase(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when execution is completed", () => {
|
||||
const state = makeState({ currentPhase: 3, execute: { completed: true } });
|
||||
expect(canAdvancePhase(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase 4 (Verify)", () => {
|
||||
it("returns false when verification not complete", () => {
|
||||
const state = makeState({ currentPhase: 4 });
|
||||
expect(canAdvancePhase(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when verification is complete", () => {
|
||||
const state = makeState({
|
||||
currentPhase: 4,
|
||||
verify: { criteria: [], verificationComplete: true, report: "All good" },
|
||||
});
|
||||
expect(canAdvancePhase(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── canGoBack ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("canGoBack", () => {
|
||||
it("returns false for phase 1", () => {
|
||||
expect(canGoBack(1)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for phase 2", () => {
|
||||
expect(canGoBack(2)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for phase 3", () => {
|
||||
expect(canGoBack(3)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for phase 4", () => {
|
||||
expect(canGoBack(4)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getPhaseLabel ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getPhaseLabel", () => {
|
||||
it("returns Discuss for phase 1", () => {
|
||||
expect(getPhaseLabel(1)).toBe("Discuss");
|
||||
});
|
||||
|
||||
it("returns Plan for phase 2", () => {
|
||||
expect(getPhaseLabel(2)).toBe("Plan");
|
||||
});
|
||||
|
||||
it("returns Execute for phase 3", () => {
|
||||
expect(getPhaseLabel(3)).toBe("Execute");
|
||||
});
|
||||
|
||||
it("returns Verify for phase 4", () => {
|
||||
expect(getPhaseLabel(4)).toBe("Verify");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── generateCriterionId ─────────────────────────────────────────────────────
|
||||
|
||||
describe("generateCriterionId", () => {
|
||||
it("returns a non-empty string", () => {
|
||||
expect(generateCriterionId().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns unique IDs on successive calls", () => {
|
||||
const ids = new Set(Array.from({ length: 20 }, () => generateCriterionId()));
|
||||
expect(ids.size).toBe(20);
|
||||
});
|
||||
|
||||
it("starts with the expected prefix", () => {
|
||||
expect(generateCriterionId()).toMatch(/^criterion-/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type WorkflowPhase = 1 | 2 | 3 | 4;
|
||||
export type CriterionStatus = "pending" | "pass" | "fail" | "partial";
|
||||
|
||||
export interface VerifyCriterion {
|
||||
id: string;
|
||||
text: string;
|
||||
status: CriterionStatus;
|
||||
}
|
||||
|
||||
export interface WorkflowState {
|
||||
version: 1;
|
||||
currentPhase: WorkflowPhase;
|
||||
quickMode: boolean;
|
||||
discuss: {
|
||||
description: string;
|
||||
contextCaptured: boolean;
|
||||
};
|
||||
plan: {
|
||||
tasksApproved: boolean;
|
||||
};
|
||||
execute: {
|
||||
completed: boolean;
|
||||
};
|
||||
verify: {
|
||||
criteria: VerifyCriterion[];
|
||||
verificationComplete: boolean;
|
||||
report: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const WORKFLOW_STATE_FILENAME = "workflow-state.json";
|
||||
|
||||
const DEFAULT_STATE: WorkflowState = {
|
||||
version: 1,
|
||||
currentPhase: 1,
|
||||
quickMode: false,
|
||||
discuss: { description: "", contextCaptured: false },
|
||||
plan: { tasksApproved: false },
|
||||
execute: { completed: false },
|
||||
verify: { criteria: [], verificationComplete: false, report: "" },
|
||||
};
|
||||
|
||||
// ─── Pure functions (exported for testing) ───────────────────────────────────
|
||||
|
||||
export function buildDiscussPrompt(description: string): string {
|
||||
return `Please help me clarify and document the following project goal, then write a \`CONTEXT.md\` file in the working directory.
|
||||
|
||||
Project description:
|
||||
${description}
|
||||
|
||||
The \`CONTEXT.md\` file should include:
|
||||
|
||||
## Goal
|
||||
A clear, one-paragraph statement of what we are building and why.
|
||||
|
||||
## Scope
|
||||
What is in scope and what is explicitly out of scope.
|
||||
|
||||
## Acceptance Criteria
|
||||
A numbered list of concrete, verifiable criteria that must be met for this project to be considered complete. Each criterion should be specific and testable.
|
||||
|
||||
## Key Assumptions
|
||||
Any assumptions being made about the implementation, environment, or user needs.
|
||||
|
||||
## Open Questions
|
||||
Any questions that need to be resolved before or during development.
|
||||
|
||||
Write the file concisely but thoroughly. Focus on information that guides implementation and defines success.`;
|
||||
}
|
||||
|
||||
export function buildVerifyPrompt(criteria: VerifyCriterion[]): string {
|
||||
if (criteria.length === 0) {
|
||||
return `Please review the project implementation and write a \`VERIFY.md\` file in the working directory with your overall assessment.
|
||||
|
||||
Include:
|
||||
## Summary
|
||||
Overall pass/fail assessment.
|
||||
|
||||
## Findings
|
||||
What you found when reviewing the implementation.
|
||||
|
||||
## Recommendation
|
||||
Whether the project is ready to ship or what remains to be done.`;
|
||||
}
|
||||
|
||||
const criteriaList = criteria.map((c, i) => `${i + 1}. ${c.text}`).join("\n");
|
||||
|
||||
return `Please verify the project implementation against the following acceptance criteria and write a \`VERIFY.md\` file in the working directory.
|
||||
|
||||
Acceptance criteria:
|
||||
${criteriaList}
|
||||
|
||||
For each criterion, check whether it is met by examining the codebase and any relevant files. Then write \`VERIFY.md\` with:
|
||||
|
||||
## Summary
|
||||
Overall PASSED / FAILED / PARTIAL status.
|
||||
|
||||
## Criterion Results
|
||||
For each criterion: state whether it PASSES, FAILS, or is PARTIAL, with a brief explanation.
|
||||
|
||||
## Findings
|
||||
Any notable issues, edge cases, or improvements spotted during review.
|
||||
|
||||
## Recommendation
|
||||
Whether the project is ready to ship or what remains to be done.`;
|
||||
}
|
||||
|
||||
export function canAdvancePhase(state: WorkflowState): boolean {
|
||||
switch (state.currentPhase) {
|
||||
case 1:
|
||||
return state.quickMode || state.discuss.contextCaptured;
|
||||
case 2:
|
||||
return state.plan.tasksApproved;
|
||||
case 3:
|
||||
return state.execute.completed;
|
||||
case 4:
|
||||
return state.verify.verificationComplete;
|
||||
}
|
||||
}
|
||||
|
||||
export function canGoBack(phase: WorkflowPhase): boolean {
|
||||
return phase > 1;
|
||||
}
|
||||
|
||||
export function getPhaseLabel(phase: WorkflowPhase): string {
|
||||
switch (phase) {
|
||||
case 1:
|
||||
return "Discuss";
|
||||
case 2:
|
||||
return "Plan";
|
||||
case 3:
|
||||
return "Execute";
|
||||
case 4:
|
||||
return "Verify";
|
||||
}
|
||||
}
|
||||
|
||||
export function generateCriterionId(): string {
|
||||
return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function createWorkflowStore() {
|
||||
const state = writable<WorkflowState>({ ...DEFAULT_STATE });
|
||||
|
||||
async function loadState(workingDirectory: string): Promise<void> {
|
||||
try {
|
||||
const path = `${workingDirectory}/${WORKFLOW_STATE_FILENAME}`;
|
||||
const content = await invoke<string>("read_file_content", { path });
|
||||
const parsed = JSON.parse(content) as WorkflowState;
|
||||
state.set(parsed);
|
||||
} catch {
|
||||
state.set({ ...DEFAULT_STATE });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState(workingDirectory: string): Promise<void> {
|
||||
try {
|
||||
const path = `${workingDirectory}/${WORKFLOW_STATE_FILENAME}`;
|
||||
const current = get(state);
|
||||
await invoke("write_file_content", { path, content: JSON.stringify(current, null, 2) });
|
||||
} catch (error) {
|
||||
console.error("Failed to save workflow state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function setPhase(phase: WorkflowPhase): void {
|
||||
state.update((s) => ({ ...s, currentPhase: phase }));
|
||||
}
|
||||
|
||||
function setQuickMode(value: boolean): void {
|
||||
state.update((s) => ({ ...s, quickMode: value }));
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
state.set({ ...DEFAULT_STATE });
|
||||
}
|
||||
|
||||
function setDiscussDescription(text: string): void {
|
||||
state.update((s) => ({ ...s, discuss: { ...s.discuss, description: text } }));
|
||||
}
|
||||
|
||||
function markContextCaptured(): void {
|
||||
state.update((s) => ({ ...s, discuss: { ...s.discuss, contextCaptured: true } }));
|
||||
}
|
||||
|
||||
function approvePlan(): void {
|
||||
state.update((s) => ({ ...s, plan: { tasksApproved: true } }));
|
||||
}
|
||||
|
||||
function completeExecution(): void {
|
||||
state.update((s) => ({ ...s, execute: { completed: true } }));
|
||||
}
|
||||
|
||||
function addCriterion(text: string): void {
|
||||
const criterion: VerifyCriterion = {
|
||||
id: generateCriterionId(),
|
||||
text,
|
||||
status: "pending",
|
||||
};
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
verify: { ...s.verify, criteria: [...s.verify.criteria, criterion] },
|
||||
}));
|
||||
}
|
||||
|
||||
function removeCriterion(id: string): void {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
verify: { ...s.verify, criteria: s.verify.criteria.filter((c) => c.id !== id) },
|
||||
}));
|
||||
}
|
||||
|
||||
function updateCriterionStatus(id: string, status: CriterionStatus): void {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
verify: {
|
||||
...s.verify,
|
||||
criteria: s.verify.criteria.map((c) => (c.id === id ? { ...c, status } : c)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function completeVerification(report: string): void {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
verify: { ...s.verify, verificationComplete: true, report },
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
state: { subscribe: state.subscribe },
|
||||
loadState,
|
||||
saveState,
|
||||
setPhase,
|
||||
setQuickMode,
|
||||
reset,
|
||||
setDiscussDescription,
|
||||
markContextCaptured,
|
||||
approvePlan,
|
||||
completeExecution,
|
||||
addCriterion,
|
||||
removeCriterion,
|
||||
updateCriterionStatus,
|
||||
completeVerification,
|
||||
};
|
||||
}
|
||||
|
||||
export const workflowStore = createWorkflowStore();
|
||||
@@ -188,3 +188,11 @@ export interface UpdateInfo {
|
||||
release_url: string;
|
||||
release_notes?: string;
|
||||
}
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
url: string;
|
||||
notes?: string;
|
||||
prerelease: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -240,9 +240,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C - Interrupt (only when processing)
|
||||
// Ctrl+C - Interrupt (only when processing AND no text is selected)
|
||||
if (event.ctrlKey && event.key === "c") {
|
||||
if (get(isClaudeProcessing)) {
|
||||
const hasSelection = Boolean(window.getSelection()?.toString());
|
||||
if (get(isClaudeProcessing) && !hasSelection) {
|
||||
event.preventDefault();
|
||||
handleInterrupt();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* +page.svelte keyboard shortcut tests
|
||||
*
|
||||
* Tests the pure decision logic for the Ctrl+C keyboard shortcut handler.
|
||||
* The handler should only intercept Ctrl+C (to send an interrupt signal) when:
|
||||
* - Claude is currently processing a request, AND
|
||||
* - No text is currently selected (so normal copy behaviour is preserved)
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Ctrl+C with text selected copies the text (browser default)
|
||||
* - [ ] Ctrl+C with no text selected and Claude processing sends an interrupt
|
||||
* - [ ] Ctrl+C with no text selected and Claude idle does nothing special
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Mirror the Ctrl+C interrupt decision logic from +page.svelte
|
||||
function shouldInterruptOnCtrlC(isProcessing: boolean, hasSelection: boolean): boolean {
|
||||
return isProcessing && !hasSelection;
|
||||
}
|
||||
|
||||
describe("Ctrl+C interrupt logic", () => {
|
||||
it("interrupts when Claude is processing and no text is selected", () => {
|
||||
expect(shouldInterruptOnCtrlC(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not interrupt when text is selected, even if Claude is processing", () => {
|
||||
expect(shouldInterruptOnCtrlC(true, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not interrupt when Claude is idle and no text is selected", () => {
|
||||
expect(shouldInterruptOnCtrlC(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not interrupt when Claude is idle and text is selected", () => {
|
||||
expect(shouldInterruptOnCtrlC(false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user