feat: productivity suite — task loop, workflow, theming, docs & more #197

Merged
naomi merged 16 commits from feat/productivity into main 2026-03-07 03:08:33 -08:00
52 changed files with 8865 additions and 529 deletions
+3
View File
@@ -11,3 +11,6 @@ vite.config.ts.timestamp-*
# Coverage reports
/coverage
# PRD task files (user-generated data, not source code)
hikari-tasks.json
+458
View File
@@ -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
View File
@@ -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.
+210
View File
@@ -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
View File
@@ -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
);
}
}
+2
View File
@@ -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
View File
@@ -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;
+153
View File
@@ -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>
+68
View File
@@ -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");
});
});
+97 -4
View File
@@ -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 -->
+132 -81
View File
@@ -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>
+9
View File
@@ -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);
+11 -7
View File
@@ -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>
+163
View File
@@ -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>');
});
});
+584
View File
@@ -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>
+80
View File
@@ -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);
});
});
+375
View File
@@ -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 310 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>
+44 -409
View File
@@ -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 {
+2 -1
View File
@@ -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";
+780
View File
@@ -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>
+96 -10
View File
@@ -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);
}
+104
View File
@@ -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);
});
});
+833
View File
@@ -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 &amp; 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>
+102
View File
@@ -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 &amp; Connect</strong>. See the <strong>Panels &amp; 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>
+99
View File
@@ -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);
});
});
+40
View File
@@ -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;
}
+8 -5
View File
@@ -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() {
+65
View File
@@ -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);
+20 -1
View File
@@ -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() {
+615
View File
@@ -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);
});
});
});
+197
View File
@@ -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 310 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();
+390
View File
@@ -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);
});
});
+217
View File
@@ -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`;
+325
View File
@@ -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]]);
});
});
+230
View File
@@ -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();
+211
View File
@@ -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-/);
});
});
+253
View File
@@ -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();
+8
View File
@@ -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;
}
+3 -2
View File
@@ -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;
+38
View File
@@ -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);
});
});