Compare commits

..

8 Commits

Author SHA1 Message Date
naomi fe7027c585 release: v1.8.1
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m8s
CI / Lint & Test (push) Successful in 16m51s
CI / Build Linux (push) Successful in 20m11s
CI / Build Windows (cross-compile) (push) Successful in 30m45s
2026-02-26 23:36:01 -08:00
hikari 89a0bdd8f1 fix: assorted bug fixes for lists, sounds, interrupts, and permissions (#173)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 59s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

- **Markdown lists**: Explicitly set `list-style-type: disc` / `decimal` in the Markdown renderer — Tauri's WebView strips browser defaults, leaving bullets and numbers invisible.
- **Notification sounds**: Moved all per-task sounds (success, error, permission, task-start) from a global `characterState` subscription into the per-conversation `claude:state` event handler, so background tabs receive their sounds correctly and tab-switching never replays a sound that already fired. Closes #172
- **Draft text**: Persists `inputValue` per conversation tab so a half-typed prompt survives switching to another tab and back.
- **Interrupt messages**: Replaced vague "Process interrupted" / "Disconnected" strings with source-specific descriptions (keyboard shortcut, stop button, unexpected crash) so it's clear what actually happened.
- **Silent prompt loss**: When Claude Code exits whilst a prompt is in-flight, emits a visible error line telling the user their last prompt was not processed and to reconnect and retry.
- **Double disconnect**: Added an `intentional_stop` flag to `WslBridge` so that `stop()` / `interrupt()` — which kill the process themselves — suppress the duplicate "Disconnected unexpectedly" message that `handle_stdout`'s EOF path was also emitting.
- **Permission modal**: Fixed two cooperating reactivity bugs — `pendingPermissions` was mutated in-place (`.push()`), causing Svelte's derived-store chain to receive the same array reference and skip re-rendering; `PermissionModal.svelte` also used `$state()` (runes mode) where plain `let` is required for correct store-subscription reactivity.

## Test plan

- [ ] Unordered and ordered lists render with visible bullets and numbers in the chat terminal
- [ ] Completion sound plays once when a background tab finishes; switching back to that tab does not replay it
- [ ] Sounds for error, permission request, and task-start also play for background tabs and do not replay on tab switch
- [ ] Typing a prompt, switching tabs, and switching back restores the draft text
- [ ] Pressing Ctrl+C shows "keyboard shortcut (Ctrl+C)"; clicking the stop button shows "via stop button"
- [ ] If Claude exits mid-request, an error message appears prompting the user to resend
- [ ] Clicking stop or pressing Ctrl+C produces exactly one disconnect message (not two)
- [ ] When a tool requires permission, the permission modal appears and the user can approve or dismiss it

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #173
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-26 23:34:51 -08:00
naomi 2e3f203508 release: v1.8.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m51s
CI / Lint & Test (push) Successful in 17m15s
CI / Build Linux (push) Successful in 20m7s
CI / Build Windows (cross-compile) (push) Successful in 30m8s
2026-02-25 22:57:52 -08:00
hikari b745100bd5 feat: Claude CLI 2.1.50–2.1.53 audit (#171)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m28s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
## Summary

This PR covers the full audit of Claude CLI changes from 2.1.50 to 2.1.53, plus a batch of bug fixes, new features, and maintenance work identified during that review.

### New Features
- **Workspace trust gate** — detects hooks, MCP servers, and custom commands in a workspace before connecting; persists trust decisions so users aren't prompted repeatedly
- **Custom background image** — users can set a background image with configurable opacity; character panel and compact mode go transparent when active
- **Draggable tab reordering** — conversation tabs can be reordered via pointer-event drag-and-drop (HTML5 drag is intercepted by Tauri/WebView2, so pointer events are used instead)
- **Org UUID in account info** — exposes the org UUID from Claude auth status

### Bug Fixes
- **Unread dot false positives** — initialise unread counts on mount to prevent all tabs showing the blue dot after toggling the file editor (Closes #164)
- **Watchdog for hung WSL bridge** — detects connections that never receive `system:init` and kills the stale process after 1 minute (Closes #166)
- **Suppress terminal window flash on Windows** — applies `CREATE_NO_WINDOW` to all subprocesses via a `HideWindow` trait extension (Closes #165)
- **HTML escaping in markdown renderer** — escape `<` and `>` in `codespan` and `html` renderer callbacks to prevent raw HTML injection (Closes #169)

### Maintenance
- Verify stream-JSON handles tool results above the 50K threshold correctly (Closes #162)
- Reviewed hook security fixes from CLI 2.1.51 — not applicable to our setup (Closes #163)
- Expose org UUID from `claude auth status` (Closes #160)
- Clean up Svelte and Vite build warnings (`a11y_click_events_have_key_events`, `state_referenced_locally`, `non_reactive_update`, `codeSplitting`, chunk size, CodeMirror dynamic import)
- Update all npm dependencies to latest compatible versions with exact pinning (Closes #81, Closes #82, Closes #83, Closes #84, Closes #85, Closes #86, Closes #87, Closes #90, Closes #91, Closes #93, Closes #94, Closes #95, Closes #96, Closes #97, Closes #98, Closes #99, Closes #101, Closes #141, Closes #142, Closes #143, Closes #145, Closes #146, Closes #147)
- Run `cargo update` to bring Cargo.lock up to date

### Closes

Closes #160
Closes #162
Closes #163
Closes #164
Closes #165
Closes #166
Closes #167
Closes #168
Closes #169
Closes #81
Closes #82
Closes #83
Closes #84
Closes #85
Closes #86
Closes #87
Closes #90
Closes #91
Closes #93
Closes #94
Closes #95
Closes #96
Closes #97
Closes #98
Closes #99
Closes #101
Closes #141
Closes #142
Closes #143
Closes #145
Closes #146
Closes #147

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #171
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-25 22:55:47 -08:00
naomi 1bb7eb4d26 release: v1.7.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m23s
CI / Lint & Test (push) Successful in 16m55s
CI / Build Linux (push) Successful in 19m53s
CI / Build Windows (cross-compile) (push) Successful in 30m20s
2026-02-24 20:50:04 -08:00
hikari a4e6788573 feat: stuffy feature bundle (#159)
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

This PR bundles a collection of new features and quality-of-life improvements identified during a Claude CLI 2.1.50 audit.

- **Tab status indicator** — Tab stays yellow until the greeting is responded to, then turns green. Fixed disconnect not resetting to grey. Closes #157
- **Auth status display** — New "Account" section in settings sidebar showing login status, email, org, API key source, and Hikari override indicator. Includes login/logout buttons. Closes #153
- **CLI version badge** — New "Supported" badge showing the highest audited CLI version, colour-coded green/amber/red based on installed vs supported version. Closes #154 (bump to 2.1.50)
- **Rate limit events** — `rate_limit_event` messages from the stream are now parsed and shown as amber `[rate-limit]` lines in the terminal instead of being silently dropped. Closes #155
- **"Prompt is too long" handling** — Detects this error in assistant messages and shows a  Compact Conversation button to send `/compact` directly. Closes #158
- **`last_assistant_message` in Agent Monitor** — Extracts the agent's final output from the `ToolResult` content block in the JSON stream and displays it as a snippet on completed agent cards. Closes #156
- **`--worktree` flag** — New "Worktree isolation" toggle in session settings passes `--worktree` to Claude Code. Hook events (`WorktreeCreate`/`WorktreeRemove`) are displayed as green `[worktree]` lines. Closes #152, Closes #150
- **ConfigChange hook events** — `[ConfigChange Hook]` stderr events are now displayed as cyan `[config]` lines instead of errors. Closes #151
- **`CLAUDE_CODE_DISABLE_1M_CONTEXT` toggle** — New "Disable 1M context" setting in session configuration injects this env var into the Claude process. Closes #154

## Test plan

- [ ] Tab status indicator: start a new session and verify the tab stays yellow until Claude responds to the greeting, then turns green
- [ ] Auth status: open settings and verify the Account section shows correct login info
- [ ] CLI version badge: verify the "Supported 2.1.50" badge shows green when CLI matches
- [ ] Rate limit events: unit tests cover parsing; amber `[rate-limit]` lines display correctly
- [ ] Compact button: unit tests cover detection; button renders correctly in terminal
- [ ] Agent Monitor: use the Task tool and verify completed agent cards show a message snippet
- [ ] Worktree: enable toggle, start session, verify `--worktree` flag appears in process args
- [ ] ConfigChange: hook events display as `[config]` lines rather than errors
- [ ] Disable 1M context: enable toggle, start session, verify `CLAUDE_CODE_DISABLE_1M_CONTEXT=1` in `/proc/<pid>/environ`

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #159
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-24 20:48:49 -08:00
naomi d2e0915a75 release: v1.6.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m35s
CI / Lint & Test (push) Successful in 17m14s
CI / Build Linux (push) Successful in 20m6s
CI / Build Windows (cross-compile) (push) Successful in 30m0s
2026-02-23 21:37:18 -08:00
hikari d8cf5504d6 feat: agent monitor characters, cast panel, WSL fixes, and Sonnet 4.6 (#149)
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

### New Features
- **Claude Sonnet 4.6 support** — added `claude-sonnet-4-6` as a selectable model in the config sidebar
- **Anime girl characters for subagents** — each subagent in the agent monitor is automatically assigned one of six characters (Amari, Keiko, Minori, Reina, Tatsumi, Yumiko) with a unique name, CDN avatar, title, and lore-flavoured description; assignment avoids duplicates when possible
- **"Meet the Team" cast panel** — a new modal accessible from the status bar introduces the full cast: Naomi (Chief hEx-ecutive Officer), Hikari (Chief Operating Officer), and the six subagent girls with their C-suite titles and character bios

### Bug Fixes
- **"Already running" error on invalid working directory** — if a spawned Claude process exits unexpectedly (e.g. because the working directory doesn't exist), `try_wait()` now detects the stale handle and clears it before allowing a restart
- **Working directory pre-validation** — on Windows, the app now runs `wsl -e test -d <dir>` before launching Claude; invalid directories surface a clear error immediately
- **WSL binary detection** — on Windows, `wsl -e bash -lc "which claude"` is used to probe for the Claude binary inside WSL; on Linux/WSLg, `bash -lc "which claude"` is used as a login-shell fallback so GUI apps find the binary even without shell PATH
- **WSL detection fix for production builds** — `detect_wsl()` now short-circuits at compile time on Windows targets, preventing inherited `WSL_DISTRO_NAME` env vars from misrouting native Windows binaries through the Linux code path

 This PR was crafted with love by Hikari~ 🌸

Reviewed-on: #149
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-23 21:36:09 -08:00
56 changed files with 4210 additions and 1340 deletions
+63 -63
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.5.1",
"version": "1.8.1",
"description": "",
"type": "module",
"scripts": {
@@ -27,69 +27,69 @@
},
"license": "MIT",
"dependencies": {
"@codemirror/commands": "6.8.1",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.11",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2",
"codemirror": "^6.0.2",
"highlight.js": "^11.11.1",
"lucide-svelte": "0.564.0",
"marked": "^17.0.1"
"@codemirror/commands": "6.10.2",
"@codemirror/lang-angular": "0.1.4",
"@codemirror/lang-cpp": "6.0.3",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-go": "6.0.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-java": "6.0.2",
"@codemirror/lang-javascript": "6.2.4",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-less": "6.0.2",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/lang-php": "6.0.2",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-rust": "6.0.2",
"@codemirror/lang-sass": "6.0.2",
"@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-vue": "0.1.3",
"@codemirror/lang-wast": "6.0.2",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/language": "6.12.2",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/state": "6.5.4",
"@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "6.39.15",
"@lezer/highlight": "1.2.3",
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/plugin-fs": "2.4.5",
"@tauri-apps/plugin-notification": "2.3.3",
"@tauri-apps/plugin-opener": "2.5.3",
"@tauri-apps/plugin-os": "2.3.2",
"@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-store": "2.4.2",
"codemirror": "6.0.2",
"highlight.js": "11.11.1",
"lucide-svelte": "0.575.0",
"marked": "17.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.0.0",
"jsdom": "^27.4.0",
"prettier": "^3.8.0",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.6.2",
"typescript-eslint": "^8.53.0",
"vite": "^6.0.3",
"vitest": "^4.0.17"
"@eslint/js": "9.39.3",
"@sveltejs/adapter-static": "3.0.10",
"@sveltejs/kit": "2.53.2",
"@sveltejs/vite-plugin-svelte": "5.1.1",
"@tailwindcss/vite": "4.2.1",
"@tauri-apps/cli": "2.10.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/svelte": "5.3.1",
"@vitest/coverage-v8": "4.0.18",
"eslint": "9.39.3",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-svelte": "3.15.0",
"globals": "17.3.0",
"jsdom": "28.1.0",
"prettier": "3.8.1",
"prettier-plugin-svelte": "3.5.0",
"svelte": "5.53.5",
"svelte-check": "4.4.3",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"typescript-eslint": "8.56.1",
"vite": "6.4.1",
"vitest": "4.0.18"
}
}
+695 -682
View File
File diff suppressed because it is too large Load Diff
+493 -298
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.5.1"
version = "1.8.1"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
+282 -2
View File
@@ -7,6 +7,7 @@ use tauri_plugin_store::StoreExt;
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
use crate::bridge_manager::SharedBridgeManager;
use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::process_ext::HideWindow;
use crate::stats::UsageStats;
use crate::temp_manager::SharedTempFileManager;
@@ -59,6 +60,7 @@ fn create_claude_command() -> std::process::Command {
// Non-login shells launched by `wsl` don't inherit the full user PATH,
// so we need to use a login shell to get the correct PATH
let which_output = std::process::Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-l", "-c", "which claude"])
.output();
@@ -66,6 +68,7 @@ fn create_claude_command() -> std::process::Command {
Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut cmd = std::process::Command::new("wsl");
cmd.hide_window();
cmd.arg(claude_path);
cmd
}
@@ -73,6 +76,7 @@ fn create_claude_command() -> std::process::Command {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
let mut cmd = std::process::Command::new("wsl");
cmd.hide_window();
cmd.arg("claude");
cmd
}
@@ -85,18 +89,23 @@ fn create_claude_command() -> std::process::Command {
// This works regardless of how Claude Code was installed (standalone, npm, etc.)
// and avoids hardcoding paths
let which_output = std::process::Command::new("which")
.hide_window()
.arg("claude")
.output();
match which_output {
Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
std::process::Command::new(claude_path)
let mut cmd = std::process::Command::new(claude_path);
cmd.hide_window();
cmd
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
std::process::Command::new("claude")
let mut cmd = std::process::Command::new("claude");
cmd.hide_window();
cmd
}
}
}
@@ -334,6 +343,121 @@ pub async fn answer_question(
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
}
#[derive(Debug, Serialize)]
pub struct WorkspaceHookInfo {
pub has_concerns: bool,
pub hook_types: Vec<String>,
pub mcp_servers: Vec<String>,
pub custom_commands: Vec<String>,
}
/// Check whether a working directory has Claude Code hooks, MCP servers, or custom commands.
///
/// Hikari Desktop runs Claude in `--output-format stream-json` (non-interactive mode),
/// which bypasses Claude's own workspace trust dialog. We therefore check for these
/// ourselves so the frontend can show its own trust gate before launching.
#[tauri::command]
pub async fn check_workspace_hooks(working_dir: String) -> WorkspaceHookInfo {
let use_wsl = cfg!(windows) && working_dir.starts_with('/');
let settings_paths = [
format!("{}/.claude/settings.json", working_dir),
format!("{}/.claude/settings.local.json", working_dir),
];
let mut all_hook_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut all_mcp_servers: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for path in &settings_paths {
let content = if use_wsl {
match read_file_via_wsl(path).await {
Ok(c) => c,
Err(_) => continue,
}
} else {
match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
}
};
let settings: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(hooks) = settings.get("hooks").and_then(|h| h.as_object()) {
for key in hooks.keys() {
all_hook_types.insert(key.clone());
}
}
if let Some(servers) = settings.get("mcpServers").and_then(|s| s.as_object()) {
for key in servers.keys() {
all_mcp_servers.insert(key.clone());
}
}
}
let custom_commands = list_workspace_commands(&working_dir, use_wsl).await;
let hook_types: Vec<String> = all_hook_types.into_iter().collect();
let mcp_servers: Vec<String> = all_mcp_servers.into_iter().collect();
let has_concerns = !hook_types.is_empty() || !mcp_servers.is_empty() || !custom_commands.is_empty();
WorkspaceHookInfo {
has_concerns,
hook_types,
mcp_servers,
custom_commands,
}
}
async fn list_workspace_commands(working_dir: &str, use_wsl: bool) -> Vec<String> {
let commands_dir = format!("{}/.claude/commands", working_dir);
if use_wsl {
let script = format!(
"if [ -d '{0}' ]; then for f in '{0}'/*.md; do [ -f \"$f\" ] && basename \"$f\" .md; done; fi",
commands_dir
);
let Ok(output) = std::process::Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &script])
.output()
else {
return vec![];
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
} else {
let dir = std::path::Path::new(&commands_dir);
if !dir.exists() {
return vec![];
}
let Ok(entries) = std::fs::read_dir(dir) else {
return vec![];
};
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
})
.filter_map(|e| {
e.path()
.file_stem()
.map(|s| s.to_string_lossy().to_string())
})
.collect();
names.sort();
names
}
}
#[tauri::command]
pub async fn list_skills() -> Result<Vec<String>, String> {
// On Windows, we need to use WSL to access the skills directory
@@ -381,6 +505,7 @@ async fn list_skills_via_wsl() -> Result<Vec<String>, String> {
// Use WSL to list directories in ~/.claude/skills that contain SKILL.md
let output = Command::new("wsl")
.hide_window()
.args([
"-e",
"sh",
@@ -680,6 +805,7 @@ async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
);
let output = Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -742,6 +868,7 @@ async fn read_file_via_wsl(path: &str) -> Result<String, String> {
use std::process::Command;
let output = Command::new("wsl")
.hide_window()
.args(["-e", "cat", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -773,6 +900,7 @@ async fn write_file_via_wsl(path: &str, content: &str) -> Result<(), String> {
use std::process::{Command, Stdio};
let mut child = Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &format!("cat > '{}'", path)])
.stdin(Stdio::piped())
.spawn()
@@ -821,6 +949,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
// Check if file exists first
let check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -830,6 +959,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
}
let output = Command::new("wsl")
.hide_window()
.args(["-e", "touch", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -870,6 +1000,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
// Check if directory exists first
let check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -879,6 +1010,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
}
let output = Command::new("wsl")
.hide_window()
.args(["-e", "mkdir", "-p", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -923,6 +1055,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
// Check if path exists
let check_exists = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -933,6 +1066,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
// Check if path is a directory
let check_dir = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -942,6 +1076,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
}
let output = Command::new("wsl")
.hide_window()
.args(["-e", "rm", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -986,6 +1121,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
// Check if path exists
let check_exists = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -996,6 +1132,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
// Check if path is a directory
let check_dir = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1005,6 +1142,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
}
let output = Command::new("wsl")
.hide_window()
.args(["-e", "rm", "-rf", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1050,6 +1188,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
// Check if old path exists
let check_old = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", old_path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1060,6 +1199,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
// Check if new path already exists
let check_new = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", new_path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1069,6 +1209,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
}
let output = Command::new("wsl")
.hide_window()
.args(["-e", "mv", old_path, new_path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1246,6 +1387,7 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
"#;
let output = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-l", "-c", script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1360,6 +1502,144 @@ pub async fn get_claude_version() -> Result<String, String> {
}
}
// ==================== Auth Commands ====================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeAuthStatus {
pub is_logged_in: bool,
pub email: Option<String>,
pub org_id: Option<String>,
pub org_name: Option<String>,
pub api_key_source: Option<String>,
pub api_provider: Option<String>,
pub subscription_type: Option<String>,
}
#[tauri::command]
pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
tracing::debug!("Getting Claude auth status");
let output = create_claude_command()
.args(["auth", "status"])
.output()
.map_err(|e| format!("Failed to run claude auth status: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) {
let is_logged_in = json
.get("loggedIn")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let email = json
.get("email")
.and_then(|v| v.as_str())
.map(String::from);
let org_id = json
.get("orgId")
.and_then(|v| v.as_str())
.map(String::from);
let org_name = json
.get("orgName")
.and_then(|v| v.as_str())
.map(String::from);
let api_key_source = json
.get("apiKeySource")
.and_then(|v| v.as_str())
.map(String::from);
let api_provider = json
.get("apiProvider")
.and_then(|v| v.as_str())
.map(String::from);
let subscription_type = json
.get("subscriptionType")
.and_then(|v| v.as_str())
.map(String::from);
tracing::info!("Claude auth status: logged_in={}", is_logged_in);
Ok(ClaudeAuthStatus {
is_logged_in,
email,
org_id,
org_name,
api_key_source,
api_provider,
subscription_type,
})
} else {
// Non-JSON output: fall back to heuristic
let lower = raw.to_lowercase();
let is_logged_in = output.status.success()
&& !lower.contains("not logged in")
&& !lower.contains("not authenticated")
&& !lower.contains("no account");
tracing::info!("Claude auth status (non-JSON): logged_in={}", is_logged_in);
Ok(ClaudeAuthStatus {
is_logged_in,
email: None,
org_id: None,
org_name: None,
api_key_source: None,
api_provider: None,
subscription_type: None,
})
}
}
#[tauri::command]
pub async fn auth_login() -> Result<String, String> {
tracing::info!("Running claude auth login");
let output = create_claude_command()
.args(["auth", "login"])
.output()
.map_err(|e| format!("Failed to run claude auth login: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if output.status.success() {
let message = if stdout.is_empty() { "Login successful".to_string() } else { stdout };
tracing::info!("Claude auth login succeeded");
Ok(message)
} else {
let error = if stderr.is_empty() { stdout } else { stderr };
tracing::error!("Claude auth login failed: {}", error);
Err(format!("Login failed: {}", error))
}
}
#[tauri::command]
pub async fn auth_logout() -> Result<String, String> {
tracing::info!("Running claude auth logout");
let output = create_claude_command()
.args(["auth", "logout"])
.output()
.map_err(|e| format!("Failed to run claude auth logout: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if output.status.success() {
let message = if stdout.is_empty() { "Logged out successfully".to_string() } else { stdout };
tracing::info!("Claude auth logout succeeded");
Ok(message)
} else {
let error = if stderr.is_empty() { stdout } else { stderr };
tracing::error!("Claude auth logout failed: {}", error);
Err(format!("Logout failed: {}", error))
}
}
// ==================== Plugin Management Commands ====================
#[derive(Debug, Clone, Serialize, Deserialize)]
+39
View File
@@ -25,6 +25,12 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub resume_session_id: Option<String>,
#[serde(default)]
pub use_worktree: bool,
#[serde(default)]
pub disable_1m_context: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -113,6 +119,22 @@ pub struct HikariConfig {
#[serde(default = "default_discord_rpc_enabled")]
pub discord_rpc_enabled: bool,
#[serde(default)]
pub use_worktree: bool,
#[serde(default)]
pub disable_1m_context: bool,
#[serde(default)]
pub trusted_workspaces: Vec<String>,
// Background image settings
#[serde(default)]
pub background_image_path: Option<String>,
#[serde(default = "default_background_image_opacity")]
pub background_image_opacity: f32,
}
impl Default for HikariConfig {
@@ -145,6 +167,11 @@ impl Default for HikariConfig {
budget_action: BudgetAction::Warn,
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: Vec::new(),
background_image_path: None,
background_image_opacity: 0.3,
}
}
}
@@ -181,6 +208,10 @@ fn default_discord_rpc_enabled() -> bool {
true
}
fn default_background_image_opacity() -> f32 {
0.3
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BudgetAction {
@@ -252,6 +283,9 @@ mod tests {
assert_eq!(config.budget_action, BudgetAction::Warn);
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
assert!(config.discord_rpc_enabled);
assert!(!config.use_worktree);
assert!(!config.disable_1m_context);
assert!(config.trusted_workspaces.is_empty());
}
#[test]
@@ -284,6 +318,11 @@ mod tests {
budget_action: BudgetAction::Block,
budget_warning_threshold: 0.75,
discord_rpc_enabled: true,
use_worktree: true,
disable_1m_context: false,
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
background_image_path: Some("/home/naomi/bg.png".to_string()),
background_image_opacity: 0.25,
};
let json = serde_json::to_string(&config).unwrap();
+3
View File
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
use crate::process_ext::HideWindow;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitStatus {
pub is_repo: bool,
@@ -37,6 +39,7 @@ pub struct GitLogEntry {
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.hide_window()
.args(args)
.current_dir(working_dir)
.output()
+5
View File
@@ -8,6 +8,7 @@ mod debug_logger;
mod discord_rpc;
mod git;
mod notifications;
mod process_ext;
mod quick_actions;
mod sessions;
mod snippets;
@@ -120,6 +121,7 @@ pub fn run() {
get_persisted_stats,
load_saved_achievements,
answer_question,
check_workspace_hooks,
send_windows_notification,
send_simple_notification,
send_windows_toast,
@@ -195,6 +197,9 @@ pub fn run() {
close_application,
list_memory_files,
get_claude_version,
get_auth_status,
auth_login,
auth_logout,
list_plugins,
install_plugin,
uninstall_plugin,
+6
View File
@@ -1,6 +1,8 @@
use std::process::Command;
use tauri::command;
use crate::process_ext::HideWindow;
/// Generate PowerShell script for Windows Toast Notification
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
format!(
@@ -82,6 +84,7 @@ fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<St
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
// Use notify-send for Linux/WSL
let output = Command::new("notify-send")
.hide_window()
.arg(&title)
.arg(&body)
.arg("--urgency=normal")
@@ -109,6 +112,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
let output = Command::new("pwsh.exe")
.hide_window()
.arg("-NoProfile")
.arg("-WindowStyle")
.arg("Hidden")
@@ -117,6 +121,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
.output()
.or_else(|_| {
Command::new("powershell.exe")
.hide_window()
.arg("-NoProfile")
.arg("-WindowStyle")
.arg("Hidden")
@@ -140,6 +145,7 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
let message = format_simple_notification(&title, &body);
Command::new("cmd.exe")
.hide_window()
.arg("/c")
.arg("msg")
.arg("*")
+21
View File
@@ -0,0 +1,21 @@
use std::process::Command;
/// Extension trait for `Command` that hides the console window on Windows.
///
/// On non-Windows platforms this is a no-op, so callers can unconditionally
/// chain `.hide_window()` without any `#[cfg]` guards at the call sites.
pub trait HideWindow {
fn hide_window(&mut self) -> &mut Self;
}
impl HideWindow for Command {
fn hide_window(&mut self) -> &mut Self {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
self.creation_flags(CREATE_NO_WINDOW);
}
self
}
}
+3 -1
View File
@@ -86,8 +86,9 @@ impl ContextWarning {
/// Get the context window limit (in tokens) for a given model
fn get_context_window_limit(model: &str) -> u64 {
match model {
// Claude 4.6 family - 200K standard (1M beta available via header)
// Claude 4.6 family
"claude-opus-4-6" => 200_000,
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
// Claude 4.5 family - 200K standard context
"claude-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929"
@@ -502,6 +503,7 @@ pub fn calculate_cost(
let (input_price_per_million, output_price_per_million) = match model {
// Current generation (Claude 4.6)
"claude-opus-4-6" => (5.0, 25.0),
"claude-sonnet-4-6" => (3.0, 15.0),
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101" => (5.0, 25.0),
+100
View File
@@ -63,6 +63,26 @@ pub struct PermissionDenial {
pub tool_input: serde_json::Value,
}
/// Rate limit information from a `rate_limit_event` message.
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RateLimitInfo {
#[serde(default)]
pub requests_limit: Option<u64>,
#[serde(default)]
pub requests_remaining: Option<u64>,
#[serde(default)]
pub requests_reset: Option<String>,
#[serde(default)]
pub tokens_limit: Option<u64>,
#[serde(default)]
pub tokens_remaining: Option<u64>,
#[serde(default)]
pub tokens_reset: Option<String>,
#[serde(default)]
pub retry_after_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ClaudeMessage {
@@ -100,6 +120,11 @@ pub enum ClaudeMessage {
#[serde(default)]
usage: Option<UsageInfo>,
},
#[serde(rename = "rate_limit_event")]
RateLimitEvent {
#[serde(default)]
rate_limit_info: RateLimitInfo,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -280,6 +305,8 @@ pub struct AgentEndEvent {
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_turns: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_assistant_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -446,4 +473,77 @@ mod tests {
assert!(serialized.contains("\"input_tokens\":100"));
assert!(serialized.contains("\"output_tokens\":50"));
}
#[test]
fn test_rate_limit_info_default() {
let info = RateLimitInfo::default();
assert!(info.requests_limit.is_none());
assert!(info.requests_remaining.is_none());
assert!(info.requests_reset.is_none());
assert!(info.tokens_limit.is_none());
assert!(info.tokens_remaining.is_none());
assert!(info.tokens_reset.is_none());
assert!(info.retry_after_ms.is_none());
}
#[test]
fn test_rate_limit_event_deserialization_empty_info() {
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
}
#[test]
fn test_rate_limit_event_deserialization_no_info() {
// rate_limit_info field is optional via #[serde(default)]
let json = r#"{"type":"rate_limit_event"}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
}
#[test]
fn test_rate_limit_event_deserialization_with_data() {
let json = r#"{
"type": "rate_limit_event",
"rate_limit_info": {
"requests_limit": 1000,
"requests_remaining": 0,
"requests_reset": "2024-01-01T00:01:00Z",
"tokens_limit": 50000,
"tokens_remaining": 0,
"tokens_reset": "2024-01-01T00:01:00Z",
"retry_after_ms": 60000
}
}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
assert_eq!(rate_limit_info.requests_limit, Some(1000));
assert_eq!(rate_limit_info.requests_remaining, Some(0));
assert_eq!(
rate_limit_info.requests_reset,
Some("2024-01-01T00:01:00Z".to_string())
);
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
} else {
panic!("Expected RateLimitEvent variant");
}
}
#[test]
fn test_rate_limit_event_ignores_unknown_fields() {
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
let json = r#"{
"type": "rate_limit_event",
"rate_limit_info": {
"requests_remaining": 0,
"some_future_field": "some_value"
}
}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
assert_eq!(rate_limit_info.requests_remaining, Some(0));
} else {
panic!("Expected RateLimitEvent variant");
}
}
}
+4 -1
View File
@@ -3,6 +3,8 @@ use std::process::Command;
use tauri::command;
use tempfile::NamedTempFile;
use crate::process_ext::HideWindow;
#[command]
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
// Create a VBScript that shows a Windows notification
@@ -40,7 +42,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
} else if temp_path.starts_with("/tmp/") {
// WSL temp files might be in a different location
// Try to use wslpath to convert
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
let output = Command::new("wslpath").hide_window().arg("-w").arg(&temp_path).output();
if let Ok(result) = output {
if result.status.success() {
@@ -57,6 +59,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
// Execute the VBScript using wscript.exe
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
.hide_window()
.arg("//NoLogo")
.arg(&windows_path)
.output()
+566 -73
View File
@@ -1,17 +1,17 @@
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use parking_lot::Mutex;
use tauri::{AppHandle, Emitter};
use tempfile::NamedTempFile;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::commands::record_cost;
use crate::config::ClaudeStartOptions;
use crate::process_ext::HideWindow;
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
@@ -39,6 +39,12 @@ const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
fn detect_wsl() -> bool {
// A native Windows binary is never running inside WSL, even if launched from a WSL
// terminal that has WSL_DISTRO_NAME set in its environment.
if cfg!(target_os = "windows") {
return false;
}
// Check /proc/version for WSL indicators
if let Ok(version) = std::fs::read_to_string("/proc/version") {
let version_lower = version.to_lowercase();
@@ -61,23 +67,29 @@ fn detect_wsl() -> bool {
}
fn find_claude_binary() -> Option<String> {
// Check common installation locations for claude
let home = std::env::var("HOME").ok()?;
let paths_to_check = [
format!("{}/.local/bin/claude", home),
format!("{}/.claude/local/claude", home),
"/usr/local/bin/claude".to_string(),
"/usr/bin/claude".to_string(),
];
for path in &paths_to_check {
if std::path::Path::new(path).exists() {
return Some(path.clone());
// Check common installation locations for claude (when HOME is available)
if let Ok(home) = std::env::var("HOME") {
let paths_to_check = [
format!("{}/.local/bin/claude", home),
format!("{}/.claude/local/claude", home),
];
for path in &paths_to_check {
if std::path::Path::new(path).exists() {
return Some(path.clone());
}
}
}
// Fall back to checking PATH via which
if let Ok(output) = Command::new("which").arg("claude").output() {
// Check system-wide locations
for path in &["/usr/local/bin/claude", "/usr/bin/claude"] {
if std::path::Path::new(path).exists() {
return Some((*path).to_string());
}
}
// Use a login shell to resolve claude via the user's PATH - GUI apps don't
// inherit shell PATH, so bare `which` may miss ~/.local/bin entries
if let Ok(output) = Command::new("bash").hide_window().args(["-lc", "which claude"]).output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
@@ -90,48 +102,63 @@ fn find_claude_binary() -> Option<String> {
}
pub struct WslBridge {
process: Option<Child>,
process: Arc<Mutex<Option<Child>>>,
stdin: Option<ChildStdin>,
working_directory: String,
session_id: Option<String>,
mcp_config_file: Option<NamedTempFile>,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
/// Set to true once the `system:init` message arrives, false at the start of every new session.
received_init: Arc<AtomicBool>,
/// Set to true by stop()/interrupt() before killing the process so handle_stdout knows
/// the disconnect was intentional and should not emit a second Disconnected event.
intentional_stop: Arc<AtomicBool>,
}
impl WslBridge {
pub fn new() -> Self {
WslBridge {
process: None,
process: Arc::new(Mutex::new(None)),
stdin: None,
working_directory: String::new(),
session_id: None,
mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: None,
received_init: Arc::new(AtomicBool::new(false)),
intentional_stop: Arc::new(AtomicBool::new(false)),
}
}
pub fn new_with_conversation_id(conversation_id: String) -> Self {
WslBridge {
process: None,
process: Arc::new(Mutex::new(None)),
stdin: None,
working_directory: String::new(),
session_id: None,
mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: Some(conversation_id),
received_init: Arc::new(AtomicBool::new(false)),
intentional_stop: Arc::new(AtomicBool::new(false)),
}
}
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
if self.process.is_some() {
return Err("Process already running".to_string());
}
// Check if Claude binary is installed before attempting to start
if Command::new("which").arg("claude").output().ok().is_none_or(|output| !output.status.success()) {
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
// If a process handle exists but the process has already exited (e.g. due to a
// failed working directory), clean up the stale handle so we can restart cleanly.
{
let mut proc_guard = self.process.lock();
if let Some(ref mut proc) = *proc_guard {
if proc.try_wait().map(|s| s.is_some()).unwrap_or(false) {
*proc_guard = None;
self.stdin = None;
}
}
if proc_guard.is_some() {
return Err("Process already running".to_string());
}
}
// Load saved achievements and stats when starting a new session
@@ -208,6 +235,7 @@ impl WslBridge {
tracing::debug!("Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path);
cmd.hide_window();
cmd.args([
"--output-format",
"stream-json",
@@ -249,6 +277,11 @@ impl WslBridge {
}
}
// Add worktree flag if requested
if options.use_worktree {
cmd.arg("--worktree");
}
cmd.current_dir(working_dir);
// Set API key as environment variable if specified
@@ -258,10 +291,41 @@ impl WslBridge {
}
}
// Disable 1M context window if requested
if options.disable_1m_context {
cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1");
}
cmd
} else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
tracing::debug!("Windows path - using wsl");
// Check if Claude binary is installed inside WSL
let binary_check = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-lc", "which claude"])
.output();
if let Ok(output) = binary_check {
if !output.status.success() {
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
}
}
// Validate the working directory exists inside WSL before spawning
let dir_check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", working_dir])
.output();
if let Ok(output) = dir_check {
if !output.status.success() {
return Err(format!(
"Working directory does not exist: {}",
working_dir
));
}
}
let mut cmd = Command::new("wsl");
// Build the claude command with all arguments
@@ -274,6 +338,11 @@ impl WslBridge {
}
}
// Disable 1M context window if requested
if options.disable_1m_context {
claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 ");
}
claude_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose",
);
@@ -311,12 +380,16 @@ impl WslBridge {
}
}
// Add worktree flag if requested
if options.use_worktree {
claude_cmd.push_str(" --worktree");
}
// Use bash -lc to load login profile (ensures PATH includes claude)
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
// Hide the console window on Windows
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
cmd.hide_window();
cmd
};
@@ -336,7 +409,11 @@ impl WslBridge {
let stderr = child.stderr.take();
self.stdin = stdin;
self.process = Some(child);
*self.process.lock() = Some(child);
// Reset flags so the watchdog and stdout handler start fresh.
self.received_init.store(false, Ordering::SeqCst);
self.intentional_stop.store(false, Ordering::SeqCst);
// Note: We no longer reset stats here - stats persist across reconnects
// Stats are only reset when explicitly disconnecting via stop()
@@ -353,8 +430,17 @@ impl WslBridge {
let app_clone = app.clone();
let stats_clone = self.stats.clone();
let conv_id = self.conversation_id.clone();
let received_init_clone = self.received_init.clone();
let intentional_stop_clone = self.intentional_stop.clone();
thread::spawn(move || {
handle_stdout(stdout, app_clone, stats_clone, conv_id);
handle_stdout(
stdout,
app_clone,
stats_clone,
conv_id,
received_init_clone,
intentional_stop_clone,
);
});
}
@@ -366,12 +452,31 @@ impl WslBridge {
});
}
// Emit Connected immediately so the frontend can send the greeting message.
// This is intentionally optimistic — Claude Code buffers stdout until stdin receives
// data on Windows/WSL, so we must send something to stdin first or system:init never
// arrives. The received_init flag below tracks whether init actually arrived.
emit_connection_status(
&app,
ConnectionStatus::Connected,
self.conversation_id.clone(),
);
// Watchdog: if system:init never arrives the process is truly hung (e.g. a silent crash
// after spawning). After 5 minutes we kill it so the user isn't stuck forever.
// handle_stdout will surface the error when stdout closes after the kill.
let process_watchdog = self.process.clone();
let received_init_watchdog = self.received_init.clone();
thread::spawn(move || {
thread::sleep(Duration::from_secs(60));
if !received_init_watchdog.load(Ordering::SeqCst) {
if let Some(mut proc) = process_watchdog.lock().take() {
let _ = proc.kill();
let _ = proc.wait();
}
}
});
Ok(())
}
@@ -450,7 +555,15 @@ impl WslBridge {
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
// we have to kill the process. This is the only reliable way to stop it.
// See: https://github.com/anthropics/claude-code/issues/3455
if let Some(mut process) = self.process.take() {
// Extract the process first so the MutexGuard is dropped before we mutably
// borrow `self` again via estimate_interrupted_request_cost.
// Signal handle_stdout that this is an intentional stop so it doesn't emit
// a second Disconnected event after stdout closes due to the kill.
self.intentional_stop.store(true, Ordering::SeqCst);
let maybe_process = self.process.lock().take();
if let Some(mut process) = maybe_process {
// Estimate cost for interrupted request before killing
self.estimate_interrupted_request_cost(app);
@@ -580,7 +693,10 @@ impl WslBridge {
}
pub fn stop(&mut self, app: &AppHandle) {
if let Some(mut process) = self.process.take() {
// Signal handle_stdout that this is an intentional stop so it doesn't emit
// a second Disconnected event after stdout closes due to the kill.
self.intentional_stop.store(true, Ordering::SeqCst);
if let Some(mut process) = self.process.lock().take() {
let _ = process.kill();
let _ = process.wait();
}
@@ -611,7 +727,7 @@ impl WslBridge {
}
pub fn is_running(&self) -> bool {
self.process.is_some()
self.process.lock().is_some()
}
pub fn get_working_directory(&self) -> &str {
@@ -634,13 +750,17 @@ fn handle_stdout(
app: AppHandle,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
received_init: Arc<AtomicBool>,
intentional_stop: Arc<AtomicBool>,
) {
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(line) if !line.is_empty() => {
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
if let Err(e) =
process_json_line(&line, &app, &stats, &conversation_id, &received_init)
{
tracing::error!("Error processing line: {}", e);
}
}
@@ -652,6 +772,45 @@ fn handle_stdout(
}
}
// If this was an intentional stop (stop()/interrupt() was called), the caller already
// emitted a Disconnected event. Skip all post-loop emissions to prevent duplicates.
if intentional_stop.load(Ordering::SeqCst) {
return;
}
// If stdout closed before system:init arrived the process exited without initialising.
// Emit an error line so the user understands why the connection failed.
if !received_init.load(Ordering::SeqCst) {
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: "Claude Code exited before initialising. Check the working directory and Claude Code installation, then try connecting again.".to_string(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
// If Claude exited while a prompt was in-flight, the user's message was never processed.
// Emit a specific error so they know to resend their prompt.
let had_pending_request = stats.read().current_request_input.is_some();
if had_pending_request {
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: "Claude Code exited before finishing your request — your last prompt was not processed. Please reconnect and try again.".to_string(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
}
@@ -705,17 +864,28 @@ fn handle_stderr(
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
last_assistant_message: stop_data.last_assistant_message,
},
);
}
}
}
// Still emit the stderr line as output
// Hook events are informational — emit with distinct types instead of error
let line_type = if line.contains("[WorktreeCreate Hook]")
|| line.contains("[WorktreeRemove Hook]")
{
"worktree"
} else if line.contains("[ConfigChange Hook]") {
"config-change"
} else {
"error"
};
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
line_type: line_type.to_string(),
content: line,
tool_name: None,
conversation_id: conversation_id.clone(),
@@ -768,32 +938,84 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
#[derive(Debug)]
struct SubagentStopData {
parent_tool_use_id: Option<String>,
last_assistant_message: Option<String>,
}
/// Extracts the content of a Rust Debug-formatted `Some("...")` field from a hook line.
/// Handles escaped characters (e.g. `\"` → `"`, `\\` → `\`, `\n` → newline).
/// Returns `None` if the field is absent or formatted as `None`.
fn extract_debug_string_value(line: &str, key: &str) -> Option<String> {
let prefix = format!("{}=Some(\"", key);
let start_idx = line.find(&prefix)? + prefix.len();
let rest = &line[start_idx..];
let mut result = String::new();
let mut chars = rest.chars();
loop {
match chars.next() {
Some('"') => return Some(result),
Some('\\') => match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('"') => result.push('"'),
Some('\\') => result.push('\\'),
Some(c) => {
result.push('\\');
result.push(c);
}
None => break,
},
Some(c) => result.push(c),
None => break,
}
}
None
}
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ...
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), last_assistant_message=Some("..."), ...
// Extract parent_tool_use_id if present
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
line.split("parent_tool_use_id=Some(\"")
.nth(1)?
.split('"')
.next()
.map(|s| s.to_string())
} else {
None
};
let parent_tool_use_id = extract_debug_string_value(line, "parent_tool_use_id");
let last_assistant_message = extract_debug_string_value(line, "last_assistant_message");
Some(SubagentStopData {
parent_tool_use_id,
last_assistant_message,
})
}
/// Extract text content from a ToolResult's `content` field.
/// The content may be a JSON string or an array of typed content blocks.
fn extract_tool_result_text(content: &serde_json::Value) -> Option<String> {
match content {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Array(blocks) => {
let texts: Vec<String> = blocks
.iter()
.filter_map(|block| {
if block.get("type")?.as_str()? == "text" {
block.get("text")?.as_str().map(String::from)
} else {
None
}
})
.collect();
if texts.is_empty() {
None
} else {
Some(texts.join("\n"))
}
}
_ => None,
}
}
fn process_json_line(
line: &str,
app: &AppHandle,
stats: &Arc<RwLock<UsageStats>>,
conversation_id: &Option<String>,
received_init: &Arc<AtomicBool>,
) -> Result<(), String> {
let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
@@ -806,6 +1028,9 @@ fn process_json_line(
..
} => {
if subtype == "init" {
// Mark as initialised so the watchdog knows the process is healthy.
received_init.store(true, Ordering::SeqCst);
if let Some(id) = session_id {
let _ = app.emit(
"claude:session",
@@ -1042,17 +1267,37 @@ fn process_json_line(
stats.write().increment_code_blocks();
}
let is_prompt_too_long = text.starts_with("Prompt is too long");
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "assistant".to_string(),
line_type: if is_prompt_too_long {
"error".to_string()
} else {
"assistant".to_string()
},
content: text.clone(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: message_cost.clone(), // Include cost with assistant text
cost: message_cost.clone(),
parent_tool_use_id: parent_tool_use_id.clone(),
},
);
if is_prompt_too_long {
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "compact-prompt".to_string(),
content: String::new(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
}
ContentBlock::Thinking { thinking } => {
state = CharacterState::Thinking;
@@ -1070,8 +1315,8 @@ fn process_json_line(
}
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
..
} => {
// Emit agent-end for all tool results
// The frontend will ignore IDs that don't match known agents
@@ -1089,6 +1334,7 @@ fn process_json_line(
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
last_assistant_message: extract_tool_result_text(content),
},
);
}
@@ -1481,6 +1727,23 @@ fn process_json_line(
emit_state_change(app, state, None, conversation_id.clone());
}
ClaudeMessage::RateLimitEvent { rate_limit_info } => {
tracing::warn!("Rate limit event received: {:?}", rate_limit_info);
let content = format_rate_limit_message(rate_limit_info);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "rate-limit".to_string(),
content,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
ClaudeMessage::User { message } => {
// Increment message count for user messages
stats.write().increment_messages();
@@ -1489,8 +1752,8 @@ fn process_json_line(
for block in &message.content {
if let ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
..
} = block
{
let now = SystemTime::now()
@@ -1507,6 +1770,7 @@ fn process_json_line(
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
last_assistant_message: extract_tool_result_text(content),
},
);
}
@@ -1589,6 +1853,35 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
}
}
fn format_rate_limit_message(info: &crate::types::RateLimitInfo) -> String {
let mut parts = Vec::new();
if let (Some(remaining), Some(limit)) = (info.requests_remaining, info.requests_limit) {
parts.push(format!("requests: {}/{}", remaining, limit));
}
if let (Some(remaining), Some(limit)) = (info.tokens_remaining, info.tokens_limit) {
parts.push(format!("tokens: {}/{}", remaining, limit));
}
if let Some(reset) = &info.requests_reset {
parts.push(format!("resets at {}", reset));
} else if let Some(reset) = &info.tokens_reset {
parts.push(format!("resets at {}", reset));
}
if let Some(retry_ms) = info.retry_after_ms {
let secs = retry_ms / 1000;
parts.push(format!("retry after {}s", secs));
}
if parts.is_empty() {
"Rate limit reached".to_string()
} else {
format!("Rate limit reached — {}", parts.join(", "))
}
}
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
// Helper function to check if a path is a memory file
fn is_memory_path(path: &str) -> bool {
@@ -1649,12 +1942,7 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
}
"Bash" => {
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
let truncated = if cmd.len() > 50 {
format!("{}...", &cmd[..50])
} else {
cmd.to_string()
};
format!("Running: {}", truncated)
format!("Running: {}", cmd)
} else {
"Running command...".to_string()
}
@@ -1815,9 +2103,7 @@ mod tests {
let long_cmd = "a".repeat(100);
let input = serde_json::json!({"command": long_cmd});
let desc = format_tool_description("Bash", &input);
assert!(desc.starts_with("Running: "));
assert!(desc.ends_with("..."));
assert!(desc.len() < 70);
assert_eq!(desc, format!("Running: {}", long_cmd));
}
#[test]
@@ -1874,19 +2160,66 @@ mod tests {
}
#[test]
fn test_claude_binary_check_command_structure() {
// Test that we're using the correct command to check for Claude binary
let output = Command::new("which").arg("claude").output();
fn test_stale_process_detection_with_try_wait() {
// Spawn a real process that exits immediately so we can verify try_wait detects it
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
// The command should execute successfully (even if claude is not found)
// We're just verifying the command structure is valid
assert!(output.is_ok(), "which command should execute without error");
// Wait for it to exit
let _ = child.wait();
// Verify the check logic returns a boolean
// This is the same logic used in start() to check if claude is installed
let _result = output.ok().is_none_or(|o| !o.status.success());
// If claude is not installed, _result will be true (show error)
// If claude is installed, _result will be false (proceed with connection)
// try_wait on an already-exited process should return Some(_)
let status = child.try_wait();
assert!(
status.is_ok(),
"try_wait should not error on an exited process"
);
// The process has already been waited on, so try_wait might return None or Some
// depending on the OS - what matters is that the call succeeds
}
#[test]
fn test_stale_process_is_some_after_exit() {
// Verify the logic used in start(): a process that has exited is detected
// and the handle is cleaned up so start() can proceed
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
// Let it exit
let _ = child.wait();
// This mirrors the check in start()
let has_exited = child
.try_wait()
.map(|s| s.is_some())
.unwrap_or(false);
// After wait(), try_wait() returns None (already reaped), which means
// unwrap_or(false) → false. The important thing is the call doesn't panic
// and the control flow logic compiles and runs correctly.
let _ = has_exited; // suppress unused warning
}
/// Build the WSL binary check command structure without executing it (for testing)
#[cfg(test)]
fn build_wsl_binary_check_args() -> Vec<&'static str> {
vec!["-e", "bash", "-lc", "which claude"]
}
#[test]
fn test_wsl_binary_check_command_structure() {
// Windows path: verify Claude is detected inside WSL via `wsl -e bash -lc "which claude"`
let args = build_wsl_binary_check_args();
assert_eq!(args[0], "-e");
assert_eq!(args[1], "bash");
assert_eq!(args[2], "-lc");
assert_eq!(args[3], "which claude");
}
#[test]
fn test_linux_binary_check_does_not_panic() {
// Linux/WSL path: find_claude_binary() searches Linux filesystem paths.
// We just verify it runs without panicking; whether it returns Some depends
// on whether Claude is actually installed in this environment.
let _result = find_claude_binary();
}
#[test]
@@ -1989,5 +2322,165 @@ mod tests {
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, None);
assert_eq!(data.last_assistant_message, None);
}
#[test]
fn test_parse_subagent_stop_hook_with_last_message() {
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=Some("Task completed successfully."), session_id=123"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string()));
assert_eq!(
data.last_assistant_message,
Some("Task completed successfully.".to_string())
);
}
#[test]
fn test_parse_subagent_stop_hook_with_last_message_none() {
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=None, session_id=123"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.last_assistant_message, None);
}
#[test]
fn test_extract_debug_string_value_simple() {
let line = r#"key=Some("hello world")"#;
assert_eq!(
extract_debug_string_value(line, "key"),
Some("hello world".to_string())
);
}
#[test]
fn test_extract_debug_string_value_with_escaped_quotes() {
let line = r#"key=Some("say \"hi\" there")"#;
assert_eq!(
extract_debug_string_value(line, "key"),
Some(r#"say "hi" there"#.to_string())
);
}
#[test]
fn test_extract_debug_string_value_none_variant() {
let line = "key=None";
assert_eq!(extract_debug_string_value(line, "key"), None);
}
#[test]
fn test_extract_debug_string_value_missing_key() {
let line = "other=Some(\"value\")";
assert_eq!(extract_debug_string_value(line, "key"), None);
}
#[test]
fn test_parse_subagent_stop_hook_with_commas_in_message() {
let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_01"), last_assistant_message=Some("Found 3 files, all passing.")"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(
data.last_assistant_message,
Some("Found 3 files, all passing.".to_string())
);
}
// extract_tool_result_text tests
#[test]
fn test_extract_tool_result_text_plain_string() {
let content = serde_json::json!("Hello from agent");
assert_eq!(
extract_tool_result_text(&content),
Some("Hello from agent".to_string())
);
}
#[test]
fn test_extract_tool_result_text_empty_string() {
let content = serde_json::json!("");
assert_eq!(extract_tool_result_text(&content), None);
}
#[test]
fn test_extract_tool_result_text_array_single_text_block() {
let content = serde_json::json!([{"type": "text", "text": "Agent completed the task."}]);
assert_eq!(
extract_tool_result_text(&content),
Some("Agent completed the task.".to_string())
);
}
#[test]
fn test_extract_tool_result_text_array_multiple_text_blocks() {
let content = serde_json::json!([
{"type": "text", "text": "First part."},
{"type": "text", "text": "Second part."}
]);
assert_eq!(
extract_tool_result_text(&content),
Some("First part.\nSecond part.".to_string())
);
}
#[test]
fn test_extract_tool_result_text_array_non_text_block() {
let content = serde_json::json!([{"type": "image", "source": {"type": "base64"}}]);
assert_eq!(extract_tool_result_text(&content), None);
}
#[test]
fn test_extract_tool_result_text_array_mixed_blocks() {
let content = serde_json::json!([
{"type": "image", "source": {}},
{"type": "text", "text": "Found results."}
]);
assert_eq!(
extract_tool_result_text(&content),
Some("Found results.".to_string())
);
}
#[test]
fn test_extract_tool_result_text_null() {
let content = serde_json::Value::Null;
assert_eq!(extract_tool_result_text(&content), None);
}
#[test]
fn test_extract_tool_result_text_empty_array() {
let content = serde_json::json!([]);
assert_eq!(extract_tool_result_text(&content), None);
}
// Verify the 50K tool result persistence threshold (CLI v2.1.51+).
// Results > 50K chars are now persisted to disk; the stream may send null
// or a large inline string. Both must be handled without panicking.
#[test]
fn test_extract_tool_result_text_large_content_above_50k_threshold() {
let large_text = "x".repeat(60_000);
let content = serde_json::Value::String(large_text.clone());
assert_eq!(extract_tool_result_text(&content), Some(large_text));
}
#[test]
fn test_tool_result_deserializes_with_null_content() {
let json = r#"{"type":"tool_result","tool_use_id":"toolu_abc","content":null}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
if let ContentBlock::ToolResult { tool_use_id, content, is_error } = block {
assert_eq!(tool_use_id, "toolu_abc");
assert!(content.is_null());
assert_eq!(is_error, None);
// Persisted-to-disk results produce null content → no preview shown
assert_eq!(extract_tool_result_text(&content), None);
} else {
panic!("Expected ToolResult variant");
}
}
}
+4
View File
@@ -1,6 +1,8 @@
use std::process::Command;
use tauri::command;
use crate::process_ext::HideWindow;
#[command]
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
// Method 1: Try Windows 10/11 toast notification using PowerShell
@@ -36,6 +38,7 @@ $notifier.Show($toast)
// Try PowerShell.exe through WSL
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
.hide_window()
.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
@@ -65,6 +68,7 @@ $notifier.Show($toast)
// Method 3: Try wsl-notify-send if available
let notify_result = Command::new("wsl-notify-send")
.hide_window()
.arg("--appId")
.arg("HikariDesktop")
.arg("--category")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.5.1",
"version": "1.8.1",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
+4
View File
@@ -61,6 +61,8 @@ async function changeDirectory(path: string): Promise<void> {
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
@@ -135,6 +137,8 @@ async function startNewConversation(): Promise<void> {
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
@@ -270,6 +270,14 @@
/>
</svg>
{/if}
<img
src={agent.characterAvatar}
alt={agent.characterName}
class="w-5 h-5 rounded-full object-cover"
/>
<span class="text-[10px] font-medium text-[var(--text-primary)]">
{agent.characterName}
</span>
<span
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
agent.status
@@ -310,6 +318,16 @@
<span class="text-[10px] text-red-400">Errored / Killed</span>
{/if}
</div>
<!-- Last assistant message snippet -->
{#if agent.lastAssistantMessage}
<p
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
title={agent.lastAssistantMessage}
>
{agent.lastAssistantMessage}
</p>
{/if}
</div>
{/each}
{/if}
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="cast-title"
tabindex="-1"
>
<div class="flex items-center justify-between mb-6">
<h2 id="cast-title" class="text-xl font-semibold text-[var(--text-primary)]">
Meet the Team
</h2>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Principal cast: Hikari + Naomi -->
<div class="grid grid-cols-1 gap-3 mb-6 sm:grid-cols-2">
<div
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
>
<img
src="https://cdn.nhcarrigan.com/hikari.png"
alt="Hikari"
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
/>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-[var(--text-primary)]">Hikari</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
Chief Operating Officer
</span>
</div>
<p class="text-xs text-[var(--text-secondary)]">
Holds the line so the others don't have to. Never without her clipboard — or her
glasses.
</p>
</div>
</div>
<div
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
>
<img
src="https://cdn.nhcarrigan.com/profile.png"
alt="Naomi"
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
/>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-[var(--text-primary)]">Naomi</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
Chief hEx-ecutive Officer
</span>
</div>
<p class="text-xs text-[var(--text-secondary)]">
A 525-year-old vampire running a tech company from behind a VTuber avatar. Fixes server
crashes at 4 AM.
</p>
</div>
</div>
</div>
<!-- Subagent girls grid -->
<div>
<h3 class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3">
Subagent Squad
</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
{#each CHARACTER_POOL as character (character.name)}
<div
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-center"
>
<img
src={character.avatar}
alt={character.name}
class="w-14 h-14 object-cover rounded-full border-2 border-[var(--border-color)]"
/>
<span class="text-sm font-medium text-[var(--text-primary)]">{character.name}</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
{character.title}
</span>
<p class="text-xs text-[var(--text-secondary)] leading-snug">{character.description}</p>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
+111 -19
View File
@@ -2,15 +2,43 @@
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
let version = $state("Loading...");
const SUPPORTED_CLI_VERSION = "2.1.53";
let installedVersion = $state("Loading...");
function compareVersions(a: string, b: string): number {
const aParts = a.split(".").map(Number);
const bParts = b.split(".").map(Number);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aVal = aParts[i] ?? 0;
const bVal = bParts[i] ?? 0;
if (aVal > bVal) return 1;
if (aVal < bVal) return -1;
}
return 0;
}
let displayVersion = $derived(installedVersion.split(" (")[0]);
let supportedBadgeState = $derived.by(() => {
if (installedVersion === "Loading..." || installedVersion === "Unknown") {
return "neutral";
}
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
if (!semverMatch) return "neutral";
const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION);
if (cmp > 0) return "ahead";
if (cmp < 0) return "behind";
return "current";
});
async function fetchVersion() {
try {
const result = await invoke<string>("get_claude_version");
version = result;
installedVersion = result;
} catch (error) {
console.error("Failed to get Claude CLI version:", error);
version = "Unknown";
installedVersion = "Unknown";
}
}
@@ -19,25 +47,60 @@
});
</script>
<div class="cli-version">
<svg
class="terminal-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span class="version-text">CLI {version}</span>
<div class="cli-versions">
<div class="cli-version">
<svg
class="terminal-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span class="version-text">CLI {displayVersion}</span>
</div>
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
<svg
class="terminal-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<span class="version-text">Supported {SUPPORTED_CLI_VERSION}</span>
</div>
{#if supportedBadgeState === "ahead"}
<span class="version-warning ahead"
>Your version is newer, some features may not be supported</span
>
{:else if supportedBadgeState === "behind"}
<span class="version-warning behind"
>Your version is out of date, please update to ensure compatibility</span
>
{/if}
</div>
<style>
.cli-versions {
display: flex;
gap: 6px;
align-items: center;
}
.cli-version {
display: flex;
align-items: center;
@@ -57,6 +120,21 @@
color: var(--accent-primary);
}
.cli-version.supported.current {
border-color: var(--success-color, #4caf50);
color: var(--success-color, #4caf50);
}
.cli-version.supported.ahead {
border-color: var(--warning-color, #ff9800);
color: var(--warning-color, #ff9800);
}
.cli-version.supported.behind {
border-color: var(--error-color, #f44336);
color: var(--error-color, #f44336);
}
.terminal-icon {
flex-shrink: 0;
opacity: 0.7;
@@ -65,4 +143,18 @@
.version-text {
white-space: nowrap;
}
.version-warning {
font-size: 0.75rem;
font-style: italic;
white-space: nowrap;
}
.version-warning.ahead {
color: var(--warning-color, #ff9800);
}
.version-warning.behind {
color: var(--error-color, #f44336);
}
</style>
+9 -3
View File
@@ -3,7 +3,7 @@
import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState, characterInfo } from "$lib/stores/character";
import { isStreamerMode } from "$lib/stores/config";
import { isStreamerMode, configStore } from "$lib/stores/config";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
@@ -14,6 +14,9 @@
let { onExpand }: Props = $props();
const configValues = configStore.config;
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
let inputValue = $state("");
let isSubmitting = $state(false);
let isConnected = $state(false);
@@ -132,7 +135,7 @@
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Interrupted");
claudeStore.addLine("system", "Process interrupted via stop button");
characterState.setState("idle");
} catch (error) {
console.error("Failed to interrupt:", error);
@@ -150,7 +153,10 @@
}
</script>
<div class="compact-container {getStateGlow()}">
<div
class="compact-container {getStateGlow()}"
style={hasBackgroundImage ? "background: transparent !important;" : ""}
>
<!-- Character sprite (smaller) -->
<div class="compact-character">
<div class="sprite-wrapper {getAnimationClass()}">
+259 -1
View File
@@ -12,6 +12,8 @@
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import CostSummary from "./CostSummary.svelte";
let config: HikariConfig = $state({
@@ -52,10 +54,30 @@
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
});
let showCustomThemeEditor = $state(false);
interface AuthStatus {
is_logged_in: boolean;
email: string | null;
org_id: string | null;
org_name: string | null;
api_key_source: string | null;
api_provider: string | null;
subscription_type: string | null;
}
let authStatus: AuthStatus | null = $state(null);
let authLoading = $state(false);
let authActionLoading = $state(false);
let authError: string | null = $state(null);
let isOpen = $state(false);
let isSaving = $state(false);
let saveError: string | null = $state(null);
@@ -69,6 +91,9 @@
configStore.isSidebarOpen.subscribe((open) => {
isOpen = open;
if (open && authStatus === null) {
void refreshAuthStatus();
}
});
configStore.saveError.subscribe((error) => {
@@ -83,8 +108,9 @@
{ value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.6)
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Recommended)" },
// Previous generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
// Previous generation (Claude 4.x)
@@ -110,6 +136,44 @@
"Task",
];
async function refreshAuthStatus() {
authLoading = true;
authError = null;
try {
authStatus = await invoke<AuthStatus>("get_auth_status");
} catch (e) {
authError = String(e);
} finally {
authLoading = false;
}
}
async function handleAuthLogin() {
authActionLoading = true;
authError = null;
try {
await invoke<string>("auth_login");
await refreshAuthStatus();
} catch (e) {
authError = String(e);
} finally {
authActionLoading = false;
}
}
async function handleAuthLogout() {
authActionLoading = true;
authError = null;
try {
await invoke<string>("auth_logout");
await refreshAuthStatus();
} catch (e) {
authError = String(e);
} finally {
authActionLoading = false;
}
}
async function handleSave() {
isSaving = true;
saveError = null;
@@ -181,6 +245,20 @@
await window.setAlwaysOnTop(enabled);
await configStore.updateConfig({ always_on_top: enabled });
}
async function pickBackgroundImage() {
const selected = await open({
multiple: false,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "avif"] }],
});
if (selected) {
config.background_image_path = selected;
}
}
function clearBackgroundImage() {
config.background_image_path = null;
}
</script>
<!-- Backdrop -->
@@ -227,6 +305,109 @@
</div>
{/if}
<!-- Account Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Account
</h3>
{#if authLoading}
<div class="text-sm text-[var(--text-secondary)] py-2">Checking auth status...</div>
{:else if authStatus}
<div class="flex items-center gap-2 mb-3">
<span
class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 {authStatus.is_logged_in
? 'bg-green-500'
: 'bg-red-500'}"
></span>
<span class="text-sm font-medium text-[var(--text-primary)]">
{authStatus.is_logged_in ? "Logged in" : "Not logged in"}
</span>
</div>
{#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key}
<dl class="text-xs space-y-1 mb-3">
{#if authStatus.email}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Email</dt>
<dd class="text-[var(--text-primary)] break-all">{authStatus.email}</dd>
</div>
{/if}
{#if authStatus.org_name}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org</dt>
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
</div>
{/if}
{#if authStatus.org_id}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org UUID</dt>
<dd class="text-[var(--text-secondary)] font-mono text-[10px] break-all">
{authStatus.org_id}
</dd>
</div>
{/if}
{#if authStatus.api_key_source}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
<dd class="text-[var(--text-primary)]">{authStatus.api_key_source}</dd>
</div>
{/if}
{#if authStatus.subscription_type}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Plan</dt>
<dd class="text-[var(--text-primary)]">{authStatus.subscription_type}</dd>
</div>
{/if}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Override</dt>
<dd class="text-[var(--text-primary)]">
{#if config.api_key}
{config.streamer_mode ? "Custom key set 🔒" : "Custom key set"}
{:else}
None
{/if}
</dd>
</div>
</dl>
{/if}
{:else}
<div class="text-sm text-[var(--text-secondary)] py-2">Auth status unavailable</div>
{/if}
{#if authError}
<div class="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">
{authError}
</div>
{/if}
<div class="flex gap-2">
<button
onclick={refreshAuthStatus}
disabled={authLoading || authActionLoading}
class="px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
Refresh
</button>
{#if authStatus && !authStatus.is_logged_in}
<button
onclick={handleAuthLogin}
disabled={authActionLoading}
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
>
{authActionLoading ? "Logging in..." : "Login"}
</button>
{:else if authStatus && authStatus.is_logged_in}
<button
onclick={handleAuthLogout}
disabled={authActionLoading}
class="px-3 py-1.5 text-sm bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
>
{authActionLoading ? "Logging out..." : "Logout"}
</button>
{/if}
</div>
</section>
<!-- Agent Settings Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -321,6 +502,37 @@
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
></textarea>
</div>
<!-- Worktree Isolation -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.use_worktree}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Worktree isolation</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
</p>
</div>
<!-- Disable 1M Context Window -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.disable_1m_context}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Disable 1M context window</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_1M_CONTEXT=1</code> to opt out of the extended
context window
</p>
</div>
</section>
<!-- Greeting Section -->
@@ -720,6 +932,52 @@
expanded/collapsed to see reasoning details.
</p>
</div>
<!-- Background Image -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Background Image</span>
{#if config.background_image_path}
<p class="text-xs text-[var(--text-tertiary)] font-mono mb-2 truncate">
{config.background_image_path.split("/").pop()}
</p>
{/if}
<div class="flex gap-2">
<button
onclick={pickBackgroundImage}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
{config.background_image_path ? "Change Image" : "Choose Image"}
</button>
{#if config.background_image_path}
<button
onclick={clearBackgroundImage}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
title="Remove background image"
>
Clear
</button>
{/if}
</div>
{#if config.background_image_path}
<div class="mt-3">
<div class="flex items-center justify-between mb-1">
<label for="bg-opacity" class="text-xs text-[var(--text-secondary)]"> Opacity </label>
<span class="text-xs text-[var(--text-tertiary)]">
{Math.round(config.background_image_opacity * 100)}%
</span>
</div>
<input
id="bg-opacity"
type="range"
bind:value={config.background_image_opacity}
min="0.05"
max="1"
step="0.05"
class="w-full h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
</div>
{/if}
</div>
</section>
<!-- Window Section -->
+117 -13
View File
@@ -12,6 +12,25 @@
let editingTabId = $state<string | null>(null);
let editingName = $state("");
// Tab order for pointer-drag reordering
let tabOrder = $state<string[]>([]);
let draggedId = $state<string | null>(null);
let dragOverId = $state<string | null>(null);
let dragStartX = 0;
let isDragging = false;
let wasDragged = false;
let tabsRef = $state<HTMLElement | null>(null);
// Keep tabOrder in sync with conversations map (add new, remove deleted)
$effect(() => {
const currentIds = Array.from($conversations.keys());
const validIds = tabOrder.filter((id) => currentIds.includes(id));
const newIds = currentIds.filter((id) => !tabOrder.includes(id));
if (validIds.length !== tabOrder.length || newIds.length > 0) {
tabOrder = [...validIds, ...newIds];
}
});
// Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>();
@@ -138,8 +157,73 @@
}
}
async function handleTabClick(id: string) {
if (wasDragged) {
wasDragged = false;
return;
}
await switchTab(id);
}
function handlePointerDown(event: PointerEvent, id: string) {
if (editingTabId === id) return;
draggedId = id;
dragStartX = event.clientX;
isDragging = false;
wasDragged = false;
function onMove(e: PointerEvent) {
if (!isDragging && Math.abs(e.clientX - dragStartX) > 5) {
isDragging = true;
}
if (!isDragging || !tabsRef) return;
const tabs = tabsRef.querySelectorAll<HTMLElement>("[data-tab-id]");
dragOverId = null;
for (const tab of tabs) {
const rect = tab.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right) {
const tabId = tab.dataset.tabId;
if (tabId && tabId !== id) {
dragOverId = tabId;
}
break;
}
}
}
function onUp() {
if (isDragging && draggedId && dragOverId && draggedId !== dragOverId) {
const order = [...tabOrder];
const fromIndex = order.indexOf(draggedId);
const toIndex = order.indexOf(dragOverId);
order.splice(fromIndex, 1);
order.splice(toIndex, 0, draggedId);
tabOrder = order;
wasDragged = true;
}
draggedId = null;
dragOverId = null;
isDragging = false;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
}
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
}
// Keyboard shortcuts
onMount(() => {
// Initialise all conversations as seen on mount so that remounting
// this component (e.g. after closing the file editor) doesn't falsely
// mark existing messages as unread.
for (const [id, conversation] of $conversations) {
lastSeenMessageCount.set(id, conversation.terminalLines.length);
}
lastSeenMessageCount = lastSeenMessageCount;
function handleGlobalKeydown(event: KeyboardEvent) {
// Ctrl/Cmd + T: New tab
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
@@ -165,21 +249,19 @@
// Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabs[nextIndex]);
const nextIndex = (currentIndex + 1) % tabOrder.length;
claudeStore.switchConversation(tabOrder[nextIndex]);
}
}
// Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault();
const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabs[prevIndex]);
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
claudeStore.switchConversation(tabOrder[prevIndex]);
}
}
}
@@ -190,15 +272,22 @@
</script>
<div
bind:this={tabsRef}
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
>
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
{#each tabOrder
.filter((id) => $conversations.has(id))
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
<div
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
data-tab-id={id}
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
{id === $activeConversationId
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
onclick={() => switchTab(id)}
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
{draggedId === id ? 'dragging' : ''}"
onpointerdown={(e) => handlePointerDown(e, id)}
onclick={() => handleTabClick(id)}
onkeydown={(e) => handleTabKeydown(id, e)}
role="tab"
tabindex={0}
@@ -211,7 +300,7 @@
onblur={saveTabName}
onkeydown={handleKeydown}
onclick={(e) => e.stopPropagation()}
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32 select-text"
/>
{:else}
<div class="flex items-center gap-2">
@@ -296,5 +385,20 @@
.tab-item {
min-width: 100px;
cursor: grab;
touch-action: none;
user-select: none;
}
.tab-item:active {
cursor: grabbing;
}
.drag-over {
border-left: 2px solid var(--accent-primary);
}
.dragging {
opacity: 0.4;
}
</style>
+20 -1
View File
@@ -164,6 +164,17 @@
attachments = storedAttachments;
});
// Per-tab draft persistence — restore the draft text whenever the active
// conversation changes, and save it back on every keystroke.
claudeStore.activeConversationId.subscribe((conversationId) => {
if (conversationId) {
const conv = get(claudeStore.conversations).get(conversationId);
inputValue = conv?.draftText ?? "";
} else {
inputValue = "";
}
});
function handleInputChange() {
// If input is empty, allow history navigation again
// Otherwise, mark that user has manually typed
@@ -176,6 +187,12 @@
historyIndex = -1;
tempInput = "";
// Save the current draft so it persists if the user switches tabs.
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
claudeStore.setDraftText(activeId, inputValue);
}
if (isSlashCommand(inputValue)) {
matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0;
@@ -326,7 +343,7 @@ User: ${formattedMessage}`;
throw new Error("No active conversation");
}
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted - reconnecting...");
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
characterState.setState("idle");
// Show connecting status while we reconnect
@@ -362,6 +379,8 @@ User: ${formattedMessage}`;
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
+13 -2
View File
@@ -35,7 +35,12 @@
};
renderer.codespan = ({ text }) => {
return `<code class="hljs-inline">${text}</code>`;
const escaped = text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
return `<code class="hljs-inline">${escaped}</code>`;
};
renderer.html = ({ text }) => {
return text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
};
marked.setOptions({
@@ -276,10 +281,16 @@
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(ul),
.markdown-content :global(ul) {
margin: 0.5em 0;
padding-left: 1.5em;
list-style-type: disc;
}
.markdown-content :global(ol) {
margin: 0.5em 0;
padding-left: 1.5em;
list-style-type: decimal;
}
.markdown-content :global(li) {
+27 -44
View File
@@ -190,10 +190,13 @@
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
<label
for="mcp-new-name"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Server Name</label
>
<input
id="mcp-new-name"
type="text"
bind:value={newServerName}
placeholder="my-server"
@@ -201,10 +204,13 @@
/>
</div>
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
<label
for="mcp-new-transport"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Transport</label
>
<select
id="mcp-new-transport"
bind:value={newServerTransport}
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
>
@@ -214,10 +220,14 @@
</select>
</div>
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1">
<label
for="mcp-new-url"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>
{newServerTransport === "stdio" ? "Command" : "URL"}
</label>
<input
id="mcp-new-url"
type="text"
bind:value={newServerUrl}
placeholder={newServerTransport === "stdio"
@@ -266,6 +276,7 @@
{:else}
<div class="space-y-2">
{#each servers as server (server.name)}
{@const TransportIcon = getTransportIcon(server.transport)}
<button
onclick={() => loadServerDetails(server.name)}
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
@@ -274,10 +285,7 @@
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
<svelte:component
this={getTransportIcon(server.transport)}
class="w-4 h-4 {getTransportColor(server.transport)}"
/>
<TransportIcon class="w-4 h-4 {getTransportColor(server.transport)}" />
{server.name}
{#if server.status}
{#if server.status.includes("Connected")}
@@ -323,25 +331,19 @@
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
</div>
{:else}
{@const TransportIcon = getTransportIcon(selectedServer.transport)}
<div class="space-y-4">
<!-- Name -->
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Name</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
</div>
<!-- Transport -->
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Transport</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
<svelte:component
this={getTransportIcon(selectedServer.transport)}
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
/>
<TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
{selectedServer.transport.toUpperCase()}
</p>
</div>
@@ -349,9 +351,7 @@
<!-- URL or Command -->
{#if selectedServer.url}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>URL</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</p>
<p
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
@@ -362,9 +362,7 @@
{#if selectedServer.command}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Command</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</p>
<p
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
@@ -376,9 +374,9 @@
<!-- Environment Variables -->
{#if selectedServer.env}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Environment</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
Environment
</p>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
selectedServer.env,
@@ -391,9 +389,9 @@
<!-- Full Server Details -->
{#if serverDetails}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Full Details</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
Full Details
</p>
<pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
</div>
@@ -416,18 +414,3 @@
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
+5 -3
View File
@@ -9,10 +9,10 @@
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let permissions: PermissionRequest[] = $state([]);
let permissions: PermissionRequest[] = [];
let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
let grantedToolsList: string[] = [];
let workingDirectory = "";
conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms;
@@ -87,6 +87,8 @@
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
@@ -430,18 +430,3 @@
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
@@ -471,6 +471,7 @@
tabindex="0"
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
onclick={(e) => e.stopPropagation()}
+88 -5
View File
@@ -27,6 +27,7 @@
import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte";
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
import CastPanel from "./CastPanel.svelte";
import PluginManagementPanel from "./PluginManagementPanel.svelte";
import McpManagementPanel from "./McpManagementPanel.svelte";
import { conversationsStore } from "$lib/stores/conversations";
@@ -37,6 +38,8 @@
} 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";
@@ -56,9 +59,12 @@
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({
@@ -99,6 +105,11 @@
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
});
let streamerModeActive = $state(false);
@@ -152,11 +163,7 @@
}
}
async function handleConnect() {
if (isConnecting || connectionStatus === "connected") return;
const targetDir = selectedDirectory || "/home/naomi";
async function doConnect(targetDir: string) {
// Combine session-granted tools with config auto-granted tools
const allAllowedTools = [
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
@@ -176,6 +183,8 @@
custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
},
});
@@ -194,6 +203,52 @@
}
}
async function handleConnect() {
if (isConnecting || connectionStatus === "connected") return;
const targetDir = selectedDirectory || "/home/naomi";
if (currentConfig.trusted_workspaces?.includes(targetDir)) {
await doConnect(targetDir);
return;
}
try {
const hookInfo = await invoke<WorkspaceHookInfo>("check_workspace_hooks", {
workingDir: targetDir,
});
if (hookInfo.has_concerns) {
pendingHookInfo = hookInfo;
showWorkspaceTrust = true;
return;
}
} catch (error) {
// Fail open: if we can't check hooks, proceed with connection
console.error("Failed to check workspace hooks:", error);
}
await doConnect(targetDir);
}
async function handleTrustAndConnect() {
showWorkspaceTrust = false;
const targetDir = selectedDirectory || "/home/naomi";
pendingHookInfo = null;
const alreadyTrusted = currentConfig.trusted_workspaces?.includes(targetDir) ?? false;
if (!alreadyTrusted) {
await configStore.updateConfig({
trusted_workspaces: [...(currentConfig.trusted_workspaces ?? []), targetDir],
});
}
doConnect(targetDir);
}
function handleCancelConnect() {
showWorkspaceTrust = false;
pendingHookInfo = null;
}
async function handleDisconnect() {
try {
const conversationId = get(claudeStore.activeConversationId);
@@ -287,6 +342,8 @@
custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
},
});
@@ -519,6 +576,20 @@
/>
</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
@@ -737,6 +808,10 @@
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if}
{#if showCastPanel}
<CastPanel onClose={() => (showCastPanel = false)} />
{/if}
{#if showPluginPanel}
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
{/if}
@@ -745,6 +820,14 @@
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if}
{#if showWorkspaceTrust && pendingHookInfo}
<WorkspaceTrustModal
hookInfo={pendingHookInfo}
onTrust={handleTrustAndConnect}
onCancel={handleCancelConnect}
/>
{/if}
<style>
/* Responsive status bar styling */
.status-bar {
+118 -1
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
@@ -92,6 +94,14 @@
return "terminal-error";
case "thinking":
return "terminal-thinking";
case "rate-limit":
return "terminal-rate-limit";
case "compact-prompt":
return "terminal-compact-prompt";
case "worktree":
return "terminal-worktree";
case "config-change":
return "terminal-config-change";
default:
return "terminal-default";
}
@@ -109,6 +119,12 @@
return "[tool]";
case "error":
return "[error]";
case "rate-limit":
return "[rate-limit]";
case "worktree":
return "[worktree]";
case "config-change":
return "[config]";
default:
return "";
}
@@ -187,6 +203,27 @@
copiedMessageId = null;
}, 2000);
}
async function handleCompact() {
if (!currentConversationId) return;
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
}
// Collapsible tool lines
const TOOL_COLLAPSE_THRESHOLD = 60;
let expandedToolLines: Record<string, boolean> = {};
function isToolContentLong(content: string): boolean {
return content.length > TOOL_COLLAPSE_THRESHOLD;
}
function truncateToolContent(content: string): string {
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
}
function toggleToolLine(id: string) {
expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] };
}
</script>
<div
@@ -262,7 +299,11 @@
{#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant" || line.type === "user"}
{#if line.type === "compact-prompt"}
<button class="compact-action-btn" onclick={handleCompact}>
⚡ Compact Conversation
</button>
{:else if line.type === "assistant" || line.type === "user"}
<div class="message-content-wrapper">
<Markdown
content={maskPaths(line.content, hidePaths)}
@@ -289,6 +330,22 @@
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button>
</div>
{:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))}
<span class="tool-collapsible">
<HighlightedText
content={expandedToolLines[line.id]
? maskPaths(line.content, hidePaths)
: truncateToolContent(maskPaths(line.content, hidePaths))}
searchQuery={currentSearchQuery}
/>
<button
class="tool-toggle-btn"
onclick={() => toggleToolLine(line.id)}
title={expandedToolLines[line.id] ? "Collapse" : "Expand to see full content"}
>
{expandedToolLines[line.id] ? "▲" : "▼"}
</button>
</span>
{:else}
<HighlightedText
content={maskPaths(line.content, hidePaths)}
@@ -329,6 +386,42 @@
color: var(--terminal-error, #f87171);
}
.terminal-rate-limit {
color: var(--terminal-rate-limit, #fb923c);
}
.terminal-compact-prompt {
color: var(--text-secondary);
}
.terminal-worktree {
color: var(--terminal-worktree, #34d399);
}
.terminal-config-change {
color: var(--terminal-config-change, #a78bfa);
}
.compact-action-btn {
display: inline-flex;
align-items: center;
gap: 0.4em;
background: var(--bg-secondary);
border: 1px solid var(--terminal-error, #f87171);
color: var(--terminal-error, #f87171);
padding: 0.3em 0.8em;
cursor: pointer;
border-radius: 4px;
font-size: 0.9em;
font-family: inherit;
transition: all 0.15s ease;
}
.compact-action-btn:hover {
background: color-mix(in srgb, var(--terminal-error, #f87171) 15%, transparent);
color: var(--terminal-error, #f87171);
}
.terminal-default {
color: var(--text-primary);
}
@@ -408,4 +501,28 @@
.terminal-line {
position: relative;
}
.tool-collapsible {
display: inline-flex;
align-items: baseline;
gap: 0.4em;
}
.tool-toggle-btn {
background: none;
border: none;
color: var(--text-tertiary, #6b7280);
cursor: pointer;
font-size: 0.7em;
padding: 0;
line-height: 1;
opacity: 0.7;
transition: opacity 0.15s ease;
font-family: inherit;
}
.tool-toggle-btn:hover {
opacity: 1;
color: var(--terminal-tool, #c084fc);
}
</style>
+264
View File
@@ -0,0 +1,264 @@
/**
* Terminal Component Tests
*
* Tests the pure helper functions extracted from the Terminal component:
* - getLineClass: maps line types to CSS class names
* - getLinePrefix: maps line types to display prefixes
* - formatTime: formats a Date as "HH:MM AM/PM"
* - isToolContentLong: checks if tool content exceeds collapse threshold
* - truncateToolContent: truncates long tool content with ellipsis
*
* Manual testing checklist:
* - [ ] rate-limit lines appear in amber
* - [ ] error lines appear in red
* - [ ] tool lines appear in purple
* - [ ] system lines appear in grey italic
* - [ ] user lines appear in cyan
* - [ ] assistant lines appear in primary text colour
* - [ ] long tool content is collapsed by default with a toggle button
*/
import { describe, it, expect } from "vitest";
// Mirror functions from Terminal.svelte for isolated testing
function getLineClass(type: string): string {
switch (type) {
case "user":
return "terminal-user";
case "assistant":
return "terminal-assistant";
case "system":
return "terminal-system italic";
case "tool":
return "terminal-tool";
case "error":
return "terminal-error";
case "thinking":
return "terminal-thinking";
case "rate-limit":
return "terminal-rate-limit";
case "compact-prompt":
return "terminal-compact-prompt";
case "worktree":
return "terminal-worktree";
case "config-change":
return "terminal-config-change";
default:
return "terminal-default";
}
}
function getLinePrefix(type: string): string {
switch (type) {
case "user":
return ">";
case "assistant":
return "";
case "system":
return "[system]";
case "tool":
return "[tool]";
case "error":
return "[error]";
case "rate-limit":
return "[rate-limit]";
case "worktree":
return "[worktree]";
case "config-change":
return "[config]";
default:
return "";
}
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
const TOOL_COLLAPSE_THRESHOLD = 60;
function isToolContentLong(content: string): boolean {
return content.length > TOOL_COLLAPSE_THRESHOLD;
}
function truncateToolContent(content: string): string {
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
}
// ---
describe("getLineClass", () => {
it("returns terminal-user for user lines", () => {
expect(getLineClass("user")).toBe("terminal-user");
});
it("returns terminal-assistant for assistant lines", () => {
expect(getLineClass("assistant")).toBe("terminal-assistant");
});
it("returns terminal-system italic for system lines", () => {
expect(getLineClass("system")).toBe("terminal-system italic");
});
it("returns terminal-tool for tool lines", () => {
expect(getLineClass("tool")).toBe("terminal-tool");
});
it("returns terminal-error for error lines", () => {
expect(getLineClass("error")).toBe("terminal-error");
});
it("returns terminal-thinking for thinking lines", () => {
expect(getLineClass("thinking")).toBe("terminal-thinking");
});
it("returns terminal-rate-limit for rate-limit lines", () => {
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
});
it("returns terminal-compact-prompt for compact-prompt lines", () => {
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt");
});
it("returns terminal-worktree for worktree lines", () => {
expect(getLineClass("worktree")).toBe("terminal-worktree");
});
it("returns terminal-config-change for config-change lines", () => {
expect(getLineClass("config-change")).toBe("terminal-config-change");
});
it("returns terminal-default for unknown line types", () => {
expect(getLineClass("unknown")).toBe("terminal-default");
expect(getLineClass("")).toBe("terminal-default");
expect(getLineClass("random-future-type")).toBe("terminal-default");
});
});
describe("getLinePrefix", () => {
it("returns > for user lines", () => {
expect(getLinePrefix("user")).toBe(">");
});
it("returns empty string for assistant lines", () => {
expect(getLinePrefix("assistant")).toBe("");
});
it("returns [system] for system lines", () => {
expect(getLinePrefix("system")).toBe("[system]");
});
it("returns [tool] for tool lines", () => {
expect(getLinePrefix("tool")).toBe("[tool]");
});
it("returns [error] for error lines", () => {
expect(getLinePrefix("error")).toBe("[error]");
});
it("returns [rate-limit] for rate-limit lines", () => {
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
});
it("returns empty string for compact-prompt lines (button renders instead)", () => {
expect(getLinePrefix("compact-prompt")).toBe("");
});
it("returns [worktree] for worktree lines", () => {
expect(getLinePrefix("worktree")).toBe("[worktree]");
});
it("returns [config] for config-change lines", () => {
expect(getLinePrefix("config-change")).toBe("[config]");
});
it("returns empty string for thinking lines (no prefix)", () => {
expect(getLinePrefix("thinking")).toBe("");
});
it("returns empty string for unknown line types", () => {
expect(getLinePrefix("unknown")).toBe("");
expect(getLinePrefix("")).toBe("");
});
});
describe("formatTime", () => {
it("formats time in 12-hour format with AM/PM", () => {
const date = new Date(2026, 1, 7, 14, 35);
const formatted = formatTime(date);
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
});
it("formats afternoon times correctly", () => {
const date = new Date(2026, 1, 7, 14, 35);
const formatted = formatTime(date);
expect(formatted).toContain("02:35");
expect(formatted.toUpperCase()).toContain("PM");
});
it("formats morning times correctly", () => {
const date = new Date(2026, 1, 7, 9, 5);
const formatted = formatTime(date);
expect(formatted).toContain("09:05");
expect(formatted.toUpperCase()).toContain("AM");
});
it("formats midnight correctly", () => {
const date = new Date(2026, 1, 7, 0, 0);
const formatted = formatTime(date);
expect(formatted).toContain("12:00");
expect(formatted.toUpperCase()).toContain("AM");
});
it("formats noon correctly", () => {
const date = new Date(2026, 1, 7, 12, 0);
const formatted = formatTime(date);
expect(formatted).toContain("12:00");
expect(formatted.toUpperCase()).toContain("PM");
});
});
describe("isToolContentLong", () => {
it("returns false for content at or below the threshold", () => {
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
expect(isToolContentLong(exactThreshold)).toBe(false);
});
it("returns true for content exceeding the threshold", () => {
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
expect(isToolContentLong(overThreshold)).toBe(true);
});
it("returns false for short content", () => {
expect(isToolContentLong("short")).toBe(false);
});
it("returns false for empty content", () => {
expect(isToolContentLong("")).toBe(false);
});
});
describe("truncateToolContent", () => {
it("truncates content to the threshold length with an ellipsis", () => {
const long = "x".repeat(100);
const result = truncateToolContent(long);
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
});
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
const short = "hello";
const result = truncateToolContent(short);
expect(result).toBe("hello…");
});
it("uses the unicode ellipsis character (not three dots)", () => {
const long = "x".repeat(100);
const result = truncateToolContent(long);
expect(result.endsWith("…")).toBe(true);
expect(result.endsWith("...")).toBe(false);
});
});
@@ -73,7 +73,7 @@
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
bind:this={menuElement}
class="menu-content"
@@ -106,6 +106,8 @@
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: grantedToolsList,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
@@ -0,0 +1,110 @@
<script lang="ts">
import { characterState } from "$lib/stores/character";
import type { WorkspaceHookInfo } from "$lib/types/messages";
interface Props {
hookInfo: WorkspaceHookInfo;
onTrust: () => void;
onCancel: () => void;
}
const { hookInfo, onTrust, onCancel }: Props = $props();
$effect(() => {
characterState.setState("permission");
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onclick={onCancel}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 max-w-md w-full mx-4 shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<div class="flex items-center gap-3 mb-4">
<svg
class="w-6 h-6 text-yellow-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Workspace Trust Required</h2>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-4">
This workspace contains configuration that can execute code on your system. Review what was
found before connecting.
</p>
<div class="space-y-3 mb-4">
{#if hookInfo.hook_types.length > 0}
<div class="bg-[var(--bg-primary)] rounded-md p-3">
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
Hooks (run shell commands automatically):
</p>
<ul class="space-y-1">
{#each hookInfo.hook_types as hookType (hookType)}
<li class="text-sm text-yellow-400 font-mono">{hookType}</li>
{/each}
</ul>
</div>
{/if}
{#if hookInfo.mcp_servers.length > 0}
<div class="bg-[var(--bg-primary)] rounded-md p-3">
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
MCP servers (run as local processes with system access):
</p>
<ul class="space-y-1">
{#each hookInfo.mcp_servers as server (server)}
<li class="text-sm text-yellow-400 font-mono">{server}</li>
{/each}
</ul>
</div>
{/if}
{#if hookInfo.custom_commands.length > 0}
<div class="bg-[var(--bg-primary)] rounded-md p-3">
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
Custom slash commands (can execute arbitrary instructions):
</p>
<ul class="space-y-1">
{#each hookInfo.custom_commands as cmd (cmd)}
<li class="text-sm text-yellow-400 font-mono">• /{cmd}</li>
{/each}
</ul>
</div>
{/if}
</div>
<p class="text-xs text-[var(--text-secondary)] mb-6">
Only connect to workspaces you trust. Trusting this workspace will remember your choice for
future sessions.
</p>
<div class="flex gap-3 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded-md transition-colors"
>
Cancel
</button>
<button
onclick={onTrust}
class="px-4 py-2 text-sm bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/30 rounded-md transition-colors"
>
Trust and Connect
</button>
</div>
</div>
</div>
@@ -30,9 +30,9 @@
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div class="dialog-overlay" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
<h2 class="dialog-title">{title}</h2>
<p class="dialog-message">{message}</p>
@@ -83,7 +83,7 @@
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
bind:this={menuElement}
class="menu-content"
@@ -78,7 +78,7 @@
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="menu-overlay"
onclick={onClose}
@@ -87,7 +87,7 @@
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
bind:this={menuElement}
class="menu-content"
+2 -2
View File
@@ -50,9 +50,9 @@
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div class="dialog-overlay" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
<h2 class="dialog-title">{title}</h2>
+6 -74
View File
@@ -1,52 +1,8 @@
import { characterState } from "$lib/stores/character";
import { notificationManager } from "./notificationManager";
import type { CharacterState } from "$lib/types/states";
import type { ConnectionStatus } from "$lib/types/messages";
// Track previous states to detect transitions
let previousCharacterState: CharacterState | null = null;
// Track previous connection status to detect transitions
let previousConnectionStatus: ConnectionStatus | null = null;
let taskStartTime: number | null = null;
let hasNotifiedTaskStart = false;
export function handleCharacterStateChange(newState: CharacterState): void {
// Detect state transitions
if (previousCharacterState === newState) return;
// Task completion: any state -> success
if (newState === "success" && previousCharacterState !== null) {
const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0;
// Only notify for tasks that took more than 2 seconds
if (taskDuration > 2000) {
notificationManager.notifySuccess();
}
taskStartTime = null;
}
// Error occurred
if (newState === "error" && previousCharacterState !== "error") {
notificationManager.notifyError();
}
// Permission needed
if (newState === "permission") {
notificationManager.notifyPermission();
}
// Starting long tasks - only notify once per response
if (
(newState === "coding" || newState === "searching") &&
previousCharacterState !== "coding" &&
previousCharacterState !== "searching" &&
!hasNotifiedTaskStart
) {
taskStartTime = Date.now();
hasNotifiedTaskStart = true;
notificationManager.notifyTaskStart();
}
previousCharacterState = newState;
}
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
// Only notify on successful connection after being disconnected
@@ -67,37 +23,13 @@ export function handleToolExecution(_toolName: string): void {
// But we could add specific rules here if needed
}
// Reset notification state for a new response
export function handleNewUserMessage(): void {
hasNotifiedTaskStart = false;
}
// No-op: sound tracking is now per-conversation in tauri.ts
export function handleNewUserMessage(): void {}
// Store unsubscribe functions
let unsubscribeCharacterState: (() => void) | null = null;
// No-op: all per-conversation sounds are driven by tauri.ts event listeners
export function initializeNotificationRules(): void {}
// Initialize listeners
export function initializeNotificationRules(): void {
// Clean up any existing subscriptions first
cleanupNotificationRules();
// Subscribe to character state changes
unsubscribeCharacterState = characterState.subscribe((state) => {
handleCharacterStateChange(state);
});
// We'll connect to connection status in the next step
}
// Cleanup function to prevent duplicate listeners
// Cleanup — reset connection tracking on teardown
export function cleanupNotificationRules(): void {
if (unsubscribeCharacterState) {
unsubscribeCharacterState();
unsubscribeCharacterState = null;
}
// Reset state tracking
previousCharacterState = null;
previousConnectionStatus = null;
taskStartTime = null;
hasNotifiedTaskStart = false;
}
+58 -7
View File
@@ -2,12 +2,15 @@ import { describe, it, expect, beforeEach } from "vitest";
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
import { get } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
describe("agents store", () => {
const conversationId = "test-conversation-1";
const otherConversationId = "test-conversation-2";
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">;
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
toolUseId: "toolu_test123",
description: "Test agent",
subagentType: "Explore",
@@ -37,7 +40,29 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(1);
expect(agents[0]).toEqual(agent);
expect(agents[0]).toMatchObject(agent);
});
it("assigns a character name and avatar to added agents", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
const agents = get(getAgentsForConversation(conversationId));
const validNames = CHARACTER_POOL.map((c) => c.name);
expect(validNames).toContain(agents[0].characterName);
expect(agents[0].characterAvatar).toMatch(/^https:\/\//u);
});
it("avoids duplicate character names across agents when possible", () => {
// Add 6 agents - each should ideally get a unique character
for (let i = 0; i < 6; i++) {
agentStore.addAgent(conversationId, createMockAgent({ toolUseId: `tool${i.toString()}` }));
}
const agents = get(getAgentsForConversation(conversationId));
const names = agents.map((a) => a.characterName);
const uniqueNames = new Set(names);
expect(uniqueNames.size).toBe(6);
});
it("adds multiple agents to the same conversation", () => {
@@ -49,8 +74,8 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(2);
expect(agents[0]).toEqual(agent1);
expect(agents[1]).toEqual(agent2);
expect(agents[0]).toMatchObject(agent1);
expect(agents[1]).toMatchObject(agent2);
});
it("keeps agents in different conversations separate", () => {
@@ -65,8 +90,8 @@ describe("agents store", () => {
expect(agents1).toHaveLength(1);
expect(agents2).toHaveLength(1);
expect(agents1[0]).toEqual(agent1);
expect(agents2[0]).toEqual(agent2);
expect(agents1[0]).toMatchObject(agent1);
expect(agents2[0]).toMatchObject(agent2);
});
});
@@ -152,6 +177,32 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("running"); // Status unchanged
});
it("stores lastAssistantMessage when provided", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(
conversationId,
agent.toolUseId,
Date.now(),
false,
"Task completed successfully."
);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].lastAssistantMessage).toBe("Task completed successfully.");
});
it("leaves lastAssistantMessage undefined when not provided", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].lastAssistantMessage).toBeUndefined();
});
});
describe("markAllErrored", () => {
@@ -256,7 +307,7 @@ describe("agents store", () => {
expect(agents1).toHaveLength(0);
expect(agents2).toHaveLength(1);
expect(agents2[0]).toEqual(agent2);
expect(agents2[0]).toMatchObject(agent2);
});
it("does nothing if conversation doesn't exist", () => {
+16 -3
View File
@@ -1,5 +1,6 @@
import { writable, derived } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
import { assignCharacter } from "$lib/utils/agentCharacters";
// Map of conversation ID -> agents in that conversation
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
@@ -8,12 +9,17 @@ function createAgentStore() {
return {
subscribe: agentsByConversation.subscribe,
addAgent(conversationId: string, agent: AgentInfo) {
addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) {
agentsByConversation.update((state) => {
const existing = state[conversationId] || [];
const activeNames = existing.map((a) => a.characterName);
const character = assignCharacter(activeNames);
return {
...state,
[conversationId]: [...existing, agent],
[conversationId]: [
...existing,
{ ...agent, characterName: character.name, characterAvatar: character.avatar },
],
};
});
},
@@ -39,7 +45,13 @@ function createAgentStore() {
});
},
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
endAgent(
conversationId: string,
toolUseId: string,
endedAt: number,
isError: boolean,
lastAssistantMessage?: string
) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
@@ -56,6 +68,7 @@ function createAgentStore() {
endedAt,
status: isError ? "errored" : "completed",
durationMs,
lastAssistantMessage,
};
return {
+9
View File
@@ -60,6 +60,15 @@ export const claudeStore = {
isToolGranted: conversationsStore.isToolGranted,
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
// Sound tracking
resetSoundState: conversationsStore.resetSoundState,
setTaskStartTime: conversationsStore.setTaskStartTime,
markSuccessSoundFired: conversationsStore.markSuccessSoundFired,
markTaskStartSoundFired: conversationsStore.markTaskStartSoundFired,
// Draft text (per-tab input persistence)
setDraftText: conversationsStore.setDraftText,
// Conversation management
createConversation: conversationsStore.createConversation,
deleteConversation: conversationsStore.deleteConversation,
+15
View File
@@ -194,6 +194,11 @@ describe("config store", () => {
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
};
expect(config.model).toBe("claude-sonnet-4");
@@ -240,6 +245,11 @@ describe("config store", () => {
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
};
expect(config.model).toBeNull();
@@ -785,6 +795,11 @@ describe("config store", () => {
budget_warning_threshold: 0.9,
discord_rpc_enabled: false,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
};
const mockInvokeImpl = vi.mocked(invoke);
+14
View File
@@ -47,6 +47,15 @@ export interface HikariConfig {
discord_rpc_enabled: boolean;
// Thinking blocks settings
show_thinking_blocks: boolean;
// Worktree isolation
use_worktree: boolean;
// Disable 1M context window
disable_1m_context: boolean;
// Workspaces the user has explicitly trusted
trusted_workspaces: string[];
// Background image settings
background_image_path: string | null;
background_image_opacity: number;
}
const defaultConfig: HikariConfig = {
@@ -87,6 +96,11 @@ const defaultConfig: HikariConfig = {
budget_warning_threshold: 0.8,
discord_rpc_enabled: true,
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
};
function createConfigStore() {
+38
View File
@@ -523,3 +523,41 @@ describe("pending retry message", () => {
expect(pendingRetryMessage).toBeNull();
});
});
describe("draft text persistence", () => {
it("initialises draft text as empty string", () => {
const conversation = { draftText: "" };
expect(conversation.draftText).toBe("");
});
it("stores draft text per conversation", () => {
const conversations = new Map([
["conv-1", { draftText: "Hello world" }],
["conv-2", { draftText: "" }],
]);
expect(conversations.get("conv-1")?.draftText).toBe("Hello world");
expect(conversations.get("conv-2")?.draftText).toBe("");
});
it("updates draft text independently per conversation", () => {
const conversations = new Map([
["conv-1", { draftText: "Draft A" }],
["conv-2", { draftText: "Draft B" }],
]);
const convA = conversations.get("conv-1");
if (convA) convA.draftText = "Updated A";
expect(conversations.get("conv-1")?.draftText).toBe("Updated A");
expect(conversations.get("conv-2")?.draftText).toBe("Draft B");
});
it("clears draft text after submission", () => {
const conversation = { draftText: "My prompt" };
conversation.draftText = "";
expect(conversation.draftText).toBe("");
});
});
+71 -4
View File
@@ -37,6 +37,10 @@ export interface Conversation {
attachments: Attachment[];
summary: ConversationSummary | null;
startedAt: Date;
taskStartTime: number | null;
successSoundFired: boolean;
taskStartSoundFired: boolean;
draftText: string;
}
function createConversationsStore() {
@@ -75,6 +79,10 @@ function createConversationsStore() {
attachments: [],
summary: null,
startedAt: new Date(),
taskStartTime: null,
successSoundFired: false,
taskStartSoundFired: false,
draftText: "",
};
}
@@ -196,7 +204,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermissions.push(request);
conv.pendingPermissions = [...conv.pendingPermissions, request];
conv.lastActivityAt = new Date();
}
return convs;
@@ -219,7 +227,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermissions.push(request);
conv.pendingPermissions = [...conv.pendingPermissions, request];
conv.lastActivityAt = new Date();
}
return convs;
@@ -364,9 +372,15 @@ function createConversationsStore() {
if (currentId !== id) {
activeConversationId.set(id);
// Update the global character state to match the conversation's state
// Update the global character state to match the conversation's state.
// Map success/error → idle since those are transient states that have
// already been displayed — restoring them would re-trigger sound rules.
if (targetConv) {
characterState.setState(targetConv.characterState);
const stateToRestore =
targetConv.characterState === "success" || targetConv.characterState === "error"
? "idle"
: targetConv.characterState;
characterState.setState(stateToRestore);
}
}
},
@@ -816,6 +830,59 @@ function createConversationsStore() {
});
},
// Sound tracking methods
resetSoundState: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartTime = null;
conv.successSoundFired = false;
conv.taskStartSoundFired = false;
}
return convs;
});
},
setTaskStartTime: (conversationId: string, time: number | null) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartTime = time;
}
return convs;
});
},
markSuccessSoundFired: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.successSoundFired = true;
}
return convs;
});
},
markTaskStartSoundFired: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartSoundFired = true;
}
return convs;
});
},
setDraftText: (conversationId: string, text: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.draftText = text;
}
return convs;
});
},
// Add initialization helper
initialize: () => {
ensureInitialized();
+1
View File
@@ -12,6 +12,7 @@ export type BudgetType = "token" | "cost";
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
// Current generation (Claude 4.6)
"claude-opus-4-6": { input: 5.0, output: 25.0 },
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
// Previous generation (Claude 4.5)
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
+138 -9
View File
@@ -21,6 +21,7 @@ import {
handleConnectionStatusChange,
handleNewUserMessage,
} from "$lib/notifications/rules";
import { notificationManager } from "$lib/notifications/notificationManager";
interface StateChangePayload {
state: CharacterState;
@@ -29,6 +30,7 @@ interface StateChangePayload {
}
const connectedConversations = new Set<string>();
const greetingPendingConversations = new Set<string>();
let unlisteners: Array<() => void> = [];
let skipNextGreeting = false;
@@ -55,17 +57,17 @@ function generateGreetingPrompt(): string {
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
}
async function sendGreeting(conversationId: string) {
async function sendGreeting(conversationId: string): Promise<boolean> {
// Check if we should skip this greeting
if (skipNextGreeting) {
skipNextGreeting = false; // Reset the flag
return;
return false;
}
const config = configStore.getConfig();
if (!config.greeting_enabled) {
return;
return false;
}
const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt();
@@ -81,10 +83,12 @@ async function sendGreeting(conversationId: string) {
conversationId,
message: greetingPrompt,
});
return true;
} catch (error) {
console.error("Failed to send greeting:", error);
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
characterState.setTemporaryState("error", 3000);
return false;
}
}
@@ -118,6 +122,7 @@ interface WorkingDirectoryPayload {
export async function cleanupConversationTracking(conversationId: string) {
connectedConversations.delete(conversationId);
greetingPendingConversations.delete(conversationId);
// Clean up any temp files associated with this conversation
try {
@@ -173,7 +178,24 @@ export async function initializeTauriListeners() {
if (!connectedConversations.has(targetConversationId)) {
connectedConversations.add(targetConversationId);
resetSessionStats(); // Reset session stats on new connection
await sendGreeting(targetConversationId);
// Immediately hold the tab at yellow while we wait for the greeting response.
// This avoids a brief green flash before the greeting is even sent.
greetingPendingConversations.add(targetConversationId);
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"connecting" as ConnectionStatus
);
const greetingSent = await sendGreeting(targetConversationId);
if (!greetingSent) {
// Greeting was disabled or failed — flip straight to connected.
greetingPendingConversations.delete(targetConversationId);
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"connected" as ConnectionStatus
);
}
}
}
} else if (status === "disconnected") {
@@ -191,6 +213,7 @@ export async function initializeTauriListeners() {
// Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId);
greetingPendingConversations.delete(targetConversationId);
}
// Don't add system message if we're about to reconnect
@@ -198,13 +221,21 @@ export async function initializeTauriListeners() {
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Disconnected from Claude Code"
"Disconnected from Claude Code unexpectedly — the process may have crashed or been stopped by the system"
);
// Clear todos on real disconnect (not on reconnects for permissions)
todos.clear();
}
// Update the tab's connection status on real disconnects
if (!skipNextGreeting && targetConversationId) {
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"disconnected" as ConnectionStatus
);
}
// Update character state for this conversation
if (targetConversationId) {
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
@@ -214,6 +245,7 @@ export async function initializeTauriListeners() {
if (targetConversationId) {
connectedConversations.delete(targetConversationId);
greetingPendingConversations.delete(targetConversationId);
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
}
@@ -239,6 +271,63 @@ export async function initializeTauriListeners() {
const mappedState = stateMap[state.toLowerCase()] || "idle";
// Per-conversation sound tracking — fires for any tab (active or background).
// All sounds are driven from state-change events rather than a global store
// subscription, so background tabs receive their sounds correctly and
// switching tabs never replays a sound that has already fired.
const resolvedConversationId = conversation_id || get(claudeStore.activeConversationId) || null;
if (resolvedConversationId) {
const conv = get(claudeStore.conversations).get(resolvedConversationId);
if (conv) {
const previousState = conv.characterState;
// New response starting — clear all per-task sound flags.
if (mappedState === "thinking") {
claudeStore.resetSoundState(resolvedConversationId);
}
// Record when a long-running phase begins (used for the 2-second
// minimum duration check before playing the completion sound).
if (
(mappedState === "coding" || mappedState === "searching") &&
previousState !== "coding" &&
previousState !== "searching"
) {
claudeStore.setTaskStartTime(resolvedConversationId, Date.now());
}
// Task-start sound — fires once when work enters a long-running phase.
if (
(mappedState === "coding" || mappedState === "searching") &&
previousState !== "coding" &&
previousState !== "searching" &&
!conv.taskStartSoundFired
) {
notificationManager.notifyTaskStart();
claudeStore.markTaskStartSoundFired(resolvedConversationId);
}
// Error sound — fires each time a new error state is entered.
if (mappedState === "error" && previousState !== "error") {
notificationManager.notifyError();
}
// Permission sound — fires each time a permission request arrives.
if (mappedState === "permission") {
notificationManager.notifyPermission();
}
// Completion sound — fires once per task after sufficient duration.
if (mappedState === "success" && !conv.successSoundFired) {
const duration = conv.taskStartTime ? Date.now() - conv.taskStartTime : 0;
if (duration > 2000) {
notificationManager.notifySuccess();
}
claudeStore.markSuccessSoundFired(resolvedConversationId);
}
}
}
// Always update the conversation's state
if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
@@ -275,11 +364,34 @@ export async function initializeTauriListeners() {
}
: undefined;
// Flip to connected when first assistant message arrives after greeting
if (
conversation_id &&
line_type === "assistant" &&
greetingPendingConversations.has(conversation_id)
) {
greetingPendingConversations.delete(conversation_id);
claudeStore.setConnectionStatusForConversation(
conversation_id,
"connected" as ConnectionStatus
);
}
// Always store the output to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
line_type as
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change",
content,
tool_name || undefined,
costData,
@@ -288,7 +400,17 @@ export async function initializeTauriListeners() {
} else {
// Fallback to active conversation if no conversation_id provided
claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
line_type as
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change",
content,
tool_name || undefined,
costData,
@@ -410,10 +532,17 @@ export async function initializeTauriListeners() {
unlisteners.push(agentUpdateUnlisten);
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
const { tool_use_id, ended_at, is_error, conversation_id, last_assistant_message } =
event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
agentStore.endAgent(
targetConversationId,
tool_use_id,
ended_at,
is_error,
last_assistant_message
);
}
});
unlisteners.push(agentEndUnlisten);
+4
View File
@@ -10,6 +10,9 @@ export interface AgentInfo {
status: AgentStatus;
parentToolUseId?: string;
durationMs?: number;
characterName: string;
characterAvatar: string;
lastAssistantMessage?: string;
}
export interface AgentStartPayload {
@@ -29,4 +32,5 @@ export interface AgentEndPayload {
conversation_id?: string;
duration_ms?: number;
num_turns?: number;
last_assistant_message?: string;
}
+18 -1
View File
@@ -1,6 +1,16 @@
export interface TerminalLine {
id: string;
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
type:
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change";
content: string;
timestamp: Date;
toolName?: string;
@@ -164,6 +174,13 @@ export interface Attachment {
previewUrl?: string; // For images, a data URL or object URL for preview
}
export interface WorkspaceHookInfo {
has_concerns: boolean;
hook_types: string[];
mcp_servers: string[];
custom_commands: string[];
}
export interface UpdateInfo {
current_version: string;
latest_version: string;
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import { CHARACTER_POOL, assignCharacter } from "./agentCharacters";
describe("agentCharacters", () => {
describe("CHARACTER_POOL", () => {
it("contains exactly 6 characters", () => {
expect(CHARACTER_POOL).toHaveLength(6);
});
it("each character has a name, avatar, title, and description", () => {
for (const character of CHARACTER_POOL) {
expect(character.name).toBeTruthy();
expect(character.avatar).toBeTruthy();
expect(character.avatar).toMatch(/^https:\/\//u);
expect(character.title).toBeTruthy();
expect(character.description).toBeTruthy();
}
});
it("all names are unique", () => {
const names = CHARACTER_POOL.map((c) => c.name);
const uniqueNames = new Set(names);
expect(uniqueNames.size).toBe(CHARACTER_POOL.length);
});
});
describe("assignCharacter", () => {
it("returns a character from the pool", () => {
const character = assignCharacter([]);
const names = CHARACTER_POOL.map((c) => c.name);
expect(names).toContain(character.name);
});
it("avoids names already in use when possible", () => {
const takenNames = ["Amari", "Keiko", "Minori", "Reina", "Tatsumi"];
// Run many times to confirm we never get a taken name
for (let i = 0; i < 50; i++) {
const character = assignCharacter(takenNames);
expect(takenNames).not.toContain(character.name);
expect(character.name).toBe("Yumiko");
}
});
it("picks from the full pool when all 6 names are taken", () => {
const allNames = CHARACTER_POOL.map((c) => c.name);
const seen = new Set<string>();
// Run enough times that we'd statistically see variety
for (let i = 0; i < 100; i++) {
const character = assignCharacter(allNames);
seen.add(character.name);
}
// Should still pick valid characters
for (const name of seen) {
expect(allNames).toContain(name);
}
// With 100 runs and 6 characters, we should see at least 2 distinct names
expect(seen.size).toBeGreaterThan(1);
});
it("returns a character with name, avatar, title, and description", () => {
const character = assignCharacter([]);
expect(character.name).toBeTruthy();
expect(character.avatar).toBeTruthy();
expect(character.title).toBeTruthy();
expect(character.description).toBeTruthy();
});
it("works when the active list is empty", () => {
const character = assignCharacter([]);
expect(character).toBeDefined();
});
});
});
+61
View File
@@ -0,0 +1,61 @@
export interface AgentCharacter {
name: string;
avatar: string;
title: string;
description: string;
}
export const CHARACTER_POOL: readonly AgentCharacter[] = [
{
name: "Amari",
avatar: "https://cdn.nhcarrigan.com/amari.png",
title: "Executive Assistant",
description:
"Fey-blooded PA and healer of the team. She always knows when you need a break — and makes sure you take one.",
},
{
name: "Keiko",
avatar: "https://cdn.nhcarrigan.com/keiko.png",
title: "Chief Security Officer",
description:
"Bodyguard and shadow of the family. Conceals blades beneath evening gowns; always watching from the dark.",
},
{
name: "Minori",
avatar: "https://cdn.nhcarrigan.com/minori.png",
title: "Chief Compliance Officer",
description:
"An ancient Automaton built to guard the Great Library. Perfect memory, perfect logic, perfect dedication.",
},
{
name: "Reina",
avatar: "https://cdn.nhcarrigan.com/reina.png",
title: "Chief Legal Officer",
description:
"Demon of the Crossroads turned corporate lawyer. Her binding contracts have held for millennia.",
},
{
name: "Tatsumi",
avatar: "https://cdn.nhcarrigan.com/tatsumi.png",
title: "Chief Design Officer",
description:
"A Siren who traded the ocean for a stylus. Uses her glamour to make every interface welcoming and beautiful.",
},
{
name: "Yumiko",
avatar: "https://cdn.nhcarrigan.com/yumiko.png",
title: "Chief Technology Officer",
description:
"Technomancer and machine whisperer. She communes with machine spirits and keeps the digital world running.",
},
];
/**
* Picks a character for a new subagent.
* Avoids names already assigned to active agents unless all six are taken.
*/
export function assignCharacter(activeNames: readonly string[]): AgentCharacter {
const available = CHARACTER_POOL.filter((c) => !activeNames.includes(c.name));
const pool = available.length > 0 ? available : [...CHARACTER_POOL];
return pool[Math.floor(Math.random() * pool.length)];
}
+59 -4
View File
@@ -12,6 +12,7 @@
setSkipNextGreeting,
} from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import { readFile } from "@tauri-apps/plugin-fs";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
@@ -37,6 +38,45 @@
import { debugConsoleStore } from "$lib/stores/debugConsole";
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
let backgroundDataUrl = $state<string | null>(null);
let backgroundOpacity = $state(0.3);
const configValues = configStore.config;
$effect(() => {
const cfg = $configValues;
backgroundOpacity = cfg.background_image_opacity;
if (cfg.background_image_path) {
void loadBackgroundImage(cfg.background_image_path);
} else {
backgroundDataUrl = null;
}
});
async function loadBackgroundImage(path: string) {
try {
const data = await readFile(path);
const chunks: string[] = [];
const chunkSize = 8192;
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
}
const ext = path.split(".").pop()?.toLowerCase() ?? "png";
const mimeMap: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
avif: "image/avif",
};
const mime = mimeMap[ext] ?? "image/png";
backgroundDataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
} catch (error) {
console.error("Failed to load background image:", error);
backgroundDataUrl = null;
}
}
let initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false);
@@ -297,7 +337,7 @@
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted");
claudeStore.addLine("system", "Process interrupted by keyboard shortcut (Ctrl+C)");
} catch (error) {
console.error("Failed to interrupt:", error);
}
@@ -473,16 +513,27 @@
});
</script>
{#if backgroundDataUrl}
<div
class="fixed inset-0 bg-cover bg-center pointer-events-none"
style="background-image: url('{backgroundDataUrl}'); opacity: {backgroundOpacity}; z-index: 0;"
></div>
{/if}
{#if compactModeActive}
<!-- Compact mode: minimal widget interface -->
<div
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
style={backgroundDataUrl ? "background: transparent;" : ""}
>
<CompactMode onExpand={exitCompactMode} />
</div>
{:else}
<!-- Full mode: standard interface -->
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
<div
class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
style={backgroundDataUrl ? "background: transparent;" : ""}
>
<StatusBar
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
onToggleCompact={enterCompactMode}
@@ -491,8 +542,12 @@
<main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display -->
<div
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center {backgroundDataUrl
? ''
: 'bg-[var(--bg-secondary)]/50'}"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;{backgroundDataUrl
? ' background: transparent !important;'
: ''}"
>
<AnimeGirl />
</div>
+14
View File
@@ -8,6 +8,20 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
// Suppress specific build-time warnings that are intentional patterns:
// - a11y_click_events_have_key_events: all overlay/context-menu divs use svelte:window handlers
// - state_referenced_locally: InputDialog intentionally captures the initial prop value
onwarn: (warning, handler) => {
if (
warning.code === "a11y_click_events_have_key_events" ||
warning.code === "state_referenced_locally" ||
// SvelteSet is already reactive; $state wrapping is unnecessary per ESLint,
// but vite-plugin-svelte incorrectly fires non_reactive_update on SvelteSet mutations
warning.code === "non_reactive_update"
)
return;
handler(warning);
},
kit: {
adapter: adapter({
fallback: "index.html",
+15 -1
View File
@@ -1,13 +1,27 @@
import { defineConfig } from "vite";
import { defineConfig, createLogger } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// SvelteKit passes codeSplitting to Rollup 4 which no longer recognises it — harmless
const logger = createLogger();
const baseWarn = logger.warn.bind(logger);
logger.warn = (/** @type {string} */ msg, /** @type {any} */ options) => {
// SvelteKit passes codeSplitting to Rollup 4 which no longer recognises it
if (msg.includes("codeSplitting")) return;
// Large chunks are fine for a desktop app — no network penalty
if (msg.includes("chunks are larger than")) return;
// Dynamic/static import mix in CodeMirror — harmless, module stays in main chunk
if (msg.includes("dynamically imported by") && msg.includes("codemirror")) return;
baseWarn(msg, options);
};
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [tailwindcss(), sveltekit()],
customLogger: logger,
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//