diff --git a/.gitignore b/.gitignore index 6a66025..072b98d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ vite.config.ts.timestamp-* # Coverage reports /coverage + +# PRD task files (user-generated data, not source code) +hikari-tasks.json diff --git a/CODEBASE.md b/CODEBASE.md new file mode 100644 index 0000000..2b3afdd --- /dev/null +++ b/CODEBASE.md @@ -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 │ │ +│ └──────────────────────────┬───────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────▼───────────────────────────────┐ │ +│ │ 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` keyed by `conversation_id`. This enables true parallel conversations — each tab has its own isolated Claude process. The manager is wrapped in `Arc>` (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**: `` character display with state-reactive glow effects (trans pride gradient colours per state) +- **Right panel**: `` + `` (or `` 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 ``. 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::() + → 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. diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..9a97193 --- /dev/null +++ b/PROJECT.md @@ -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. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index acddc96..35838e8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -606,6 +606,62 @@ pub async fn check_for_updates() -> Result { }) } +#[derive(Debug, serde::Deserialize)] +struct GiteaChangelogRelease { + tag_name: String, + html_url: String, + body: Option, + prerelease: bool, + created_at: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ChangelogEntry { + pub version: String, + pub url: String, + pub notes: Option, + pub prerelease: bool, + pub created_at: String, +} + +#[tauri::command] +pub async fn fetch_changelog() -> Result, 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 = + 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 { } } +// ==================== 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, +} + +/// 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, +) { + if depth > max_depth { + lines.push(format!("{}...", prefix)); + return; + } + + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + let mut items: Vec = 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 { + 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 = 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 = 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::*; diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index cfb4f7f..dedae81 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -158,6 +158,16 @@ pub struct HikariConfig { #[serde(default)] pub custom_ui_font_family: Option, + + // 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::("\"dracula\"").unwrap(), + Theme::Dracula + ); + assert_eq!( + serde_json::from_str::("\"catppuccin\"").unwrap(), + Theme::Catppuccin + ); + assert_eq!( + serde_json::from_str::("\"nord\"").unwrap(), + Theme::Nord + ); + assert_eq!( + serde_json::from_str::("\"solarized\"").unwrap(), + Theme::Solarized + ); + assert_eq!( + serde_json::from_str::("\"solarized-light\"").unwrap(), + Theme::SolarizedLight + ); + assert_eq!( + serde_json::from_str::("\"catppuccin-latte\"").unwrap(), + Theme::CatppuccinLatte + ); + assert_eq!( + serde_json::from_str::("\"gruvbox-light\"").unwrap(), + Theme::GruvboxLight + ); + assert_eq!( + serde_json::from_str::("\"rose-pine-dawn\"").unwrap(), + Theme::RosePineDawn + ); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0d8ae5..9710b77 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src/app.css b/src/app.css index 88bf299..a4f01e0 100644 --- a/src/app.css +++ b/src/app.css @@ -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; diff --git a/src/lib/components/ChangelogPanel.svelte b/src/lib/components/ChangelogPanel.svelte new file mode 100644 index 0000000..56e1ba7 --- /dev/null +++ b/src/lib/components/ChangelogPanel.svelte @@ -0,0 +1,153 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="changelog-title" + tabindex="-1" + > +
+

+ Changelog +

+ +
+ +
+ {#if loading} +
+
+ Fetching releases... +
+ {:else if error} +
+

{error}

+ +
+ {:else if entries.length === 0} +

No releases found.

+ {:else} +
+ {#each entries as entry (entry.version)} +
+
+ + {entry.version} + + {#if entry.version === `v${currentVersion}`} + + current + + {/if} + {#if entry.prerelease} + + pre-release + + {/if} + + {formatReleaseDate(entry.created_at)} + + +
+ {#if entry.notes} +
+ +
+ {:else} +

No release notes.

+ {/if} +
+ {/each} +
+ {/if} +
+
+
diff --git a/src/lib/components/ChangelogPanel.test.ts b/src/lib/components/ChangelogPanel.test.ts new file mode 100644 index 0000000..72a9eac --- /dev/null +++ b/src/lib/components/ChangelogPanel.test.ts @@ -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"); + }); +}); diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 1328fbb..8a9474b 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -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 @@
+ + + Dark Presets +
+ + + + +
+ + + Light Presets +
+ + + + +
diff --git a/src/lib/components/HelpPanel.svelte b/src/lib/components/HelpPanel.svelte index 4e8a418..f1a461c 100644 --- a/src/lib/components/HelpPanel.svelte +++ b/src/lib/components/HelpPanel.svelte @@ -1,54 +1,69 @@ + + +
e.key === "Escape" && onClose()} > +
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-labelledby="help-title" tabindex="-1" > -
-

- How to Use Hikari Desktop + +
+

+ Help & Documentation

-
- {#each sections as section (section.title)} -
-

{section.title}

-
    - {#each section.items as item (item)} -
  • - - {item} -
  • - {/each} -
-
- {/each} + +
+ + -
-

- Need more help? Join our Discord community for support and updates! -

+ +
+
+ + +
+ + + + Page {currentPageIndex + 1} of {HELP_PAGES.length} + + + +
diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index aa63b6a..024aedc 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -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); diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index 205fb6a..6786210 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -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" > - {@html renderMarkdown(content)} + {@html renderedHtml}

diff --git a/src/lib/components/NavMenu.test.ts b/src/lib/components/NavMenu.test.ts new file mode 100644 index 0000000..60b84ae --- /dev/null +++ b/src/lib/components/NavMenu.test.ts @@ -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); + }); +}); diff --git a/src/lib/components/PrdPanel.svelte b/src/lib/components/PrdPanel.svelte new file mode 100644 index 0000000..759a7ad --- /dev/null +++ b/src/lib/components/PrdPanel.svelte @@ -0,0 +1,375 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="prd-panel-title" + tabindex="-1" + > + +
+
+

+ PRD Creator +

+ {#if $isGenerating} + Generating tasks... + {:else if $isLoading} + Loading... + {:else if $isLoaded} + + {$tasks.length} task{$tasks.length === 1 ? "" : "s"} + + {/if} +
+
+ {#if onBackToWorkflow} + + {/if} + +
+
+ + +
+ {#if $isGenerating} + +
+
⚙️
+

Generating Tasks...

+

+ Claude is breaking down your goal into actionable tasks and writing + hikari-tasks.json. This will auto-reload when + complete. +

+
+ {:else if $isLoaded} + +
+
+ Goal: + {$goal} +
+ {#each $tasks as task, index (task.id)} +
+ +
+ #{index + 1} + + 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" + /> + +
+ + + +
+
+ + +
+ {/each} + {#if $tasks.length === 0} +

+ No tasks — all removed. Click Regenerate to start over. +

+ {/if} +
+ {:else} + +
+
+ + +
+

+ Claude will analyse your goal and write a + hikari-tasks.json file with 3–10 actionable tasks, each with + a detailed prompt ready to execute. +

+
+ {/if} +
+ + +
+
+ {workingDirectory}/hikari-tasks.json +
+
+ {#if $isLoaded} + + + + {:else if !$isGenerating} + + {/if} +
+
+
+
+ + diff --git a/src/lib/components/ProjectContextPanel.svelte b/src/lib/components/ProjectContextPanel.svelte new file mode 100644 index 0000000..dc0e8f7 --- /dev/null +++ b/src/lib/components/ProjectContextPanel.svelte @@ -0,0 +1,323 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="project-context-title" + tabindex="-1" + > + +
+
+

+ Project Context +

+ {#if $activeFile === "CODEBASE"} + {#if $isMappingCodebase} + Mapping codebase... + {:else if $isLoading[$activeFile]} + Loading... + {:else if fileExists($activeFile)} + + ✓ File exists + + {:else} + + ✗ Not generated + + {/if} + {:else if $isLoading[$activeFile]} + Loading... + {:else if fileExists($activeFile)} + + ✓ File exists + + {:else} + + ✗ Not created + + {/if} + {#if hasUnsavedChanges} + Unsaved changes + {/if} +
+ +
+ + +
+ {#each ALL_FILES as file (file)} + + {/each} +
+ + +
+ {#if $activeFile === "CODEBASE" && !fileExists("CODEBASE") && !$isMappingCodebase} + +
+
🗺️
+

No Codebase Map Yet

+

+ Generate a CODEBASE.md 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. +

+ +
+ {:else if $activeFile === "CODEBASE" && $isMappingCodebase} + +
+
⚙️
+

Mapping Codebase...

+

+ Claude is analysing the project and writing CODEBASE.md. This will auto-reload when complete. +

+
+ {:else} + + {/if} +
+ + +
+
+ {workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]} +
+
+ {#if $activeFile === "CODEBASE"} + + + {:else} + + + + {/if} +
+
+
+
+ + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 0c59b7f..77485ea 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -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}
-
+
{#if streamerModeActive}
{/if} - - - - - - - - - - - - - - - - - - - + + {#if $loopStatus === "running" || $loopStatus === "paused"} + + Loop {$loopStatus === "running" ? "▶" : "⏸"} + {loopCompletedCount + + ($loopStatus === "running" && $loopCurrentIndex >= 0 ? 1 : 0)}/{loopTotalCount} + + {/if} + + + {#if appVersion} v{appVersion} {/if} - {#if showStats} -
- -
- {/if} {#if connectionStatus === "connected"}
-{#if showStats} - - -
(showStats = false)}>
-
- -
-{/if} - -{#if showAbout} - (showAbout = false)} /> -{/if} - -{#if showHelp} - (showHelp = false)} /> -{/if} - -{#if showKeyboardShortcuts} - (showKeyboardShortcuts = false)} /> -{/if} - -{#if showSessionHistory} - (showSessionHistory = false)} /> -{/if} - -{#if showTodoPanel} - (showTodoPanel = false)} /> -{/if} - -{#if showGitPanel} - (showGitPanel = false)} /> -{/if} - -{#if showProfile} - (showProfile = false)} /> -{/if} - -{#if showAgentMonitor} - (showAgentMonitor = false)} /> -{/if} - -{#if showCastPanel} - (showCastPanel = false)} /> -{/if} - -{#if showPluginPanel} - (showPluginPanel = false)} /> -{/if} - -{#if showMcpPanel} - (showMcpPanel = false)} /> -{/if} - {#if showWorkspaceTrust && pendingHookInfo}