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
49 changed files with 1272 additions and 2670 deletions
+63 -63
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "1.8.1", "version": "1.6.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -27,69 +27,69 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.10.2", "@codemirror/commands": "6.8.1",
"@codemirror/lang-angular": "0.1.4", "@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "6.0.3", "@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "6.0.1", "@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "6.4.11", "@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "6.0.2", "@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "6.2.4", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "6.0.2", "@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-markdown": "6.5.0", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "6.0.2", "@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "6.0.2", "@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "6.0.2", "@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "6.10.0", "@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "0.1.3", "@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "6.0.2", "@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-xml": "6.1.0", "@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "6.12.2", "@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "6.5.2", "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "6.5.4", "@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "6.39.15", "@codemirror/view": "^6.39.11",
"@lezer/highlight": "1.2.3", "@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "2.10.1", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "2.3.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "2.4.5", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "2.3.3", "@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "2.5.3", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "2.3.2", "@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "2.3.5", "@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "2.4.2", "@tauri-apps/plugin-store": "^2",
"codemirror": "6.0.2", "codemirror": "^6.0.2",
"highlight.js": "11.11.1", "highlight.js": "^11.11.1",
"lucide-svelte": "0.575.0", "lucide-svelte": "^0.563.0",
"marked": "17.0.3" "marked": "^17.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.39.3", "@eslint/js": "^9.39.2",
"@sveltejs/adapter-static": "3.0.10", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "2.53.2", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "5.1.1", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "4.2.1", "@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "2.10.0", "@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "5.3.1", "@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "4.0.18", "@vitest/coverage-v8": "^4.0.18",
"eslint": "9.39.3", "eslint": "^9.39.2",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "3.15.0", "eslint-plugin-svelte": "^3.14.0",
"globals": "17.3.0", "globals": "^17.0.0",
"jsdom": "28.1.0", "jsdom": "^27.4.0",
"prettier": "3.8.1", "prettier": "^3.8.0",
"prettier-plugin-svelte": "3.5.0", "prettier-plugin-svelte": "^3.4.1",
"svelte": "5.53.5", "svelte": "^5.0.0",
"svelte-check": "4.4.3", "svelte-check": "^4.0.0",
"tailwindcss": "4.2.1", "tailwindcss": "^4.1.18",
"typescript": "5.9.3", "typescript": "~5.6.2",
"typescript-eslint": "8.56.1", "typescript-eslint": "^8.53.0",
"vite": "6.4.1", "vite": "^6.0.3",
"vitest": "4.0.18" "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] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.8.1" version = "1.6.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" 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::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
use crate::bridge_manager::SharedBridgeManager; use crate::bridge_manager::SharedBridgeManager;
use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::process_ext::HideWindow;
use crate::stats::UsageStats; use crate::stats::UsageStats;
use crate::temp_manager::SharedTempFileManager; 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, // 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 // so we need to use a login shell to get the correct PATH
let which_output = std::process::Command::new("wsl") let which_output = std::process::Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-l", "-c", "which claude"]) .args(["-e", "bash", "-l", "-c", "which claude"])
.output(); .output();
@@ -68,7 +66,6 @@ fn create_claude_command() -> std::process::Command {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut cmd = std::process::Command::new("wsl"); let mut cmd = std::process::Command::new("wsl");
cmd.hide_window();
cmd.arg(claude_path); cmd.arg(claude_path);
cmd cmd
} }
@@ -76,7 +73,6 @@ fn create_claude_command() -> std::process::Command {
// Fallback to just "claude" if which fails // Fallback to just "claude" if which fails
// This maintains backwards compatibility // This maintains backwards compatibility
let mut cmd = std::process::Command::new("wsl"); let mut cmd = std::process::Command::new("wsl");
cmd.hide_window();
cmd.arg("claude"); cmd.arg("claude");
cmd cmd
} }
@@ -89,23 +85,18 @@ fn create_claude_command() -> std::process::Command {
// This works regardless of how Claude Code was installed (standalone, npm, etc.) // This works regardless of how Claude Code was installed (standalone, npm, etc.)
// and avoids hardcoding paths // and avoids hardcoding paths
let which_output = std::process::Command::new("which") let which_output = std::process::Command::new("which")
.hide_window()
.arg("claude") .arg("claude")
.output(); .output();
match which_output { match which_output {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut cmd = std::process::Command::new(claude_path); std::process::Command::new(claude_path)
cmd.hide_window();
cmd
} }
_ => { _ => {
// Fallback to just "claude" if which fails // Fallback to just "claude" if which fails
// This maintains backwards compatibility // This maintains backwards compatibility
let mut cmd = std::process::Command::new("claude"); std::process::Command::new("claude")
cmd.hide_window();
cmd
} }
} }
} }
@@ -343,121 +334,6 @@ pub async fn answer_question(
manager.send_tool_result(&conversation_id, &tool_use_id, answers) 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] #[tauri::command]
pub async fn list_skills() -> Result<Vec<String>, String> { pub async fn list_skills() -> Result<Vec<String>, String> {
// On Windows, we need to use WSL to access the skills directory // 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 // Use WSL to list directories in ~/.claude/skills that contain SKILL.md
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args([ .args([
"-e", "-e",
"sh", "sh",
@@ -805,7 +680,6 @@ async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
); );
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &script]) .args(["-e", "sh", "-c", &script])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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; use std::process::Command;
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args(["-e", "cat", path]) .args(["-e", "cat", path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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}; use std::process::{Command, Stdio};
let mut child = Command::new("wsl") let mut child = Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &format!("cat > '{}'", path)]) .args(["-e", "sh", "-c", &format!("cat > '{}'", path)])
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.spawn() .spawn()
@@ -949,7 +821,6 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
// Check if file exists first // Check if file exists first
let check = Command::new("wsl") let check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path]) .args(["-e", "test", "-e", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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") let output = Command::new("wsl")
.hide_window()
.args(["-e", "touch", path]) .args(["-e", "touch", path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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 // Check if directory exists first
let check = Command::new("wsl") let check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path]) .args(["-e", "test", "-e", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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") let output = Command::new("wsl")
.hide_window()
.args(["-e", "mkdir", "-p", path]) .args(["-e", "mkdir", "-p", path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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 // Check if path exists
let check_exists = Command::new("wsl") let check_exists = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path]) .args(["-e", "test", "-e", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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 // Check if path is a directory
let check_dir = Command::new("wsl") let check_dir = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", path]) .args(["-e", "test", "-d", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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") let output = Command::new("wsl")
.hide_window()
.args(["-e", "rm", path]) .args(["-e", "rm", path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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 // Check if path exists
let check_exists = Command::new("wsl") let check_exists = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path]) .args(["-e", "test", "-e", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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 // Check if path is a directory
let check_dir = Command::new("wsl") let check_dir = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", path]) .args(["-e", "test", "-d", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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") let output = Command::new("wsl")
.hide_window()
.args(["-e", "rm", "-rf", path]) .args(["-e", "rm", "-rf", path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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 // Check if old path exists
let check_old = Command::new("wsl") let check_old = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", old_path]) .args(["-e", "test", "-e", old_path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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 // Check if new path already exists
let check_new = Command::new("wsl") let check_new = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", new_path]) .args(["-e", "test", "-e", new_path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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") let output = Command::new("wsl")
.hide_window()
.args(["-e", "mv", old_path, new_path]) .args(["-e", "mv", old_path, new_path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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") let output = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-l", "-c", script]) .args(["-e", "bash", "-l", "-c", script])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .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 struct ClaudeAuthStatus {
pub is_logged_in: bool, pub is_logged_in: bool,
pub email: Option<String>, pub email: Option<String>,
pub org_id: Option<String>,
pub org_name: Option<String>, pub org_name: Option<String>,
pub api_key_source: Option<String>, pub api_key_source: Option<String>,
pub api_provider: 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()) .and_then(|v| v.as_str())
.map(String::from); .map(String::from);
let org_id = json
.get("orgId")
.and_then(|v| v.as_str())
.map(String::from);
let org_name = json let org_name = json
.get("orgName") .get("orgName")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -1568,7 +1420,6 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
Ok(ClaudeAuthStatus { Ok(ClaudeAuthStatus {
is_logged_in, is_logged_in,
email, email,
org_id,
org_name, org_name,
api_key_source, api_key_source,
api_provider, api_provider,
@@ -1585,7 +1436,6 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
Ok(ClaudeAuthStatus { Ok(ClaudeAuthStatus {
is_logged_in, is_logged_in,
email: None, email: None,
org_id: None,
org_name: None, org_name: None,
api_key_source: None, api_key_source: None,
api_provider: None, api_provider: None,
-31
View File
@@ -28,9 +28,6 @@ pub struct ClaudeStartOptions {
#[serde(default)] #[serde(default)]
pub use_worktree: bool, pub use_worktree: bool,
#[serde(default)]
pub disable_1m_context: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -122,19 +119,6 @@ pub struct HikariConfig {
#[serde(default)] #[serde(default)]
pub use_worktree: bool, 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 { impl Default for HikariConfig {
@@ -168,10 +152,6 @@ impl Default for HikariConfig {
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
use_worktree: false, 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 true
} }
fn default_background_image_opacity() -> f32 {
0.3
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum BudgetAction { pub enum BudgetAction {
@@ -283,9 +259,6 @@ mod tests {
assert_eq!(config.budget_action, BudgetAction::Warn); assert_eq!(config.budget_action, BudgetAction::Warn);
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON); assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
assert!(config.discord_rpc_enabled); assert!(config.discord_rpc_enabled);
assert!(!config.use_worktree);
assert!(!config.disable_1m_context);
assert!(config.trusted_workspaces.is_empty());
} }
#[test] #[test]
@@ -319,10 +292,6 @@ mod tests {
budget_warning_threshold: 0.75, budget_warning_threshold: 0.75,
discord_rpc_enabled: true, discord_rpc_enabled: true,
use_worktree: 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(); let json = serde_json::to_string(&config).unwrap();
-3
View File
@@ -1,8 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::process::Command; use std::process::Command;
use crate::process_ext::HideWindow;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitStatus { pub struct GitStatus {
pub is_repo: bool, pub is_repo: bool,
@@ -39,7 +37,6 @@ pub struct GitLogEntry {
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> { fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
let output = Command::new("git") let output = Command::new("git")
.hide_window()
.args(args) .args(args)
.current_dir(working_dir) .current_dir(working_dir)
.output() .output()
-2
View File
@@ -8,7 +8,6 @@ mod debug_logger;
mod discord_rpc; mod discord_rpc;
mod git; mod git;
mod notifications; mod notifications;
mod process_ext;
mod quick_actions; mod quick_actions;
mod sessions; mod sessions;
mod snippets; mod snippets;
@@ -121,7 +120,6 @@ pub fn run() {
get_persisted_stats, get_persisted_stats,
load_saved_achievements, load_saved_achievements,
answer_question, answer_question,
check_workspace_hooks,
send_windows_notification, send_windows_notification,
send_simple_notification, send_simple_notification,
send_windows_toast, send_windows_toast,
-6
View File
@@ -1,8 +1,6 @@
use std::process::Command; use std::process::Command;
use tauri::command; use tauri::command;
use crate::process_ext::HideWindow;
/// Generate PowerShell script for Windows Toast Notification /// Generate PowerShell script for Windows Toast Notification
fn generate_powershell_toast_script(title: &str, body: &str) -> String { fn generate_powershell_toast_script(title: &str, body: &str) -> String {
format!( 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> { pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
// Use notify-send for Linux/WSL // Use notify-send for Linux/WSL
let output = Command::new("notify-send") let output = Command::new("notify-send")
.hide_window()
.arg(&title) .arg(&title)
.arg(&body) .arg(&body)
.arg("--urgency=normal") .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 // Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
let output = Command::new("pwsh.exe") let output = Command::new("pwsh.exe")
.hide_window()
.arg("-NoProfile") .arg("-NoProfile")
.arg("-WindowStyle") .arg("-WindowStyle")
.arg("Hidden") .arg("Hidden")
@@ -121,7 +117,6 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
.output() .output()
.or_else(|_| { .or_else(|_| {
Command::new("powershell.exe") Command::new("powershell.exe")
.hide_window()
.arg("-NoProfile") .arg("-NoProfile")
.arg("-WindowStyle") .arg("-WindowStyle")
.arg("Hidden") .arg("Hidden")
@@ -145,7 +140,6 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
let message = format_simple_notification(&title, &body); let message = format_simple_notification(&title, &body);
Command::new("cmd.exe") Command::new("cmd.exe")
.hide_window()
.arg("/c") .arg("/c")
.arg("msg") .arg("msg")
.arg("*") .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>, pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub num_turns: Option<u32>, pub num_turns: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_assistant_message: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
+1 -4
View File
@@ -3,8 +3,6 @@ use std::process::Command;
use tauri::command; use tauri::command;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use crate::process_ext::HideWindow;
#[command] #[command]
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> { pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
// Create a VBScript that shows a Windows notification // 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/") { } else if temp_path.starts_with("/tmp/") {
// WSL temp files might be in a different location // WSL temp files might be in a different location
// Try to use wslpath to convert // 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 let Ok(result) = output {
if result.status.success() { if result.status.success() {
@@ -59,7 +57,6 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
// Execute the VBScript using wscript.exe // Execute the VBScript using wscript.exe
let output = Command::new("/mnt/c/Windows/System32/wscript.exe") let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
.hide_window()
.arg("//NoLogo") .arg("//NoLogo")
.arg(&windows_path) .arg(&windows_path)
.output() .output()
+39 -366
View File
@@ -1,17 +1,17 @@
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio}; use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use parking_lot::Mutex;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::commands::record_cost; use crate::commands::record_cost;
use crate::config::ClaudeStartOptions; use crate::config::ClaudeStartOptions;
use crate::process_ext::HideWindow;
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{ use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, 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 // 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 // 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() { if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() { if !path.is_empty() {
@@ -102,65 +102,54 @@ fn find_claude_binary() -> Option<String> {
} }
pub struct WslBridge { pub struct WslBridge {
process: Arc<Mutex<Option<Child>>>, process: Option<Child>,
stdin: Option<ChildStdin>, stdin: Option<ChildStdin>,
working_directory: String, working_directory: String,
session_id: Option<String>, session_id: Option<String>,
mcp_config_file: Option<NamedTempFile>, mcp_config_file: Option<NamedTempFile>,
stats: Arc<RwLock<UsageStats>>, stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>, 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 { impl WslBridge {
pub fn new() -> Self { pub fn new() -> Self {
WslBridge { WslBridge {
process: Arc::new(Mutex::new(None)), process: None,
stdin: None, stdin: None,
working_directory: String::new(), working_directory: String::new(),
session_id: None, session_id: None,
mcp_config_file: None, mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())), stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: None, 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 { pub fn new_with_conversation_id(conversation_id: String) -> Self {
WslBridge { WslBridge {
process: Arc::new(Mutex::new(None)), process: None,
stdin: None, stdin: None,
working_directory: String::new(), working_directory: String::new(),
session_id: None, session_id: None,
mcp_config_file: None, mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())), stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: Some(conversation_id), 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> { 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 // 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. // failed working directory), clean up the stale handle so we can restart cleanly.
{ if let Some(ref mut process) = self.process {
let mut proc_guard = self.process.lock(); if process.try_wait().map(|s| s.is_some()).unwrap_or(false) {
if let Some(ref mut proc) = *proc_guard { self.process = None;
if proc.try_wait().map(|s| s.is_some()).unwrap_or(false) { self.stdin = None;
*proc_guard = None;
self.stdin = None;
}
}
if proc_guard.is_some() {
return Err("Process already running".to_string());
} }
} }
if self.process.is_some() {
return Err("Process already running".to_string());
}
// Load saved achievements and stats when starting a new session // Load saved achievements and stats when starting a new session
let app_clone = app.clone(); let app_clone = app.clone();
let stats = self.stats.clone(); let stats = self.stats.clone();
@@ -235,7 +224,6 @@ impl WslBridge {
tracing::debug!("Working dir: {}", working_dir); tracing::debug!("Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path); let mut cmd = Command::new(&claude_path);
cmd.hide_window();
cmd.args([ cmd.args([
"--output-format", "--output-format",
"stream-json", "stream-json",
@@ -291,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 cmd
} else { } else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded // Running on Windows - use wsl with bash login shell to ensure PATH is loaded
@@ -303,7 +286,6 @@ impl WslBridge {
// Check if Claude binary is installed inside WSL // Check if Claude binary is installed inside WSL
let binary_check = Command::new("wsl") let binary_check = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-lc", "which claude"]) .args(["-e", "bash", "-lc", "which claude"])
.output(); .output();
if let Ok(output) = binary_check { if let Ok(output) = binary_check {
@@ -314,7 +296,6 @@ impl WslBridge {
// Validate the working directory exists inside WSL before spawning // Validate the working directory exists inside WSL before spawning
let dir_check = Command::new("wsl") let dir_check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", working_dir]) .args(["-e", "test", "-d", working_dir])
.output(); .output();
if let Ok(output) = dir_check { if let Ok(output) = dir_check {
@@ -338,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_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose", "claude --output-format stream-json --input-format stream-json --verbose",
); );
@@ -389,7 +365,8 @@ impl WslBridge {
cmd.args(["-e", "bash", "-lc", &claude_cmd]); cmd.args(["-e", "bash", "-lc", &claude_cmd]);
// Hide the console window on Windows // Hide the console window on Windows
cmd.hide_window(); #[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
cmd cmd
}; };
@@ -409,11 +386,7 @@ impl WslBridge {
let stderr = child.stderr.take(); let stderr = child.stderr.take();
self.stdin = stdin; self.stdin = stdin;
*self.process.lock() = Some(child); self.process = 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 // Note: We no longer reset stats here - stats persist across reconnects
// Stats are only reset when explicitly disconnecting via stop() // Stats are only reset when explicitly disconnecting via stop()
@@ -430,17 +403,8 @@ impl WslBridge {
let app_clone = app.clone(); let app_clone = app.clone();
let stats_clone = self.stats.clone(); let stats_clone = self.stats.clone();
let conv_id = self.conversation_id.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 || { thread::spawn(move || {
handle_stdout( handle_stdout(stdout, app_clone, stats_clone, conv_id);
stdout,
app_clone,
stats_clone,
conv_id,
received_init_clone,
intentional_stop_clone,
);
}); });
} }
@@ -452,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( emit_connection_status(
&app, &app,
ConnectionStatus::Connected, ConnectionStatus::Connected,
self.conversation_id.clone(), 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(()) Ok(())
} }
@@ -555,15 +500,7 @@ impl WslBridge {
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work, // 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. // we have to kill the process. This is the only reliable way to stop it.
// See: https://github.com/anthropics/claude-code/issues/3455 // See: https://github.com/anthropics/claude-code/issues/3455
// Extract the process first so the MutexGuard is dropped before we mutably if let Some(mut process) = self.process.take() {
// 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 // Estimate cost for interrupted request before killing
self.estimate_interrupted_request_cost(app); self.estimate_interrupted_request_cost(app);
@@ -693,10 +630,7 @@ impl WslBridge {
} }
pub fn stop(&mut self, app: &AppHandle) { pub fn stop(&mut self, app: &AppHandle) {
// Signal handle_stdout that this is an intentional stop so it doesn't emit if let Some(mut process) = self.process.take() {
// 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.kill();
let _ = process.wait(); let _ = process.wait();
} }
@@ -727,7 +661,7 @@ impl WslBridge {
} }
pub fn is_running(&self) -> bool { pub fn is_running(&self) -> bool {
self.process.lock().is_some() self.process.is_some()
} }
pub fn get_working_directory(&self) -> &str { pub fn get_working_directory(&self) -> &str {
@@ -750,17 +684,13 @@ fn handle_stdout(
app: AppHandle, app: AppHandle,
stats: Arc<RwLock<UsageStats>>, stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>, conversation_id: Option<String>,
received_init: Arc<AtomicBool>,
intentional_stop: Arc<AtomicBool>,
) { ) {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
for line in reader.lines() { for line in reader.lines() {
match line { match line {
Ok(line) if !line.is_empty() => { Ok(line) if !line.is_empty() => {
if let Err(e) = if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
process_json_line(&line, &app, &stats, &conversation_id, &received_init)
{
tracing::error!("Error processing line: {}", e); tracing::error!("Error processing line: {}", e);
} }
} }
@@ -772,45 +702,6 @@ 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); emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
} }
@@ -864,7 +755,6 @@ fn handle_stderr(
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
duration_ms: None, duration_ms: None,
num_turns: None, num_turns: None,
last_assistant_message: stop_data.last_assistant_message,
}, },
); );
} }
@@ -938,84 +828,32 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
#[derive(Debug)] #[derive(Debug)]
struct SubagentStopData { struct SubagentStopData {
parent_tool_use_id: Option<String>, 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> { 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"); // Extract parent_tool_use_id if present
let last_assistant_message = extract_debug_string_value(line, "last_assistant_message"); 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 { Some(SubagentStopData {
parent_tool_use_id, 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( fn process_json_line(
line: &str, line: &str,
app: &AppHandle, app: &AppHandle,
stats: &Arc<RwLock<UsageStats>>, stats: &Arc<RwLock<UsageStats>>,
conversation_id: &Option<String>, conversation_id: &Option<String>,
received_init: &Arc<AtomicBool>,
) -> Result<(), String> { ) -> Result<(), String> {
let message: ClaudeMessage = serde_json::from_str(line) let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
@@ -1028,9 +866,6 @@ fn process_json_line(
.. ..
} => { } => {
if subtype == "init" { 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 { if let Some(id) = session_id {
let _ = app.emit( let _ = app.emit(
"claude:session", "claude:session",
@@ -1315,8 +1150,8 @@ fn process_json_line(
} }
ContentBlock::ToolResult { ContentBlock::ToolResult {
tool_use_id, tool_use_id,
content,
is_error, is_error,
..
} => { } => {
// Emit agent-end for all tool results // Emit agent-end for all tool results
// The frontend will ignore IDs that don't match known agents // The frontend will ignore IDs that don't match known agents
@@ -1334,7 +1169,6 @@ fn process_json_line(
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
duration_ms: None, duration_ms: None,
num_turns: None, num_turns: None,
last_assistant_message: extract_tool_result_text(content),
}, },
); );
} }
@@ -1752,8 +1586,8 @@ fn process_json_line(
for block in &message.content { for block in &message.content {
if let ContentBlock::ToolResult { if let ContentBlock::ToolResult {
tool_use_id, tool_use_id,
content,
is_error, is_error,
..
} = block } = block
{ {
let now = SystemTime::now() let now = SystemTime::now()
@@ -1770,7 +1604,6 @@ fn process_json_line(
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
duration_ms: None, duration_ms: None,
num_turns: None, num_turns: None,
last_assistant_message: extract_tool_result_text(content),
}, },
); );
} }
@@ -2162,7 +1995,7 @@ mod tests {
#[test] #[test]
fn test_stale_process_detection_with_try_wait() { fn test_stale_process_detection_with_try_wait() {
// Spawn a real process that exits immediately so we can verify try_wait detects it // 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 // Wait for it to exit
let _ = child.wait(); let _ = child.wait();
@@ -2181,7 +2014,7 @@ mod tests {
fn test_stale_process_is_some_after_exit() { fn test_stale_process_is_some_after_exit() {
// Verify the logic used in start(): a process that has exited is detected // Verify the logic used in start(): a process that has exited is detected
// and the handle is cleaned up so start() can proceed // 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 it exit
let _ = child.wait(); let _ = child.wait();
@@ -2322,165 +2155,5 @@ mod tests {
assert!(result.is_some()); assert!(result.is_some());
let data = result.unwrap(); let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, None); 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 std::process::Command;
use tauri::command; use tauri::command;
use crate::process_ext::HideWindow;
#[command] #[command]
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> { pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
// Method 1: Try Windows 10/11 toast notification using PowerShell // Method 1: Try Windows 10/11 toast notification using PowerShell
@@ -38,7 +36,6 @@ $notifier.Show($toast)
// Try PowerShell.exe through WSL // Try PowerShell.exe through WSL
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe") let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
.hide_window()
.arg("-NoProfile") .arg("-NoProfile")
.arg("-ExecutionPolicy") .arg("-ExecutionPolicy")
.arg("Bypass") .arg("Bypass")
@@ -68,7 +65,6 @@ $notifier.Show($toast)
// Method 3: Try wsl-notify-send if available // Method 3: Try wsl-notify-send if available
let notify_result = Command::new("wsl-notify-send") let notify_result = Command::new("wsl-notify-send")
.hide_window()
.arg("--appId") .arg("--appId")
.arg("HikariDesktop") .arg("HikariDesktop")
.arg("--category") .arg("--category")
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "1.8.1", "version": "1.6.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "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, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false, 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, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false, 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> <span class="text-[10px] text-red-400">Errored / Killed</span>
{/if} {/if}
</div> </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> </div>
{/each} {/each}
{/if} {/if}
+1 -1
View File
@@ -2,7 +2,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte"; import { onMount } from "svelte";
const SUPPORTED_CLI_VERSION = "2.1.53"; const SUPPORTED_CLI_VERSION = "2.1.33";
let installedVersion = $state("Loading..."); let installedVersion = $state("Loading...");
+3 -9
View File
@@ -3,7 +3,7 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude"; import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState, characterInfo } from "$lib/stores/character"; 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 { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri"; import { setSkipNextGreeting } from "$lib/tauri";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states"; import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
@@ -14,9 +14,6 @@
let { onExpand }: Props = $props(); let { onExpand }: Props = $props();
const configValues = configStore.config;
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
let inputValue = $state(""); let inputValue = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let isConnected = $state(false); let isConnected = $state(false);
@@ -135,7 +132,7 @@
setSkipNextGreeting(true); setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId }); await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted via stop button"); claudeStore.addLine("system", "Interrupted");
characterState.setState("idle"); characterState.setState("idle");
} catch (error) { } catch (error) {
console.error("Failed to interrupt:", error); console.error("Failed to interrupt:", error);
@@ -153,10 +150,7 @@
} }
</script> </script>
<div <div class="compact-container {getStateGlow()}">
class="compact-container {getStateGlow()}"
style={hasBackgroundImage ? "background: transparent !important;" : ""}
>
<!-- Character sprite (smaller) --> <!-- Character sprite (smaller) -->
<div class="compact-character"> <div class="compact-character">
<div class="sprite-wrapper {getAnimationClass()}"> <div class="sprite-wrapper {getAnimationClass()}">
-90
View File
@@ -13,7 +13,6 @@
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import CostSummary from "./CostSummary.svelte"; import CostSummary from "./CostSummary.svelte";
let config: HikariConfig = $state({ let config: HikariConfig = $state({
@@ -55,10 +54,6 @@
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false, use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}); });
let showCustomThemeEditor = $state(false); let showCustomThemeEditor = $state(false);
@@ -66,7 +61,6 @@
interface AuthStatus { interface AuthStatus {
is_logged_in: boolean; is_logged_in: boolean;
email: string | null; email: string | null;
org_id: string | null;
org_name: string | null; org_name: string | null;
api_key_source: string | null; api_key_source: string | null;
api_provider: string | null; api_provider: string | null;
@@ -245,20 +239,6 @@
await window.setAlwaysOnTop(enabled); await window.setAlwaysOnTop(enabled);
await configStore.updateConfig({ always_on_top: 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> </script>
<!-- Backdrop --> <!-- Backdrop -->
@@ -338,14 +318,6 @@
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd> <dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
</div> </div>
{/if} {/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} {#if authStatus.api_key_source}
<div class="flex gap-2"> <div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt> <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 Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
</p> </p>
</div> </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> </section>
<!-- Greeting Section --> <!-- Greeting Section -->
@@ -932,52 +888,6 @@
expanded/collapsed to see reasoning details. expanded/collapsed to see reasoning details.
</p> </p>
</div> </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> </section>
<!-- Window Section --> <!-- Window Section -->
+13 -117
View File
@@ -12,25 +12,6 @@
let editingTabId = $state<string | null>(null); let editingTabId = $state<string | null>(null);
let editingName = $state(""); 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 // Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>(); 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 // Keyboard shortcuts
onMount(() => { 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) { function handleGlobalKeydown(event: KeyboardEvent) {
// Ctrl/Cmd + T: New tab // Ctrl/Cmd + T: New tab
if ((event.ctrlKey || event.metaKey) && event.key === "t") { if ((event.ctrlKey || event.metaKey) && event.key === "t") {
@@ -249,19 +165,21 @@
// Ctrl/Cmd + Tab: Next tab // Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) { else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault(); 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) { if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabOrder.length; const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabOrder[nextIndex]); claudeStore.switchConversation(tabs[nextIndex]);
} }
} }
// Ctrl/Cmd + Shift + Tab: Previous tab // Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) { else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault(); 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) { if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length; const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabOrder[prevIndex]); claudeStore.switchConversation(tabs[prevIndex]);
} }
} }
} }
@@ -272,22 +190,15 @@
</script> </script>
<div <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)]" class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
> >
{#each tabOrder {#each Array.from($conversations.entries()) as [id, conversation] (id)}
.filter((id) => $conversations.has(id))
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
<div <div
data-tab-id={id} class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
{id === $activeConversationId {id === $activeConversationId
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]' ? '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'} : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
{dragOverId === id && draggedId !== id ? 'drag-over' : ''} onclick={() => switchTab(id)}
{draggedId === id ? 'dragging' : ''}"
onpointerdown={(e) => handlePointerDown(e, id)}
onclick={() => handleTabClick(id)}
onkeydown={(e) => handleTabKeydown(id, e)} onkeydown={(e) => handleTabKeydown(id, e)}
role="tab" role="tab"
tabindex={0} tabindex={0}
@@ -300,7 +211,7 @@
onblur={saveTabName} onblur={saveTabName}
onkeydown={handleKeydown} onkeydown={handleKeydown}
onclick={(e) => e.stopPropagation()} 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} {:else}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -385,20 +296,5 @@
.tab-item { .tab-item {
min-width: 100px; 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> </style>
+1 -19
View File
@@ -164,17 +164,6 @@
attachments = storedAttachments; 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() { function handleInputChange() {
// If input is empty, allow history navigation again // If input is empty, allow history navigation again
// Otherwise, mark that user has manually typed // Otherwise, mark that user has manually typed
@@ -187,12 +176,6 @@
historyIndex = -1; historyIndex = -1;
tempInput = ""; 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)) { if (isSlashCommand(inputValue)) {
matchingCommands = getMatchingCommands(inputValue); matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0; showCommandMenu = matchingCommands.length > 0;
@@ -343,7 +326,7 @@ User: ${formattedMessage}`;
throw new Error("No active conversation"); throw new Error("No active conversation");
} }
await invoke("interrupt_claude", { conversationId }); await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting..."); claudeStore.addLine("system", "Process interrupted - reconnecting...");
characterState.setState("idle"); characterState.setState("idle");
// Show connecting status while we reconnect // Show connecting status while we reconnect
@@ -380,7 +363,6 @@ User: ${formattedMessage}`;
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false, use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
}, },
}); });
+2 -13
View File
@@ -35,12 +35,7 @@
}; };
renderer.codespan = ({ text }) => { renderer.codespan = ({ text }) => {
const escaped = text.replace(/</g, "&lt;").replace(/>/g, "&gt;"); return `<code class="hljs-inline">${text}</code>`;
return `<code class="hljs-inline">${escaped}</code>`;
};
renderer.html = ({ text }) => {
return text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}; };
marked.setOptions({ marked.setOptions({
@@ -281,16 +276,10 @@
font-family: "JetBrains Mono", "Fira Code", monospace; 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) { .markdown-content :global(ol) {
margin: 0.5em 0; margin: 0.5em 0;
padding-left: 1.5em; padding-left: 1.5em;
list-style-type: decimal;
} }
.markdown-content :global(li) { .markdown-content :global(li) {
+44 -27
View File
@@ -190,13 +190,10 @@
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3> <h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label <label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
for="mcp-new-name"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Server Name</label >Server Name</label
> >
<input <input
id="mcp-new-name"
type="text" type="text"
bind:value={newServerName} bind:value={newServerName}
placeholder="my-server" placeholder="my-server"
@@ -204,13 +201,10 @@
/> />
</div> </div>
<div> <div>
<label <label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
for="mcp-new-transport"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Transport</label >Transport</label
> >
<select <select
id="mcp-new-transport"
bind:value={newServerTransport} 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)]" 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> </select>
</div> </div>
<div> <div>
<label <label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1">
for="mcp-new-url"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>
{newServerTransport === "stdio" ? "Command" : "URL"} {newServerTransport === "stdio" ? "Command" : "URL"}
</label> </label>
<input <input
id="mcp-new-url"
type="text" type="text"
bind:value={newServerUrl} bind:value={newServerUrl}
placeholder={newServerTransport === "stdio" placeholder={newServerTransport === "stdio"
@@ -276,7 +266,6 @@
{:else} {:else}
<div class="space-y-2"> <div class="space-y-2">
{#each servers as server (server.name)} {#each servers as server (server.name)}
{@const TransportIcon = getTransportIcon(server.transport)}
<button <button
onclick={() => loadServerDetails(server.name)} 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" 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 items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2"> <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} {server.name}
{#if server.status} {#if server.status}
{#if server.status.includes("Connected")} {#if server.status.includes("Connected")}
@@ -331,19 +323,25 @@
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" /> <RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
</div> </div>
{:else} {:else}
{@const TransportIcon = getTransportIcon(selectedServer.transport)}
<div class="space-y-4"> <div class="space-y-4">
<!-- Name --> <!-- Name -->
<div> <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> <p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
</div> </div>
<!-- Transport --> <!-- Transport -->
<div> <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"> <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()} {selectedServer.transport.toUpperCase()}
</p> </p>
</div> </div>
@@ -351,7 +349,9 @@
<!-- URL or Command --> <!-- URL or Command -->
{#if selectedServer.url} {#if selectedServer.url}
<div> <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 <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)]" 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} {#if selectedServer.command}
<div> <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 <p
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]" 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 --> <!-- Environment Variables -->
{#if selectedServer.env} {#if selectedServer.env}
<div> <div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"> <label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
Environment >Environment</label
</p> >
<pre <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( 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, selectedServer.env,
@@ -389,9 +391,9 @@
<!-- Full Server Details --> <!-- Full Server Details -->
{#if serverDetails} {#if serverDetails}
<div> <div>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"> <label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
Full Details >Full Details</label
</p> >
<pre <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> 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> </div>
@@ -414,3 +416,18 @@
{/if} {/if}
</div> </div>
</div> </div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
+3 -4
View File
@@ -9,10 +9,10 @@
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config"; import { configStore } from "$lib/stores/config";
let permissions: PermissionRequest[] = []; let permissions: PermissionRequest[] = $state([]);
let selectedPermissions = new SvelteSet<string>(); let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = []; let grantedToolsList: string[] = $state([]);
let workingDirectory = ""; let workingDirectory = $state("");
conversationsStore.pendingPermissions.subscribe((perms) => { conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms; permissions = perms;
@@ -88,7 +88,6 @@
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools, allowed_tools: newGrantedTools,
use_worktree: config.use_worktree ?? false, use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
}, },
}); });
@@ -430,3 +430,18 @@
{/if} {/if}
</div> </div>
</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" tabindex="0"
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)} onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
> >
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6" 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()} onclick={(e) => e.stopPropagation()}
+5 -65
View File
@@ -38,8 +38,6 @@
} from "$lib/utils/conversationUtils"; } from "$lib/utils/conversationUtils";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri"; import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { debugConsoleStore } from "$lib/stores/debugConsole"; 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 DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -63,8 +61,6 @@
let showPluginPanel = $state(false); let showPluginPanel = $state(false);
let showMcpPanel = $state(false); let showMcpPanel = $state(false);
let isSummarising = $state(false); let isSummarising = $state(false);
let showWorkspaceTrust = $state(false);
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
const progress = $derived($achievementProgress); const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount); const activeAgentCount = $derived($runningAgentCount);
let currentConfig: HikariConfig = $state({ let currentConfig: HikariConfig = $state({
@@ -106,10 +102,6 @@
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false, use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}); });
let streamerModeActive = $state(false); 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 // Combine session-granted tools with config auto-granted tools
const allAllowedTools = [ const allAllowedTools = [
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]), ...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
@@ -184,7 +180,6 @@
mcp_servers_json: currentConfig.mcp_servers_json || null, mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false, 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() { async function handleDisconnect() {
try { try {
const conversationId = get(claudeStore.activeConversationId); const conversationId = get(claudeStore.activeConversationId);
@@ -343,7 +292,6 @@
mcp_servers_json: currentConfig.mcp_servers_json || null, mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false, use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
}, },
}); });
@@ -820,14 +768,6 @@
<McpManagementPanel onClose={() => (showMcpPanel = false)} /> <McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if} {/if}
{#if showWorkspaceTrust && pendingHookInfo}
<WorkspaceTrustModal
hookInfo={pendingHookInfo}
onTrust={handleTrustAndConnect}
onCancel={handleCancelConnect}
/>
{/if}
<style> <style>
/* Responsive status bar styling */ /* Responsive status bar styling */
.status-bar { .status-bar {
@@ -73,7 +73,7 @@
onClose(); onClose();
}} }}
> >
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
bind:this={menuElement} bind:this={menuElement}
class="menu-content" class="menu-content"
@@ -107,7 +107,6 @@
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: grantedToolsList, allowed_tools: grantedToolsList,
use_worktree: config.use_worktree ?? false, 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: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}> <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()}> <div class="dialog-content" onclick={(e) => e.stopPropagation()}>
<h2 class="dialog-title">{title}</h2> <h2 class="dialog-title">{title}</h2>
<p class="dialog-message">{message}</p> <p class="dialog-message">{message}</p>
@@ -83,7 +83,7 @@
onClose(); onClose();
}} }}
> >
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
bind:this={menuElement} bind:this={menuElement}
class="menu-content" class="menu-content"
@@ -78,7 +78,7 @@
<svelte:window onkeydown={handleKeydown} /> <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 <div
class="menu-overlay" class="menu-overlay"
onclick={onClose} onclick={onClose}
@@ -87,7 +87,7 @@
onClose(); onClose();
}} }}
> >
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
bind:this={menuElement} bind:this={menuElement}
class="menu-content" class="menu-content"
+2 -2
View File
@@ -50,9 +50,9 @@
<svelte:window onkeydown={handleKeydown} /> <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}> <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()}> <div class="dialog-content" onclick={(e) => e.stopPropagation()}>
<h2 class="dialog-title">{title}</h2> <h2 class="dialog-title">{title}</h2>
+78 -10
View File
@@ -1,8 +1,52 @@
import { characterState } from "$lib/stores/character";
import { notificationManager } from "./notificationManager"; import { notificationManager } from "./notificationManager";
import type { CharacterState } from "$lib/types/states";
import type { ConnectionStatus } from "$lib/types/messages"; import type { ConnectionStatus } from "$lib/types/messages";
// Track previous connection status to detect transitions // Track previous states to detect transitions
let previousCharacterState: CharacterState | null = null;
let previousConnectionStatus: ConnectionStatus | null = null; 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 { export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
// Only notify on successful connection after being disconnected // Only notify on successful connection after being disconnected
@@ -23,13 +67,37 @@ export function handleToolExecution(_toolName: string): void {
// But we could add specific rules here if needed // But we could add specific rules here if needed
} }
// No-op: sound tracking is now per-conversation in tauri.ts // Reset notification state for a new response
export function handleNewUserMessage(): void {} export function handleNewUserMessage(): void {
hasNotifiedTaskStart = false;
// No-op: all per-conversation sounds are driven by tauri.ts event listeners }
export function initializeNotificationRules(): void {}
// Store unsubscribe functions
// Cleanup — reset connection tracking on teardown let unsubscribeCharacterState: (() => void) | null = null;
export function cleanupNotificationRules(): void {
previousConnectionStatus = null; // 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
export function cleanupNotificationRules(): void {
if (unsubscribeCharacterState) {
unsubscribeCharacterState();
unsubscribeCharacterState = null;
}
// Reset state tracking
previousCharacterState = null;
previousConnectionStatus = null;
taskStartTime = null;
hasNotifiedTaskStart = false;
} }
-26
View File
@@ -177,32 +177,6 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId)); const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("running"); // Status unchanged 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", () => { describe("markAllErrored", () => {
+1 -8
View File
@@ -45,13 +45,7 @@ function createAgentStore() {
}); });
}, },
endAgent( endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
conversationId: string,
toolUseId: string,
endedAt: number,
isError: boolean,
lastAssistantMessage?: string
) {
agentsByConversation.update((state) => { agentsByConversation.update((state) => {
const agents = state[conversationId]; const agents = state[conversationId];
if (!agents) return state; if (!agents) return state;
@@ -68,7 +62,6 @@ function createAgentStore() {
endedAt, endedAt,
status: isError ? "errored" : "completed", status: isError ? "errored" : "completed",
durationMs, durationMs,
lastAssistantMessage,
}; };
return { return {
-9
View File
@@ -60,15 +60,6 @@ export const claudeStore = {
isToolGranted: conversationsStore.isToolGranted, isToolGranted: conversationsStore.isToolGranted,
setPendingRetryMessage: conversationsStore.setPendingRetryMessage, 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 // Conversation management
createConversation: conversationsStore.createConversation, createConversation: conversationsStore.createConversation,
deleteConversation: conversationsStore.deleteConversation, deleteConversation: conversationsStore.deleteConversation,
-12
View File
@@ -195,10 +195,6 @@ describe("config store", () => {
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false, 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"); expect(config.model).toBe("claude-sonnet-4");
@@ -246,10 +242,6 @@ describe("config store", () => {
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false, use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}; };
expect(config.model).toBeNull(); expect(config.model).toBeNull();
@@ -796,10 +788,6 @@ describe("config store", () => {
discord_rpc_enabled: false, discord_rpc_enabled: false,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false, use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}; };
const mockInvokeImpl = vi.mocked(invoke); const mockInvokeImpl = vi.mocked(invoke);
-11
View File
@@ -49,13 +49,6 @@ export interface HikariConfig {
show_thinking_blocks: boolean; show_thinking_blocks: boolean;
// Worktree isolation // Worktree isolation
use_worktree: boolean; 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 = { const defaultConfig: HikariConfig = {
@@ -97,10 +90,6 @@ const defaultConfig: HikariConfig = {
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false, use_worktree: false,
disable_1m_context: false,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
}; };
function createConfigStore() { function createConfigStore() {
-38
View File
@@ -523,41 +523,3 @@ describe("pending retry message", () => {
expect(pendingRetryMessage).toBeNull(); 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("");
});
});
+4 -71
View File
@@ -37,10 +37,6 @@ export interface Conversation {
attachments: Attachment[]; attachments: Attachment[];
summary: ConversationSummary | null; summary: ConversationSummary | null;
startedAt: Date; startedAt: Date;
taskStartTime: number | null;
successSoundFired: boolean;
taskStartSoundFired: boolean;
draftText: string;
} }
function createConversationsStore() { function createConversationsStore() {
@@ -79,10 +75,6 @@ function createConversationsStore() {
attachments: [], attachments: [],
summary: null, summary: null,
startedAt: new Date(), startedAt: new Date(),
taskStartTime: null,
successSoundFired: false,
taskStartSoundFired: false,
draftText: "",
}; };
} }
@@ -204,7 +196,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(activeId); const conv = convs.get(activeId);
if (conv) { if (conv) {
conv.pendingPermissions = [...conv.pendingPermissions, request]; conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -227,7 +219,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(conversationId); const conv = convs.get(conversationId);
if (conv) { if (conv) {
conv.pendingPermissions = [...conv.pendingPermissions, request]; conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -372,15 +364,9 @@ function createConversationsStore() {
if (currentId !== id) { if (currentId !== id) {
activeConversationId.set(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) { if (targetConv) {
const stateToRestore = characterState.setState(targetConv.characterState);
targetConv.characterState === "success" || targetConv.characterState === "error"
? "idle"
: targetConv.characterState;
characterState.setState(stateToRestore);
} }
} }
}, },
@@ -830,59 +816,6 @@ 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 // Add initialization helper
initialize: () => { initialize: () => {
ensureInitialized(); ensureInitialized();
+3 -68
View File
@@ -21,7 +21,6 @@ import {
handleConnectionStatusChange, handleConnectionStatusChange,
handleNewUserMessage, handleNewUserMessage,
} from "$lib/notifications/rules"; } from "$lib/notifications/rules";
import { notificationManager } from "$lib/notifications/notificationManager";
interface StateChangePayload { interface StateChangePayload {
state: CharacterState; state: CharacterState;
@@ -221,7 +220,7 @@ export async function initializeTauriListeners() {
claudeStore.addLineToConversation( claudeStore.addLineToConversation(
targetConversationId, targetConversationId,
"system", "system",
"Disconnected from Claude Code unexpectedly — the process may have crashed or been stopped by the system" "Disconnected from Claude Code"
); );
// Clear todos on real disconnect (not on reconnects for permissions) // Clear todos on real disconnect (not on reconnects for permissions)
@@ -271,63 +270,6 @@ export async function initializeTauriListeners() {
const mappedState = stateMap[state.toLowerCase()] || "idle"; 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 // Always update the conversation's state
if (conversation_id) { if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState); claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
@@ -532,17 +474,10 @@ export async function initializeTauriListeners() {
unlisteners.push(agentUpdateUnlisten); unlisteners.push(agentUpdateUnlisten);
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => { const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
const { tool_use_id, ended_at, is_error, conversation_id, last_assistant_message } = const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId); const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) { if (targetConversationId) {
agentStore.endAgent( agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
targetConversationId,
tool_use_id,
ended_at,
is_error,
last_assistant_message
);
} }
}); });
unlisteners.push(agentEndUnlisten); unlisteners.push(agentEndUnlisten);
-2
View File
@@ -12,7 +12,6 @@ export interface AgentInfo {
durationMs?: number; durationMs?: number;
characterName: string; characterName: string;
characterAvatar: string; characterAvatar: string;
lastAssistantMessage?: string;
} }
export interface AgentStartPayload { export interface AgentStartPayload {
@@ -32,5 +31,4 @@ export interface AgentEndPayload {
conversation_id?: string; conversation_id?: string;
duration_ms?: number; duration_ms?: number;
num_turns?: 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 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 { export interface UpdateInfo {
current_version: string; current_version: string;
latest_version: string; latest_version: string;
+4 -59
View File
@@ -12,7 +12,6 @@
setSkipNextGreeting, setSkipNextGreeting,
} from "$lib/tauri"; } from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config"; import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import { readFile } from "@tauri-apps/plugin-fs";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications"; import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude"; import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
@@ -38,45 +37,6 @@
import { debugConsoleStore } from "$lib/stores/debugConsole"; import { debugConsoleStore } from "$lib/stores/debugConsole";
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos"; 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 initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined); let updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false); let achievementPanelOpen = $state(false);
@@ -337,7 +297,7 @@
setSkipNextGreeting(true); setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId }); await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted by keyboard shortcut (Ctrl+C)"); claudeStore.addLine("system", "Process interrupted");
} catch (error) { } catch (error) {
console.error("Failed to interrupt:", error); console.error("Failed to interrupt:", error);
} }
@@ -513,27 +473,16 @@
}); });
</script> </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} {#if compactModeActive}
<!-- Compact mode: minimal widget interface --> <!-- Compact mode: minimal widget interface -->
<div <div
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden" 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} /> <CompactMode onExpand={exitCompactMode} />
</div> </div>
{:else} {:else}
<!-- Full mode: standard interface --> <!-- Full mode: standard interface -->
<div <div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
style={backgroundDataUrl ? "background: transparent;" : ""}
>
<StatusBar <StatusBar
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)} onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
onToggleCompact={enterCompactMode} onToggleCompact={enterCompactMode}
@@ -542,12 +491,8 @@
<main class="flex-1 flex overflow-hidden"> <main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display --> <!-- Left panel: Character display -->
<div <div
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center {backgroundDataUrl 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;"
: '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 /> <AnimeGirl />
</div> </div>
-14
View File
@@ -8,20 +8,6 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
preprocess: vitePreprocess(), 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: { kit: {
adapter: adapter({ adapter: adapter({
fallback: "index.html", 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 { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global // @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST; 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/ // https://vite.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit()],
customLogger: logger,
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //