Compare commits

..

10 Commits

Author SHA1 Message Date
hikari 5063a4f415 feat: display ConfigChange hook events in terminal
Detects [ConfigChange Hook] lines in stderr and emits them as
config-change line type with [config] prefix in purple.

Closes #151
2026-02-24 18:07:53 -08:00
hikari 54280b3920 feat: add worktree isolation support and hook event display
Closes #152, closes #150

- Add use_worktree bool to ClaudeStartOptions and HikariConfig (Rust + TS)
- Pass --worktree flag to claude in both WSL and non-WSL command paths
- Detect WorktreeCreate/WorktreeRemove hook events in stderr handler
- Emit worktree events with distinct line_type instead of error
- Add worktree line type to TerminalLine union, Terminal rendering, and tests
- Display worktree events in green with [worktree] prefix
- Add worktree isolation toggle to Agent Settings section of ConfigSidebar
- Thread use_worktree through all seven start_claude call sites
2026-02-24 17:39:09 -08:00
hikari 108a1b16b2 feat: detect prompt-too-long errors and offer compact action
Closes #158

- Detect "Prompt is too long" in assistant text blocks in wsl_bridge
- Emit error line type instead of assistant for better visibility
- Emit compact-prompt event after the error line
- Render a compact-prompt line as an action button in the terminal
- Clicking the button sends /compact to the active session
- Add getLineClass/getLinePrefix tests for compact-prompt type
2026-02-24 17:18:44 -08:00
hikari 1c02ca1bb5 feat: parse and display rate_limit_event messages from Claude CLI
Closes #155

- Add RateLimitInfo struct and RateLimitEvent variant to ClaudeMessage
- Emit rate-limit OutputEvent with human-readable message in wsl_bridge
- Add rate-limit line type to TerminalLine union and Terminal rendering
- Display rate-limit lines in amber with [rate-limit] prefix
- Add Terminal.test.ts with 28 tests for getLineClass, getLinePrefix,
  formatTime, isToolContentLong, and truncateToolContent
2026-02-24 16:28:33 -08:00
hikari ac965ebe92 feat: add auth status display and account management to settings sidebar
Implements issue #153. Adds Account section to ConfigSidebar with:
- Claude auth status (logged in/out, email, org, plan, API source)
- API key override indicator reading from local Hikari config
- Login/logout action buttons
- Refresh button for manual status updates

Adds Rust commands: get_auth_status, auth_login, auth_logout
2026-02-24 14:35:51 -08:00
hikari 5cdb4508aa feat: clean up CLI version display and add compatibility warnings
Strips the '(Claude Code)' suffix from the installed version badge so
it shows cleanly as 'CLI 2.1.50'. Also adds an italic warning message
after the supported badge when versions differ: amber text for newer
installs, red text for outdated installs.
2026-02-24 13:12:37 -08:00
hikari b914240d20 fix: extract semver before comparing CLI versions
The installed version string includes a suffix like '(Claude Code)'
which caused Number() to parse the third segment as NaN, making the
comparison always return 0 (current) instead of the correct result.
Now extracts the semver portion with a regex before comparing.
2026-02-24 12:57:39 -08:00
hikari 302963f6c0 feat: add supported CLI version badge to header
Adds a second badge alongside the existing CLI version display showing
the highest audited version (currently 2.1.33). The badge is
colour-coded: green when installed matches supported, amber when ahead,
red when behind, and neutral grey whilst loading.
2026-02-24 12:47:41 -08:00
hikari 986dd54dd9 feat: hold tab at connecting until greeting response arrives
Track greeting-pending conversations so the tab stays yellow until
Claude actually responds, rather than going green as soon as the
process starts. Also fixes the disconnect handler missing a status
update that left tabs stuck at yellow after the process died.
2026-02-24 12:31:24 -08:00
hikari 24313facca feat: collapsible tool lines in terminal
Long tool messages (e.g. Bash commands) are now stored in full and
displayed collapsed by default, with a toggle button to expand/collapse.
Removes backend truncation of Bash commands at 50 chars.
2026-02-24 10:44:22 -08:00
45 changed files with 1182 additions and 2407 deletions
+63 -63
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.8.0",
"version": "1.6.0",
"description": "",
"type": "module",
"scripts": {
@@ -27,69 +27,69 @@
},
"license": "MIT",
"dependencies": {
"@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"
"@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.563.0",
"marked": "^17.0.1"
},
"devDependencies": {
"@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"
"@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"
}
}
+682 -695
View File
File diff suppressed because it is too large Load Diff
+297 -492
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.8.0"
version = "1.6.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
+2 -152
View File
@@ -7,7 +7,6 @@ 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;
@@ -60,7 +59,6 @@ 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();
@@ -68,7 +66,6 @@ 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
}
@@ -76,7 +73,6 @@ 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
}
@@ -89,23 +85,18 @@ 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();
let mut cmd = std::process::Command::new(claude_path);
cmd.hide_window();
cmd
std::process::Command::new(claude_path)
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
let mut cmd = std::process::Command::new("claude");
cmd.hide_window();
cmd
std::process::Command::new("claude")
}
}
}
@@ -343,121 +334,6 @@ 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
@@ -505,7 +381,6 @@ 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",
@@ -805,7 +680,6 @@ 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))?;
@@ -868,7 +742,6 @@ 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))?;
@@ -900,7 +773,6 @@ 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()
@@ -949,7 +821,6 @@ 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))?;
@@ -959,7 +830,6 @@ 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))?;
@@ -1000,7 +870,6 @@ 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))?;
@@ -1010,7 +879,6 @@ 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))?;
@@ -1055,7 +923,6 @@ 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))?;
@@ -1066,7 +933,6 @@ 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))?;
@@ -1076,7 +942,6 @@ 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))?;
@@ -1121,7 +986,6 @@ 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))?;
@@ -1132,7 +996,6 @@ 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))?;
@@ -1142,7 +1005,6 @@ 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))?;
@@ -1188,7 +1050,6 @@ 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))?;
@@ -1199,7 +1060,6 @@ 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))?;
@@ -1209,7 +1069,6 @@ 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))?;
@@ -1387,7 +1246,6 @@ 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))?;
@@ -1508,7 +1366,6 @@ pub async fn get_claude_version() -> Result<String, String> {
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>,
@@ -1539,11 +1396,6 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
.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())
@@ -1568,7 +1420,6 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
Ok(ClaudeAuthStatus {
is_logged_in,
email,
org_id,
org_name,
api_key_source,
api_provider,
@@ -1585,7 +1436,6 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
Ok(ClaudeAuthStatus {
is_logged_in,
email: None,
org_id: None,
org_name: None,
api_key_source: None,
api_provider: None,
-31
View File
@@ -28,9 +28,6 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub use_worktree: bool,
#[serde(default)]
pub disable_1m_context: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -122,19 +119,6 @@ pub struct HikariConfig {
#[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 {
@@ -168,10 +152,6 @@ impl Default for HikariConfig {
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,
}
}
}
@@ -208,10 +188,6 @@ 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 {
@@ -283,9 +259,6 @@ 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]
@@ -319,10 +292,6 @@ mod tests {
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,8 +1,6 @@
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,
@@ -39,7 +37,6 @@ 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()
-2
View File
@@ -8,7 +8,6 @@ mod debug_logger;
mod discord_rpc;
mod git;
mod notifications;
mod process_ext;
mod quick_actions;
mod sessions;
mod snippets;
@@ -121,7 +120,6 @@ pub fn run() {
get_persisted_stats,
load_saved_achievements,
answer_question,
check_workspace_hooks,
send_windows_notification,
send_simple_notification,
send_windows_toast,
-6
View File
@@ -1,8 +1,6 @@
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!(
@@ -84,7 +82,6 @@ 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")
@@ -112,7 +109,6 @@ 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")
@@ -121,7 +117,6 @@ 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")
@@ -145,7 +140,6 @@ 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
@@ -1,21 +0,0 @@
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
}
}
-2
View File
@@ -305,8 +305,6 @@ 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)]
+1 -4
View File
@@ -3,8 +3,6 @@ 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
@@ -42,7 +40,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").hide_window().arg("-w").arg(&temp_path).output();
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
if let Ok(result) = output {
if result.status.success() {
@@ -59,7 +57,6 @@ 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()
+39 -320
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::{Duration, SystemTime, UNIX_EPOCH};
use parking_lot::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
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,
@@ -89,7 +89,7 @@ fn find_claude_binary() -> Option<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 let Ok(output) = Command::new("bash").args(["-lc", "which claude"]).output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
@@ -102,60 +102,54 @@ fn find_claude_binary() -> Option<String> {
}
pub struct WslBridge {
process: Arc<Mutex<Option<Child>>>,
process: 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>,
}
impl WslBridge {
pub fn new() -> Self {
WslBridge {
process: Arc::new(Mutex::new(None)),
process: 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)),
}
}
pub fn new_with_conversation_id(conversation_id: String) -> Self {
WslBridge {
process: Arc::new(Mutex::new(None)),
process: 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)),
}
}
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), 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());
if let Some(ref mut process) = self.process {
if process.try_wait().map(|s| s.is_some()).unwrap_or(false) {
self.process = None;
self.stdin = None;
}
}
if self.process.is_some() {
return Err("Process already running".to_string());
}
// Load saved achievements and stats when starting a new session
let app_clone = app.clone();
let stats = self.stats.clone();
@@ -230,7 +224,6 @@ impl WslBridge {
tracing::debug!("Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path);
cmd.hide_window();
cmd.args([
"--output-format",
"stream-json",
@@ -286,11 +279,6 @@ 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
@@ -298,7 +286,6 @@ impl WslBridge {
// 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 {
@@ -309,7 +296,6 @@ impl WslBridge {
// 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 {
@@ -333,11 +319,6 @@ 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",
);
@@ -384,7 +365,8 @@ impl WslBridge {
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
// Hide the console window on Windows
cmd.hide_window();
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
cmd
};
@@ -404,10 +386,7 @@ impl WslBridge {
let stderr = child.stderr.take();
self.stdin = stdin;
*self.process.lock() = Some(child);
// Reset the init flag so the watchdog and stdout handler start fresh.
self.received_init.store(false, Ordering::SeqCst);
self.process = Some(child);
// Note: We no longer reset stats here - stats persist across reconnects
// Stats are only reset when explicitly disconnecting via stop()
@@ -424,9 +403,8 @@ 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();
thread::spawn(move || {
handle_stdout(stdout, app_clone, stats_clone, conv_id, received_init_clone);
handle_stdout(stdout, app_clone, stats_clone, conv_id);
});
}
@@ -438,31 +416,12 @@ 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(())
}
@@ -541,10 +500,7 @@ 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
// Extract the process first so the MutexGuard is dropped before we mutably
// borrow `self` again via estimate_interrupted_request_cost.
let maybe_process = self.process.lock().take();
if let Some(mut process) = maybe_process {
if let Some(mut process) = self.process.take() {
// Estimate cost for interrupted request before killing
self.estimate_interrupted_request_cost(app);
@@ -674,7 +630,7 @@ impl WslBridge {
}
pub fn stop(&mut self, app: &AppHandle) {
if let Some(mut process) = self.process.lock().take() {
if let Some(mut process) = self.process.take() {
let _ = process.kill();
let _ = process.wait();
}
@@ -705,7 +661,7 @@ impl WslBridge {
}
pub fn is_running(&self) -> bool {
self.process.lock().is_some()
self.process.is_some()
}
pub fn get_working_directory(&self) -> &str {
@@ -728,16 +684,13 @@ fn handle_stdout(
app: AppHandle,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
received_init: 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, &received_init)
{
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
tracing::error!("Error processing line: {}", e);
}
}
@@ -749,22 +702,6 @@ fn handle_stdout(
}
}
// 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,
},
);
}
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
}
@@ -818,7 +755,6 @@ fn handle_stderr(
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
last_assistant_message: stop_data.last_assistant_message,
},
);
}
@@ -892,84 +828,32 @@ 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"), last_assistant_message=Some("..."), ...
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ...
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");
// 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
};
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))?;
@@ -982,9 +866,6 @@ 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",
@@ -1269,8 +1150,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
@@ -1288,7 +1169,6 @@ fn process_json_line(
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
last_assistant_message: extract_tool_result_text(content),
},
);
}
@@ -1706,8 +1586,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()
@@ -1724,7 +1604,6 @@ fn process_json_line(
conversation_id: conversation_id.clone(),
duration_ms: None,
num_turns: None,
last_assistant_message: extract_tool_result_text(content),
},
);
}
@@ -2116,7 +1995,7 @@ mod tests {
#[test]
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'");
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
// Wait for it to exit
let _ = child.wait();
@@ -2135,7 +2014,7 @@ mod tests {
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 mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
// Let it exit
let _ = child.wait();
@@ -2276,165 +2155,5 @@ 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,8 +1,6 @@
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
@@ -38,7 +36,6 @@ $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")
@@ -68,7 +65,6 @@ $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.8.0",
"version": "1.6.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
-2
View File
@@ -62,7 +62,6 @@ async function changeDirectory(path: string): Promise<void> {
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,
},
});
@@ -138,7 +137,6 @@ async function startNewConversation(): Promise<void> {
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,
},
});
@@ -318,16 +318,6 @@
<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}
+1 -1
View File
@@ -2,7 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte";
const SUPPORTED_CLI_VERSION = "2.1.53";
const SUPPORTED_CLI_VERSION = "2.1.33";
let installedVersion = $state("Loading...");
+2 -8
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, configStore } from "$lib/stores/config";
import { isStreamerMode } from "$lib/stores/config";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
@@ -14,9 +14,6 @@
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);
@@ -153,10 +150,7 @@
}
</script>
<div
class="compact-container {getStateGlow()}"
style={hasBackgroundImage ? "background: transparent !important;" : ""}
>
<div class="compact-container {getStateGlow()}">
<!-- Character sprite (smaller) -->
<div class="compact-character">
<div class="sprite-wrapper {getAnimationClass()}">
-90
View File
@@ -13,7 +13,6 @@
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({
@@ -55,10 +54,6 @@
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);
@@ -66,7 +61,6 @@
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;
@@ -245,20 +239,6 @@
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 -->
@@ -338,14 +318,6 @@
<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>
@@ -517,22 +489,6 @@
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 -->
@@ -932,52 +888,6 @@
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 -->
+13 -117
View File
@@ -12,25 +12,6 @@
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>();
@@ -157,73 +138,8 @@
}
}
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") {
@@ -249,19 +165,21 @@
// Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabOrder.length;
claudeStore.switchConversation(tabOrder[nextIndex]);
const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabs[nextIndex]);
}
}
// Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault();
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
claudeStore.switchConversation(tabOrder[prevIndex]);
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabs[prevIndex]);
}
}
}
@@ -272,22 +190,15 @@
</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 tabOrder
.filter((id) => $conversations.has(id))
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
<div
data-tab-id={id}
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer 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'}
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
{draggedId === id ? 'dragging' : ''}"
onpointerdown={(e) => handlePointerDown(e, id)}
onclick={() => handleTabClick(id)}
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
onclick={() => switchTab(id)}
onkeydown={(e) => handleTabKeydown(id, e)}
role="tab"
tabindex={0}
@@ -300,7 +211,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 select-text"
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
/>
{:else}
<div class="flex items-center gap-2">
@@ -385,20 +296,5 @@
.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>
-1
View File
@@ -363,7 +363,6 @@ User: ${formattedMessage}`;
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,
},
});
+1 -6
View File
@@ -35,12 +35,7 @@
};
renderer.codespan = ({ text }) => {
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;");
return `<code class="hljs-inline">${text}</code>`;
};
marked.setOptions({
+44 -27
View File
@@ -190,13 +190,10 @@
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3">
<div>
<label
for="mcp-new-name"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
<label 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"
@@ -204,13 +201,10 @@
/>
</div>
<div>
<label
for="mcp-new-transport"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
<label 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)]"
>
@@ -220,14 +214,10 @@
</select>
</div>
<div>
<label
for="mcp-new-url"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>
<label 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"
@@ -276,7 +266,6 @@
{: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"
@@ -285,7 +274,10 @@
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
<TransportIcon class="w-4 h-4 {getTransportColor(server.transport)}" />
<svelte:component
this={getTransportIcon(server.transport)}
class="w-4 h-4 {getTransportColor(server.transport)}"
/>
{server.name}
{#if server.status}
{#if server.status.includes("Connected")}
@@ -331,19 +323,25 @@
<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>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Name</label
>
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
</div>
<!-- Transport -->
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Transport</label
>
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
<TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
<svelte:component
this={getTransportIcon(selectedServer.transport)}
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
/>
{selectedServer.transport.toUpperCase()}
</p>
</div>
@@ -351,7 +349,9 @@
<!-- URL or Command -->
{#if selectedServer.url}
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</p>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>URL</label
>
<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,7 +362,9 @@
{#if selectedServer.command}
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</p>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Command</label
>
<p
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
>
@@ -374,9 +376,9 @@
<!-- Environment Variables -->
{#if selectedServer.env}
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
Environment
</p>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Environment</label
>
<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,
@@ -389,9 +391,9 @@
<!-- Full Server Details -->
{#if serverDetails}
<div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
Full Details
</p>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Full Details</label
>
<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>
@@ -414,3 +416,18 @@
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
@@ -88,7 +88,6 @@
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,3 +430,18 @@
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
@@ -471,7 +471,6 @@
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()}
+5 -65
View File
@@ -38,8 +38,6 @@
} 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";
@@ -63,8 +61,6 @@
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({
@@ -106,10 +102,6 @@
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);
@@ -163,7 +155,11 @@
}
}
async function doConnect(targetDir: string) {
async function handleConnect() {
if (isConnecting || connectionStatus === "connected") return;
const targetDir = selectedDirectory || "/home/naomi";
// Combine session-granted tools with config auto-granted tools
const allAllowedTools = [
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
@@ -184,7 +180,6 @@
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,
},
});
@@ -203,52 +198,6 @@
}
}
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);
@@ -343,7 +292,6 @@
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,
},
});
@@ -820,14 +768,6 @@
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if}
{#if showWorkspaceTrust && pendingHookInfo}
<WorkspaceTrustModal
hookInfo={pendingHookInfo}
onTrust={handleTrustAndConnect}
onCancel={handleCancelConnect}
/>
{/if}
<style>
/* Responsive status bar styling */
.status-bar {
@@ -73,7 +73,7 @@
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={menuElement}
class="menu-content"
@@ -107,7 +107,6 @@
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,
},
});
@@ -1,110 +0,0 @@
<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 a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-overlay" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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 a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={menuElement}
class="menu-content"
@@ -78,7 +78,7 @@
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="menu-overlay"
onclick={onClose}
@@ -87,7 +87,7 @@
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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 a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-overlay" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
<h2 class="dialog-title">{title}</h2>
-26
View File
@@ -177,32 +177,6 @@ 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", () => {
+1 -8
View File
@@ -45,13 +45,7 @@ function createAgentStore() {
});
},
endAgent(
conversationId: string,
toolUseId: string,
endedAt: number,
isError: boolean,
lastAssistantMessage?: string
) {
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
agentsByConversation.update((state) => {
const agents = state[conversationId];
if (!agents) return state;
@@ -68,7 +62,6 @@ function createAgentStore() {
endedAt,
status: isError ? "errored" : "completed",
durationMs,
lastAssistantMessage,
};
return {
-12
View File
@@ -195,10 +195,6 @@ describe("config store", () => {
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");
@@ -246,10 +242,6 @@ describe("config store", () => {
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();
@@ -796,10 +788,6 @@ describe("config store", () => {
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);
-11
View File
@@ -49,13 +49,6 @@ export interface HikariConfig {
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 = {
@@ -97,10 +90,6 @@ const defaultConfig: HikariConfig = {
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() {
+2 -9
View File
@@ -474,17 +474,10 @@ 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, last_assistant_message } =
event.payload;
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
agentStore.endAgent(
targetConversationId,
tool_use_id,
ended_at,
is_error,
last_assistant_message
);
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
}
});
unlisteners.push(agentEndUnlisten);
-2
View File
@@ -12,7 +12,6 @@ export interface AgentInfo {
durationMs?: number;
characterName: string;
characterAvatar: string;
lastAssistantMessage?: string;
}
export interface AgentStartPayload {
@@ -32,5 +31,4 @@ export interface AgentEndPayload {
conversation_id?: string;
duration_ms?: number;
num_turns?: number;
last_assistant_message?: string;
}
-7
View File
@@ -174,13 +174,6 @@ 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;
+3 -58
View File
@@ -12,7 +12,6 @@
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";
@@ -38,45 +37,6 @@
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);
@@ -513,27 +473,16 @@
});
</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"
style={backgroundDataUrl ? "background: transparent;" : ""}
>
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
<StatusBar
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
onToggleCompact={enterCompactMode}
@@ -542,12 +491,8 @@
<main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display -->
<div
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;'
: ''}"
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;"
>
<AnimeGirl />
</div>
-14
View File
@@ -8,20 +8,6 @@ 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",
+1 -15
View File
@@ -1,27 +1,13 @@
import { defineConfig, createLogger } from "vite";
import { defineConfig } 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`
//