generated from nhcarrigan/template
feat: Claude CLI 2.1.50–2.1.53 audit #171
+62
-62
@@ -27,69 +27,69 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.8.1",
|
||||
"@codemirror/lang-angular": "^0.1.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-less": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sass": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-vue": "^0.1.3",
|
||||
"@codemirror/lang-wast": "^6.0.2",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.11",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"codemirror": "^6.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-svelte": "^0.563.0",
|
||||
"marked": "^17.0.1"
|
||||
"@codemirror/commands": "6.10.2",
|
||||
"@codemirror/lang-angular": "0.1.4",
|
||||
"@codemirror/lang-cpp": "6.0.3",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-go": "6.0.1",
|
||||
"@codemirror/lang-html": "6.4.11",
|
||||
"@codemirror/lang-java": "6.0.2",
|
||||
"@codemirror/lang-javascript": "6.2.4",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-less": "6.0.2",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
"@codemirror/lang-php": "6.0.2",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-rust": "6.0.2",
|
||||
"@codemirror/lang-sass": "6.0.2",
|
||||
"@codemirror/lang-sql": "6.10.0",
|
||||
"@codemirror/lang-vue": "0.1.3",
|
||||
"@codemirror/lang-wast": "6.0.2",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/lang-yaml": "6.1.2",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
"@codemirror/view": "6.39.15",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "2.6.0",
|
||||
"@tauri-apps/plugin-fs": "2.4.5",
|
||||
"@tauri-apps/plugin-notification": "2.3.3",
|
||||
"@tauri-apps/plugin-opener": "2.5.3",
|
||||
"@tauri-apps/plugin-os": "2.3.2",
|
||||
"@tauri-apps/plugin-shell": "2.3.5",
|
||||
"@tauri-apps/plugin-store": "2.4.2",
|
||||
"codemirror": "6.0.2",
|
||||
"highlight.js": "11.11.1",
|
||||
"lucide-svelte": "0.575.0",
|
||||
"marked": "17.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.14.0",
|
||||
"globals": "^17.0.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"prettier": "^3.8.0",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.0.17"
|
||||
"@eslint/js": "9.39.3",
|
||||
"@sveltejs/adapter-static": "3.0.10",
|
||||
"@sveltejs/kit": "2.53.2",
|
||||
"@sveltejs/vite-plugin-svelte": "5.1.1",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@tauri-apps/cli": "2.10.0",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/svelte": "5.3.1",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"eslint": "9.39.3",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-svelte": "3.15.0",
|
||||
"globals": "17.3.0",
|
||||
"jsdom": "28.1.0",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-svelte": "3.5.0",
|
||||
"svelte": "5.53.5",
|
||||
"svelte-check": "4.4.3",
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.56.1",
|
||||
"vite": "6.4.1",
|
||||
"vitest": "4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+695
-682
File diff suppressed because it is too large
Load Diff
Generated
+492
-297
File diff suppressed because it is too large
Load Diff
+152
-2
@@ -7,6 +7,7 @@ use tauri_plugin_store::StoreExt;
|
||||
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
|
||||
use crate::bridge_manager::SharedBridgeManager;
|
||||
use crate::config::{ClaudeStartOptions, HikariConfig};
|
||||
use crate::process_ext::HideWindow;
|
||||
use crate::stats::UsageStats;
|
||||
use crate::temp_manager::SharedTempFileManager;
|
||||
|
||||
@@ -59,6 +60,7 @@ fn create_claude_command() -> std::process::Command {
|
||||
// Non-login shells launched by `wsl` don't inherit the full user PATH,
|
||||
// so we need to use a login shell to get the correct PATH
|
||||
let which_output = std::process::Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "bash", "-l", "-c", "which claude"])
|
||||
.output();
|
||||
|
||||
@@ -66,6 +68,7 @@ fn create_claude_command() -> std::process::Command {
|
||||
Ok(output) if output.status.success() => {
|
||||
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let mut cmd = std::process::Command::new("wsl");
|
||||
cmd.hide_window();
|
||||
cmd.arg(claude_path);
|
||||
cmd
|
||||
}
|
||||
@@ -73,6 +76,7 @@ fn create_claude_command() -> std::process::Command {
|
||||
// Fallback to just "claude" if which fails
|
||||
// This maintains backwards compatibility
|
||||
let mut cmd = std::process::Command::new("wsl");
|
||||
cmd.hide_window();
|
||||
cmd.arg("claude");
|
||||
cmd
|
||||
}
|
||||
@@ -85,18 +89,23 @@ fn create_claude_command() -> std::process::Command {
|
||||
// This works regardless of how Claude Code was installed (standalone, npm, etc.)
|
||||
// and avoids hardcoding paths
|
||||
let which_output = std::process::Command::new("which")
|
||||
.hide_window()
|
||||
.arg("claude")
|
||||
.output();
|
||||
|
||||
match which_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
std::process::Command::new(claude_path)
|
||||
let mut cmd = std::process::Command::new(claude_path);
|
||||
cmd.hide_window();
|
||||
cmd
|
||||
}
|
||||
_ => {
|
||||
// Fallback to just "claude" if which fails
|
||||
// This maintains backwards compatibility
|
||||
std::process::Command::new("claude")
|
||||
let mut cmd = std::process::Command::new("claude");
|
||||
cmd.hide_window();
|
||||
cmd
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,6 +343,121 @@ pub async fn answer_question(
|
||||
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WorkspaceHookInfo {
|
||||
pub has_concerns: bool,
|
||||
pub hook_types: Vec<String>,
|
||||
pub mcp_servers: Vec<String>,
|
||||
pub custom_commands: Vec<String>,
|
||||
}
|
||||
|
||||
/// Check whether a working directory has Claude Code hooks, MCP servers, or custom commands.
|
||||
///
|
||||
/// Hikari Desktop runs Claude in `--output-format stream-json` (non-interactive mode),
|
||||
/// which bypasses Claude's own workspace trust dialog. We therefore check for these
|
||||
/// ourselves so the frontend can show its own trust gate before launching.
|
||||
#[tauri::command]
|
||||
pub async fn check_workspace_hooks(working_dir: String) -> WorkspaceHookInfo {
|
||||
let use_wsl = cfg!(windows) && working_dir.starts_with('/');
|
||||
|
||||
let settings_paths = [
|
||||
format!("{}/.claude/settings.json", working_dir),
|
||||
format!("{}/.claude/settings.local.json", working_dir),
|
||||
];
|
||||
|
||||
let mut all_hook_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||||
let mut all_mcp_servers: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||||
|
||||
for path in &settings_paths {
|
||||
let content = if use_wsl {
|
||||
match read_file_via_wsl(path).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
}
|
||||
};
|
||||
|
||||
let settings: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(hooks) = settings.get("hooks").and_then(|h| h.as_object()) {
|
||||
for key in hooks.keys() {
|
||||
all_hook_types.insert(key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(servers) = settings.get("mcpServers").and_then(|s| s.as_object()) {
|
||||
for key in servers.keys() {
|
||||
all_mcp_servers.insert(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let custom_commands = list_workspace_commands(&working_dir, use_wsl).await;
|
||||
let hook_types: Vec<String> = all_hook_types.into_iter().collect();
|
||||
let mcp_servers: Vec<String> = all_mcp_servers.into_iter().collect();
|
||||
let has_concerns = !hook_types.is_empty() || !mcp_servers.is_empty() || !custom_commands.is_empty();
|
||||
|
||||
WorkspaceHookInfo {
|
||||
has_concerns,
|
||||
hook_types,
|
||||
mcp_servers,
|
||||
custom_commands,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_workspace_commands(working_dir: &str, use_wsl: bool) -> Vec<String> {
|
||||
let commands_dir = format!("{}/.claude/commands", working_dir);
|
||||
|
||||
if use_wsl {
|
||||
let script = format!(
|
||||
"if [ -d '{0}' ]; then for f in '{0}'/*.md; do [ -f \"$f\" ] && basename \"$f\" .md; done; fi",
|
||||
commands_dir
|
||||
);
|
||||
let Ok(output) = std::process::Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "sh", "-c", &script])
|
||||
.output()
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
} else {
|
||||
let dir = std::path::Path::new(&commands_dir);
|
||||
if !dir.exists() {
|
||||
return vec![];
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut names: Vec<String> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
|
||||
})
|
||||
.filter_map(|e| {
|
||||
e.path()
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
})
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_skills() -> Result<Vec<String>, String> {
|
||||
// On Windows, we need to use WSL to access the skills directory
|
||||
@@ -381,6 +505,7 @@ async fn list_skills_via_wsl() -> Result<Vec<String>, String> {
|
||||
|
||||
// Use WSL to list directories in ~/.claude/skills that contain SKILL.md
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args([
|
||||
"-e",
|
||||
"sh",
|
||||
@@ -680,6 +805,7 @@ async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
|
||||
);
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "sh", "-c", &script])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -742,6 +868,7 @@ async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "cat", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -773,6 +900,7 @@ async fn write_file_via_wsl(path: &str, content: &str) -> Result<(), String> {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "sh", "-c", &format!("cat > '{}'", path)])
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
@@ -821,6 +949,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if file exists first
|
||||
let check = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -830,6 +959,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "touch", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -870,6 +1000,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if directory exists first
|
||||
let check = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -879,6 +1010,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "mkdir", "-p", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -923,6 +1055,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if path exists
|
||||
let check_exists = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -933,6 +1066,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if path is a directory
|
||||
let check_dir = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-d", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -942,6 +1076,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "rm", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -986,6 +1121,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if path exists
|
||||
let check_exists = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -996,6 +1132,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if path is a directory
|
||||
let check_dir = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-d", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1005,6 +1142,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "rm", "-rf", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1050,6 +1188,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
|
||||
|
||||
// Check if old path exists
|
||||
let check_old = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", old_path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1060,6 +1199,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
|
||||
|
||||
// Check if new path already exists
|
||||
let check_new = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", new_path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1069,6 +1209,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "mv", old_path, new_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1246,6 +1387,7 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
|
||||
"#;
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "bash", "-l", "-c", script])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1366,6 +1508,7 @@ pub async fn get_claude_version() -> Result<String, String> {
|
||||
pub struct ClaudeAuthStatus {
|
||||
pub is_logged_in: bool,
|
||||
pub email: Option<String>,
|
||||
pub org_id: Option<String>,
|
||||
pub org_name: Option<String>,
|
||||
pub api_key_source: Option<String>,
|
||||
pub api_provider: Option<String>,
|
||||
@@ -1396,6 +1539,11 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let org_id = json
|
||||
.get("orgId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let org_name = json
|
||||
.get("orgName")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -1420,6 +1568,7 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
|
||||
Ok(ClaudeAuthStatus {
|
||||
is_logged_in,
|
||||
email,
|
||||
org_id,
|
||||
org_name,
|
||||
api_key_source,
|
||||
api_provider,
|
||||
@@ -1436,6 +1585,7 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
|
||||
Ok(ClaudeAuthStatus {
|
||||
is_logged_in,
|
||||
email: None,
|
||||
org_id: None,
|
||||
org_name: None,
|
||||
api_key_source: None,
|
||||
api_provider: None,
|
||||
|
||||
@@ -125,6 +125,16 @@ pub struct HikariConfig {
|
||||
|
||||
#[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 {
|
||||
@@ -159,6 +169,9 @@ impl Default for HikariConfig {
|
||||
discord_rpc_enabled: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: Vec::new(),
|
||||
background_image_path: None,
|
||||
background_image_opacity: 0.3,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,6 +208,10 @@ fn default_discord_rpc_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_background_image_opacity() -> f32 {
|
||||
0.3
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BudgetAction {
|
||||
@@ -268,6 +285,7 @@ mod tests {
|
||||
assert!(config.discord_rpc_enabled);
|
||||
assert!(!config.use_worktree);
|
||||
assert!(!config.disable_1m_context);
|
||||
assert!(config.trusted_workspaces.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -302,6 +320,9 @@ mod tests {
|
||||
discord_rpc_enabled: true,
|
||||
use_worktree: true,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
|
||||
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
||||
background_image_opacity: 0.25,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::process_ext::HideWindow;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitStatus {
|
||||
pub is_repo: bool,
|
||||
@@ -37,6 +39,7 @@ pub struct GitLogEntry {
|
||||
|
||||
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.hide_window()
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
.output()
|
||||
|
||||
@@ -8,6 +8,7 @@ mod debug_logger;
|
||||
mod discord_rpc;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod process_ext;
|
||||
mod quick_actions;
|
||||
mod sessions;
|
||||
mod snippets;
|
||||
@@ -120,6 +121,7 @@ pub fn run() {
|
||||
get_persisted_stats,
|
||||
load_saved_achievements,
|
||||
answer_question,
|
||||
check_workspace_hooks,
|
||||
send_windows_notification,
|
||||
send_simple_notification,
|
||||
send_windows_toast,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::process::Command;
|
||||
use tauri::command;
|
||||
|
||||
use crate::process_ext::HideWindow;
|
||||
|
||||
/// Generate PowerShell script for Windows Toast Notification
|
||||
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
|
||||
format!(
|
||||
@@ -82,6 +84,7 @@ fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<St
|
||||
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
|
||||
// Use notify-send for Linux/WSL
|
||||
let output = Command::new("notify-send")
|
||||
.hide_window()
|
||||
.arg(&title)
|
||||
.arg(&body)
|
||||
.arg("--urgency=normal")
|
||||
@@ -109,6 +112,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
|
||||
|
||||
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
|
||||
let output = Command::new("pwsh.exe")
|
||||
.hide_window()
|
||||
.arg("-NoProfile")
|
||||
.arg("-WindowStyle")
|
||||
.arg("Hidden")
|
||||
@@ -117,6 +121,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
|
||||
.output()
|
||||
.or_else(|_| {
|
||||
Command::new("powershell.exe")
|
||||
.hide_window()
|
||||
.arg("-NoProfile")
|
||||
.arg("-WindowStyle")
|
||||
.arg("Hidden")
|
||||
@@ -140,6 +145,7 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
|
||||
let message = format_simple_notification(&title, &body);
|
||||
|
||||
Command::new("cmd.exe")
|
||||
.hide_window()
|
||||
.arg("/c")
|
||||
.arg("msg")
|
||||
.arg("*")
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::process::Command;
|
||||
|
||||
/// Extension trait for `Command` that hides the console window on Windows.
|
||||
///
|
||||
/// On non-Windows platforms this is a no-op, so callers can unconditionally
|
||||
/// chain `.hide_window()` without any `#[cfg]` guards at the call sites.
|
||||
pub trait HideWindow {
|
||||
fn hide_window(&mut self) -> &mut Self;
|
||||
}
|
||||
|
||||
impl HideWindow for Command {
|
||||
fn hide_window(&mut self) -> &mut Self {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
self.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ use std::process::Command;
|
||||
use tauri::command;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use crate::process_ext::HideWindow;
|
||||
|
||||
#[command]
|
||||
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
|
||||
// Create a VBScript that shows a Windows notification
|
||||
@@ -40,7 +42,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
||||
} else if temp_path.starts_with("/tmp/") {
|
||||
// WSL temp files might be in a different location
|
||||
// Try to use wslpath to convert
|
||||
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
|
||||
let output = Command::new("wslpath").hide_window().arg("-w").arg(&temp_path).output();
|
||||
|
||||
if let Ok(result) = output {
|
||||
if result.status.success() {
|
||||
@@ -57,6 +59,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
||||
|
||||
// Execute the VBScript using wscript.exe
|
||||
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
|
||||
.hide_window()
|
||||
.arg("//NoLogo")
|
||||
.arg(&windows_path)
|
||||
.output()
|
||||
|
||||
+108
-26
@@ -1,17 +1,17 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use parking_lot::Mutex;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
||||
use crate::commands::record_cost;
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::process_ext::HideWindow;
|
||||
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
||||
use crate::types::{
|
||||
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||||
@@ -89,7 +89,7 @@ fn find_claude_binary() -> Option<String> {
|
||||
|
||||
// Use a login shell to resolve claude via the user's PATH - GUI apps don't
|
||||
// inherit shell PATH, so bare `which` may miss ~/.local/bin entries
|
||||
if let Ok(output) = Command::new("bash").args(["-lc", "which claude"]).output() {
|
||||
if let Ok(output) = Command::new("bash").hide_window().args(["-lc", "which claude"]).output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
@@ -102,52 +102,58 @@ fn find_claude_binary() -> Option<String> {
|
||||
}
|
||||
|
||||
pub struct WslBridge {
|
||||
process: Option<Child>,
|
||||
process: Arc<Mutex<Option<Child>>>,
|
||||
stdin: Option<ChildStdin>,
|
||||
working_directory: String,
|
||||
session_id: Option<String>,
|
||||
mcp_config_file: Option<NamedTempFile>,
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
conversation_id: Option<String>,
|
||||
/// Set to true once the `system:init` message arrives, false at the start of every new session.
|
||||
received_init: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl WslBridge {
|
||||
pub fn new() -> Self {
|
||||
WslBridge {
|
||||
process: None,
|
||||
process: Arc::new(Mutex::new(None)),
|
||||
stdin: None,
|
||||
working_directory: String::new(),
|
||||
session_id: None,
|
||||
mcp_config_file: None,
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: None,
|
||||
received_init: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_conversation_id(conversation_id: String) -> Self {
|
||||
WslBridge {
|
||||
process: None,
|
||||
process: Arc::new(Mutex::new(None)),
|
||||
stdin: None,
|
||||
working_directory: String::new(),
|
||||
session_id: None,
|
||||
mcp_config_file: None,
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: Some(conversation_id),
|
||||
received_init: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
||||
// If a process handle exists but the process has already exited (e.g. due to a
|
||||
// failed working directory), clean up the stale handle so we can restart cleanly.
|
||||
if let Some(ref mut process) = self.process {
|
||||
if process.try_wait().map(|s| s.is_some()).unwrap_or(false) {
|
||||
self.process = None;
|
||||
self.stdin = None;
|
||||
{
|
||||
let mut proc_guard = self.process.lock();
|
||||
if let Some(ref mut proc) = *proc_guard {
|
||||
if proc.try_wait().map(|s| s.is_some()).unwrap_or(false) {
|
||||
*proc_guard = None;
|
||||
self.stdin = None;
|
||||
}
|
||||
}
|
||||
if proc_guard.is_some() {
|
||||
return Err("Process already running".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if self.process.is_some() {
|
||||
return Err("Process already running".to_string());
|
||||
}
|
||||
|
||||
// Load saved achievements and stats when starting a new session
|
||||
@@ -224,6 +230,7 @@ impl WslBridge {
|
||||
tracing::debug!("Working dir: {}", working_dir);
|
||||
|
||||
let mut cmd = Command::new(&claude_path);
|
||||
cmd.hide_window();
|
||||
cmd.args([
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
@@ -291,6 +298,7 @@ impl WslBridge {
|
||||
|
||||
// Check if Claude binary is installed inside WSL
|
||||
let binary_check = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "bash", "-lc", "which claude"])
|
||||
.output();
|
||||
if let Ok(output) = binary_check {
|
||||
@@ -301,6 +309,7 @@ impl WslBridge {
|
||||
|
||||
// Validate the working directory exists inside WSL before spawning
|
||||
let dir_check = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-d", working_dir])
|
||||
.output();
|
||||
if let Ok(output) = dir_check {
|
||||
@@ -375,8 +384,7 @@ impl WslBridge {
|
||||
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
|
||||
|
||||
// Hide the console window on Windows
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
cmd.hide_window();
|
||||
|
||||
cmd
|
||||
};
|
||||
@@ -396,7 +404,10 @@ impl WslBridge {
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
self.stdin = stdin;
|
||||
self.process = Some(child);
|
||||
*self.process.lock() = Some(child);
|
||||
|
||||
// Reset the init flag so the watchdog and stdout handler start fresh.
|
||||
self.received_init.store(false, Ordering::SeqCst);
|
||||
|
||||
// Note: We no longer reset stats here - stats persist across reconnects
|
||||
// Stats are only reset when explicitly disconnecting via stop()
|
||||
@@ -413,8 +424,9 @@ impl WslBridge {
|
||||
let app_clone = app.clone();
|
||||
let stats_clone = self.stats.clone();
|
||||
let conv_id = self.conversation_id.clone();
|
||||
let received_init_clone = self.received_init.clone();
|
||||
thread::spawn(move || {
|
||||
handle_stdout(stdout, app_clone, stats_clone, conv_id);
|
||||
handle_stdout(stdout, app_clone, stats_clone, conv_id, received_init_clone);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -426,12 +438,31 @@ impl WslBridge {
|
||||
});
|
||||
}
|
||||
|
||||
// Emit Connected immediately so the frontend can send the greeting message.
|
||||
// This is intentionally optimistic — Claude Code buffers stdout until stdin receives
|
||||
// data on Windows/WSL, so we must send something to stdin first or system:init never
|
||||
// arrives. The received_init flag below tracks whether init actually arrived.
|
||||
emit_connection_status(
|
||||
&app,
|
||||
ConnectionStatus::Connected,
|
||||
self.conversation_id.clone(),
|
||||
);
|
||||
|
||||
// Watchdog: if system:init never arrives the process is truly hung (e.g. a silent crash
|
||||
// after spawning). After 5 minutes we kill it so the user isn't stuck forever.
|
||||
// handle_stdout will surface the error when stdout closes after the kill.
|
||||
let process_watchdog = self.process.clone();
|
||||
let received_init_watchdog = self.received_init.clone();
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_secs(60));
|
||||
if !received_init_watchdog.load(Ordering::SeqCst) {
|
||||
if let Some(mut proc) = process_watchdog.lock().take() {
|
||||
let _ = proc.kill();
|
||||
let _ = proc.wait();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -510,7 +541,10 @@ impl WslBridge {
|
||||
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
|
||||
// we have to kill the process. This is the only reliable way to stop it.
|
||||
// See: https://github.com/anthropics/claude-code/issues/3455
|
||||
if let Some(mut process) = self.process.take() {
|
||||
// Extract the process first so the MutexGuard is dropped before we mutably
|
||||
// borrow `self` again via estimate_interrupted_request_cost.
|
||||
let maybe_process = self.process.lock().take();
|
||||
if let Some(mut process) = maybe_process {
|
||||
// Estimate cost for interrupted request before killing
|
||||
self.estimate_interrupted_request_cost(app);
|
||||
|
||||
@@ -640,7 +674,7 @@ impl WslBridge {
|
||||
}
|
||||
|
||||
pub fn stop(&mut self, app: &AppHandle) {
|
||||
if let Some(mut process) = self.process.take() {
|
||||
if let Some(mut process) = self.process.lock().take() {
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
}
|
||||
@@ -671,7 +705,7 @@ impl WslBridge {
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.process.is_some()
|
||||
self.process.lock().is_some()
|
||||
}
|
||||
|
||||
pub fn get_working_directory(&self) -> &str {
|
||||
@@ -694,13 +728,16 @@ fn handle_stdout(
|
||||
app: AppHandle,
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
conversation_id: Option<String>,
|
||||
received_init: Arc<AtomicBool>,
|
||||
) {
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(line) if !line.is_empty() => {
|
||||
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) {
|
||||
if let Err(e) =
|
||||
process_json_line(&line, &app, &stats, &conversation_id, &received_init)
|
||||
{
|
||||
tracing::error!("Error processing line: {}", e);
|
||||
}
|
||||
}
|
||||
@@ -712,6 +749,22 @@ fn handle_stdout(
|
||||
}
|
||||
}
|
||||
|
||||
// If stdout closed before system:init arrived the process exited without initialising.
|
||||
// Emit an error line so the user understands why the connection failed.
|
||||
if !received_init.load(Ordering::SeqCst) {
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "error".to_string(),
|
||||
content: "Claude Code exited before initialising. Check the working directory and Claude Code installation, then try connecting again.".to_string(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||
}
|
||||
|
||||
@@ -916,6 +969,7 @@ fn process_json_line(
|
||||
app: &AppHandle,
|
||||
stats: &Arc<RwLock<UsageStats>>,
|
||||
conversation_id: &Option<String>,
|
||||
received_init: &Arc<AtomicBool>,
|
||||
) -> Result<(), String> {
|
||||
let message: ClaudeMessage = serde_json::from_str(line)
|
||||
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
|
||||
@@ -928,6 +982,9 @@ fn process_json_line(
|
||||
..
|
||||
} => {
|
||||
if subtype == "init" {
|
||||
// Mark as initialised so the watchdog knows the process is healthy.
|
||||
received_init.store(true, Ordering::SeqCst);
|
||||
|
||||
if let Some(id) = session_id {
|
||||
let _ = app.emit(
|
||||
"claude:session",
|
||||
@@ -2059,7 +2116,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_stale_process_detection_with_try_wait() {
|
||||
// Spawn a real process that exits immediately so we can verify try_wait detects it
|
||||
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
|
||||
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
|
||||
|
||||
// Wait for it to exit
|
||||
let _ = child.wait();
|
||||
@@ -2078,7 +2135,7 @@ mod tests {
|
||||
fn test_stale_process_is_some_after_exit() {
|
||||
// Verify the logic used in start(): a process that has exited is detected
|
||||
// and the handle is cleaned up so start() can proceed
|
||||
let mut child = Command::new("true").spawn().expect("Failed to spawn 'true'");
|
||||
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
|
||||
|
||||
// Let it exit
|
||||
let _ = child.wait();
|
||||
@@ -2355,4 +2412,29 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::process::Command;
|
||||
use tauri::command;
|
||||
|
||||
use crate::process_ext::HideWindow;
|
||||
|
||||
#[command]
|
||||
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
|
||||
// Method 1: Try Windows 10/11 toast notification using PowerShell
|
||||
@@ -36,6 +38,7 @@ $notifier.Show($toast)
|
||||
|
||||
// Try PowerShell.exe through WSL
|
||||
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
|
||||
.hide_window()
|
||||
.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
@@ -65,6 +68,7 @@ $notifier.Show($toast)
|
||||
|
||||
// Method 3: Try wsl-notify-send if available
|
||||
let notify_result = Command::new("wsl-notify-send")
|
||||
.hide_window()
|
||||
.arg("--appId")
|
||||
.arg("HikariDesktop")
|
||||
.arg("--category")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const SUPPORTED_CLI_VERSION = "2.1.50";
|
||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
||||
|
||||
let installedVersion = $state("Loading...");
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState, characterInfo } from "$lib/stores/character";
|
||||
import { isStreamerMode } from "$lib/stores/config";
|
||||
import { isStreamerMode, configStore } from "$lib/stores/config";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
||||
@@ -14,6 +14,9 @@
|
||||
|
||||
let { onExpand }: Props = $props();
|
||||
|
||||
const configValues = configStore.config;
|
||||
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
|
||||
|
||||
let inputValue = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let isConnected = $state(false);
|
||||
@@ -150,7 +153,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="compact-container {getStateGlow()}">
|
||||
<div
|
||||
class="compact-container {getStateGlow()}"
|
||||
style={hasBackgroundImage ? "background: transparent !important;" : ""}
|
||||
>
|
||||
<!-- Character sprite (smaller) -->
|
||||
<div class="compact-character">
|
||||
<div class="sprite-wrapper {getAnimationClass()}">
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import CostSummary from "./CostSummary.svelte";
|
||||
|
||||
let config: HikariConfig = $state({
|
||||
@@ -55,6 +56,9 @@
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
});
|
||||
|
||||
let showCustomThemeEditor = $state(false);
|
||||
@@ -62,6 +66,7 @@
|
||||
interface AuthStatus {
|
||||
is_logged_in: boolean;
|
||||
email: string | null;
|
||||
org_id: string | null;
|
||||
org_name: string | null;
|
||||
api_key_source: string | null;
|
||||
api_provider: string | null;
|
||||
@@ -240,6 +245,20 @@
|
||||
await window.setAlwaysOnTop(enabled);
|
||||
await configStore.updateConfig({ always_on_top: enabled });
|
||||
}
|
||||
|
||||
async function pickBackgroundImage() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "avif"] }],
|
||||
});
|
||||
if (selected) {
|
||||
config.background_image_path = selected;
|
||||
}
|
||||
}
|
||||
|
||||
function clearBackgroundImage() {
|
||||
config.background_image_path = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Backdrop -->
|
||||
@@ -319,6 +338,14 @@
|
||||
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authStatus.org_id}
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org UUID</dt>
|
||||
<dd class="text-[var(--text-secondary)] font-mono text-[10px] break-all">
|
||||
{authStatus.org_id}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if authStatus.api_key_source}
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
|
||||
@@ -905,6 +932,52 @@
|
||||
expanded/collapsed to see reasoning details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Background Image -->
|
||||
<div class="mb-4">
|
||||
<span class="block text-sm text-[var(--text-secondary)] mb-2">Background Image</span>
|
||||
{#if config.background_image_path}
|
||||
<p class="text-xs text-[var(--text-tertiary)] font-mono mb-2 truncate">
|
||||
{config.background_image_path.split("/").pop()}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={pickBackgroundImage}
|
||||
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
{config.background_image_path ? "Change Image" : "Choose Image"}
|
||||
</button>
|
||||
{#if config.background_image_path}
|
||||
<button
|
||||
onclick={clearBackgroundImage}
|
||||
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
||||
title="Remove background image"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if config.background_image_path}
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label for="bg-opacity" class="text-xs text-[var(--text-secondary)]"> Opacity </label>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
{Math.round(config.background_image_opacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="bg-opacity"
|
||||
type="range"
|
||||
bind:value={config.background_image_opacity}
|
||||
min="0.05"
|
||||
max="1"
|
||||
step="0.05"
|
||||
class="w-full h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Window Section -->
|
||||
|
||||
@@ -12,6 +12,25 @@
|
||||
let editingTabId = $state<string | null>(null);
|
||||
let editingName = $state("");
|
||||
|
||||
// Tab order for pointer-drag reordering
|
||||
let tabOrder = $state<string[]>([]);
|
||||
let draggedId = $state<string | null>(null);
|
||||
let dragOverId = $state<string | null>(null);
|
||||
let dragStartX = 0;
|
||||
let isDragging = false;
|
||||
let wasDragged = false;
|
||||
let tabsRef = $state<HTMLElement | null>(null);
|
||||
|
||||
// Keep tabOrder in sync with conversations map (add new, remove deleted)
|
||||
$effect(() => {
|
||||
const currentIds = Array.from($conversations.keys());
|
||||
const validIds = tabOrder.filter((id) => currentIds.includes(id));
|
||||
const newIds = currentIds.filter((id) => !tabOrder.includes(id));
|
||||
if (validIds.length !== tabOrder.length || newIds.length > 0) {
|
||||
tabOrder = [...validIds, ...newIds];
|
||||
}
|
||||
});
|
||||
|
||||
// Track last seen message count for each conversation
|
||||
let lastSeenMessageCount = new SvelteMap<string, number>();
|
||||
|
||||
@@ -138,8 +157,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTabClick(id: string) {
|
||||
if (wasDragged) {
|
||||
wasDragged = false;
|
||||
return;
|
||||
}
|
||||
await switchTab(id);
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent, id: string) {
|
||||
if (editingTabId === id) return;
|
||||
draggedId = id;
|
||||
dragStartX = event.clientX;
|
||||
isDragging = false;
|
||||
wasDragged = false;
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!isDragging && Math.abs(e.clientX - dragStartX) > 5) {
|
||||
isDragging = true;
|
||||
}
|
||||
if (!isDragging || !tabsRef) return;
|
||||
const tabs = tabsRef.querySelectorAll<HTMLElement>("[data-tab-id]");
|
||||
dragOverId = null;
|
||||
for (const tab of tabs) {
|
||||
const rect = tab.getBoundingClientRect();
|
||||
if (e.clientX >= rect.left && e.clientX <= rect.right) {
|
||||
const tabId = tab.dataset.tabId;
|
||||
if (tabId && tabId !== id) {
|
||||
dragOverId = tabId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
if (isDragging && draggedId && dragOverId && draggedId !== dragOverId) {
|
||||
const order = [...tabOrder];
|
||||
const fromIndex = order.indexOf(draggedId);
|
||||
const toIndex = order.indexOf(dragOverId);
|
||||
order.splice(fromIndex, 1);
|
||||
order.splice(toIndex, 0, draggedId);
|
||||
tabOrder = order;
|
||||
wasDragged = true;
|
||||
}
|
||||
draggedId = null;
|
||||
dragOverId = null;
|
||||
isDragging = false;
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onUp);
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onUp);
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
onMount(() => {
|
||||
// Initialise all conversations as seen on mount so that remounting
|
||||
// this component (e.g. after closing the file editor) doesn't falsely
|
||||
// mark existing messages as unread.
|
||||
for (const [id, conversation] of $conversations) {
|
||||
lastSeenMessageCount.set(id, conversation.terminalLines.length);
|
||||
}
|
||||
lastSeenMessageCount = lastSeenMessageCount;
|
||||
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// Ctrl/Cmd + T: New tab
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
||||
@@ -165,21 +249,19 @@
|
||||
// Ctrl/Cmd + Tab: Next tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from($conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const nextIndex = (currentIndex + 1) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[nextIndex]);
|
||||
const nextIndex = (currentIndex + 1) % tabOrder.length;
|
||||
claudeStore.switchConversation(tabOrder[nextIndex]);
|
||||
}
|
||||
}
|
||||
// Ctrl/Cmd + Shift + Tab: Previous tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from($conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[prevIndex]);
|
||||
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
|
||||
claudeStore.switchConversation(tabOrder[prevIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,15 +272,22 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={tabsRef}
|
||||
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
||||
>
|
||||
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
|
||||
{#each tabOrder
|
||||
.filter((id) => $conversations.has(id))
|
||||
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
|
||||
<div
|
||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
||||
data-tab-id={id}
|
||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
|
||||
{id === $activeConversationId
|
||||
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
||||
onclick={() => switchTab(id)}
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}
|
||||
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
|
||||
{draggedId === id ? 'dragging' : ''}"
|
||||
onpointerdown={(e) => handlePointerDown(e, id)}
|
||||
onclick={() => handleTabClick(id)}
|
||||
onkeydown={(e) => handleTabKeydown(id, e)}
|
||||
role="tab"
|
||||
tabindex={0}
|
||||
@@ -211,7 +300,7 @@
|
||||
onblur={saveTabName}
|
||||
onkeydown={handleKeydown}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32 select-text"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -296,5 +385,20 @@
|
||||
|
||||
.tab-item {
|
||||
min-width: 100px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
border-left: 2px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,7 +35,12 @@
|
||||
};
|
||||
|
||||
renderer.codespan = ({ text }) => {
|
||||
return `<code class="hljs-inline">${text}</code>`;
|
||||
const escaped = text.replace(/</g, "<").replace(/>/g, ">");
|
||||
return `<code class="hljs-inline">${escaped}</code>`;
|
||||
};
|
||||
|
||||
renderer.html = ({ text }) => {
|
||||
return text.replace(/</g, "<").replace(/>/g, ">");
|
||||
};
|
||||
|
||||
marked.setOptions({
|
||||
|
||||
@@ -190,10 +190,13 @@
|
||||
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
<label
|
||||
for="mcp-new-name"
|
||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>Server Name</label
|
||||
>
|
||||
<input
|
||||
id="mcp-new-name"
|
||||
type="text"
|
||||
bind:value={newServerName}
|
||||
placeholder="my-server"
|
||||
@@ -201,10 +204,13 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
<label
|
||||
for="mcp-new-transport"
|
||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>Transport</label
|
||||
>
|
||||
<select
|
||||
id="mcp-new-transport"
|
||||
bind:value={newServerTransport}
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
@@ -214,10 +220,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1">
|
||||
<label
|
||||
for="mcp-new-url"
|
||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>
|
||||
{newServerTransport === "stdio" ? "Command" : "URL"}
|
||||
</label>
|
||||
<input
|
||||
id="mcp-new-url"
|
||||
type="text"
|
||||
bind:value={newServerUrl}
|
||||
placeholder={newServerTransport === "stdio"
|
||||
@@ -266,6 +276,7 @@
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each servers as server (server.name)}
|
||||
{@const TransportIcon = getTransportIcon(server.transport)}
|
||||
<button
|
||||
onclick={() => loadServerDetails(server.name)}
|
||||
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
|
||||
@@ -274,10 +285,7 @@
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
<svelte:component
|
||||
this={getTransportIcon(server.transport)}
|
||||
class="w-4 h-4 {getTransportColor(server.transport)}"
|
||||
/>
|
||||
<TransportIcon class="w-4 h-4 {getTransportColor(server.transport)}" />
|
||||
{server.name}
|
||||
{#if server.status}
|
||||
{#if server.status.includes("Connected")}
|
||||
@@ -323,25 +331,19 @@
|
||||
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
{:else}
|
||||
{@const TransportIcon = getTransportIcon(selectedServer.transport)}
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Name</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
|
||||
</div>
|
||||
|
||||
<!-- Transport -->
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Transport</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
|
||||
<svelte:component
|
||||
this={getTransportIcon(selectedServer.transport)}
|
||||
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
|
||||
/>
|
||||
<TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
|
||||
{selectedServer.transport.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -349,9 +351,7 @@
|
||||
<!-- URL or Command -->
|
||||
{#if selectedServer.url}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>URL</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</p>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
@@ -362,9 +362,7 @@
|
||||
|
||||
{#if selectedServer.command}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Command</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</p>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
@@ -376,9 +374,9 @@
|
||||
<!-- Environment Variables -->
|
||||
{#if selectedServer.env}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Environment</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
||||
Environment
|
||||
</p>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
|
||||
selectedServer.env,
|
||||
@@ -391,9 +389,9 @@
|
||||
<!-- Full Server Details -->
|
||||
{#if serverDetails}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Full Details</label
|
||||
>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
||||
Full Details
|
||||
</p>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
|
||||
</div>
|
||||
@@ -416,18 +414,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -430,18 +430,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -471,6 +471,7 @@
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
} from "$lib/utils/conversationUtils";
|
||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
|
||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||
@@ -61,6 +63,8 @@
|
||||
let showPluginPanel = $state(false);
|
||||
let showMcpPanel = $state(false);
|
||||
let isSummarising = $state(false);
|
||||
let showWorkspaceTrust = $state(false);
|
||||
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
@@ -103,6 +107,9 @@
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
@@ -156,11 +163,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (isConnecting || connectionStatus === "connected") return;
|
||||
|
||||
const targetDir = selectedDirectory || "/home/naomi";
|
||||
|
||||
async function doConnect(targetDir: string) {
|
||||
// Combine session-granted tools with config auto-granted tools
|
||||
const allAllowedTools = [
|
||||
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
|
||||
@@ -200,6 +203,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (isConnecting || connectionStatus === "connected") return;
|
||||
|
||||
const targetDir = selectedDirectory || "/home/naomi";
|
||||
|
||||
if (currentConfig.trusted_workspaces?.includes(targetDir)) {
|
||||
await doConnect(targetDir);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hookInfo = await invoke<WorkspaceHookInfo>("check_workspace_hooks", {
|
||||
workingDir: targetDir,
|
||||
});
|
||||
|
||||
if (hookInfo.has_concerns) {
|
||||
pendingHookInfo = hookInfo;
|
||||
showWorkspaceTrust = true;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fail open: if we can't check hooks, proceed with connection
|
||||
console.error("Failed to check workspace hooks:", error);
|
||||
}
|
||||
|
||||
await doConnect(targetDir);
|
||||
}
|
||||
|
||||
async function handleTrustAndConnect() {
|
||||
showWorkspaceTrust = false;
|
||||
const targetDir = selectedDirectory || "/home/naomi";
|
||||
pendingHookInfo = null;
|
||||
const alreadyTrusted = currentConfig.trusted_workspaces?.includes(targetDir) ?? false;
|
||||
if (!alreadyTrusted) {
|
||||
await configStore.updateConfig({
|
||||
trusted_workspaces: [...(currentConfig.trusted_workspaces ?? []), targetDir],
|
||||
});
|
||||
}
|
||||
doConnect(targetDir);
|
||||
}
|
||||
|
||||
function handleCancelConnect() {
|
||||
showWorkspaceTrust = false;
|
||||
pendingHookInfo = null;
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
@@ -771,6 +820,14 @@
|
||||
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showWorkspaceTrust && pendingHookInfo}
|
||||
<WorkspaceTrustModal
|
||||
hookInfo={pendingHookInfo}
|
||||
onTrust={handleTrustAndConnect}
|
||||
onCancel={handleCancelConnect}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Responsive status bar styling */
|
||||
.status-bar {
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="menu-content"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||
|
||||
interface Props {
|
||||
hookInfo: WorkspaceHookInfo;
|
||||
onTrust: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const { hookInfo, onTrust, onCancel }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
characterState.setState("permission");
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onclick={onCancel}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 max-w-md w-full mx-4 shadow-xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<svg
|
||||
class="w-6 h-6 text-yellow-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Workspace Trust Required</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
This workspace contains configuration that can execute code on your system. Review what was
|
||||
found before connecting.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
{#if hookInfo.hook_types.length > 0}
|
||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||
Hooks (run shell commands automatically):
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each hookInfo.hook_types as hookType (hookType)}
|
||||
<li class="text-sm text-yellow-400 font-mono">• {hookType}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hookInfo.mcp_servers.length > 0}
|
||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||
MCP servers (run as local processes with system access):
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each hookInfo.mcp_servers as server (server)}
|
||||
<li class="text-sm text-yellow-400 font-mono">• {server}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hookInfo.custom_commands.length > 0}
|
||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||
Custom slash commands (can execute arbitrary instructions):
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each hookInfo.custom_commands as cmd (cmd)}
|
||||
<li class="text-sm text-yellow-400 font-mono">• /{cmd}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-6">
|
||||
Only connect to workspaces you trust. Trusting this workspace will remember your choice for
|
||||
future sessions.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={onTrust}
|
||||
class="px-4 py-2 text-sm bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/30 rounded-md transition-colors"
|
||||
>
|
||||
Trust and Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,9 +30,9 @@
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="dialog-overlay" onclick={onCancel}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
||||
<h2 class="dialog-title">{title}</h2>
|
||||
<p class="dialog-message">{message}</p>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="menu-content"
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="menu-overlay"
|
||||
onclick={onClose}
|
||||
@@ -87,7 +87,7 @@
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="menu-content"
|
||||
|
||||
@@ -50,9 +50,9 @@
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="dialog-overlay" onclick={onCancel}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
||||
<h2 class="dialog-title">{title}</h2>
|
||||
|
||||
|
||||
@@ -196,6 +196,9 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -244,6 +247,9 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
@@ -791,6 +797,9 @@ describe("config store", () => {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
};
|
||||
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
|
||||
@@ -51,6 +51,11 @@ export interface HikariConfig {
|
||||
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 = {
|
||||
@@ -93,6 +98,9 @@ const defaultConfig: HikariConfig = {
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
|
||||
@@ -174,6 +174,13 @@ export interface Attachment {
|
||||
previewUrl?: string; // For images, a data URL or object URL for preview
|
||||
}
|
||||
|
||||
export interface WorkspaceHookInfo {
|
||||
has_concerns: boolean;
|
||||
hook_types: string[];
|
||||
mcp_servers: string[];
|
||||
custom_commands: string[];
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
|
||||
+58
-3
@@ -12,6 +12,7 @@
|
||||
setSkipNextGreeting,
|
||||
} from "$lib/tauri";
|
||||
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
@@ -37,6 +38,45 @@
|
||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
|
||||
|
||||
let backgroundDataUrl = $state<string | null>(null);
|
||||
let backgroundOpacity = $state(0.3);
|
||||
|
||||
const configValues = configStore.config;
|
||||
$effect(() => {
|
||||
const cfg = $configValues;
|
||||
backgroundOpacity = cfg.background_image_opacity;
|
||||
if (cfg.background_image_path) {
|
||||
void loadBackgroundImage(cfg.background_image_path);
|
||||
} else {
|
||||
backgroundDataUrl = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadBackgroundImage(path: string) {
|
||||
try {
|
||||
const data = await readFile(path);
|
||||
const chunks: string[] = [];
|
||||
const chunkSize = 8192;
|
||||
for (let i = 0; i < data.length; i += chunkSize) {
|
||||
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
|
||||
}
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "png";
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
avif: "image/avif",
|
||||
};
|
||||
const mime = mimeMap[ext] ?? "image/png";
|
||||
backgroundDataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
|
||||
} catch (error) {
|
||||
console.error("Failed to load background image:", error);
|
||||
backgroundDataUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
let updateNotification: UpdateNotification | undefined = $state(undefined);
|
||||
let achievementPanelOpen = $state(false);
|
||||
@@ -473,16 +513,27 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if backgroundDataUrl}
|
||||
<div
|
||||
class="fixed inset-0 bg-cover bg-center pointer-events-none"
|
||||
style="background-image: url('{backgroundDataUrl}'); opacity: {backgroundOpacity}; z-index: 0;"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if compactModeActive}
|
||||
<!-- Compact mode: minimal widget interface -->
|
||||
<div
|
||||
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
|
||||
style={backgroundDataUrl ? "background: transparent;" : ""}
|
||||
>
|
||||
<CompactMode onExpand={exitCompactMode} />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Full mode: standard interface -->
|
||||
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
|
||||
<div
|
||||
class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
|
||||
style={backgroundDataUrl ? "background: transparent;" : ""}
|
||||
>
|
||||
<StatusBar
|
||||
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
|
||||
onToggleCompact={enterCompactMode}
|
||||
@@ -491,8 +542,12 @@
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<!-- Left panel: Character display -->
|
||||
<div
|
||||
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
|
||||
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
|
||||
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center {backgroundDataUrl
|
||||
? ''
|
||||
: 'bg-[var(--bg-secondary)]/50'}"
|
||||
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;{backgroundDataUrl
|
||||
? ' background: transparent !important;'
|
||||
: ''}"
|
||||
>
|
||||
<AnimeGirl />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,20 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
// Suppress specific build-time warnings that are intentional patterns:
|
||||
// - a11y_click_events_have_key_events: all overlay/context-menu divs use svelte:window handlers
|
||||
// - state_referenced_locally: InputDialog intentionally captures the initial prop value
|
||||
onwarn: (warning, handler) => {
|
||||
if (
|
||||
warning.code === "a11y_click_events_have_key_events" ||
|
||||
warning.code === "state_referenced_locally" ||
|
||||
// SvelteSet is already reactive; $state wrapping is unnecessary per ESLint,
|
||||
// but vite-plugin-svelte incorrectly fires non_reactive_update on SvelteSet mutations
|
||||
warning.code === "non_reactive_update"
|
||||
)
|
||||
return;
|
||||
handler(warning);
|
||||
},
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html",
|
||||
|
||||
+15
-1
@@ -1,13 +1,27 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, createLogger } from "vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// SvelteKit passes codeSplitting to Rollup 4 which no longer recognises it — harmless
|
||||
const logger = createLogger();
|
||||
const baseWarn = logger.warn.bind(logger);
|
||||
logger.warn = (/** @type {string} */ msg, /** @type {any} */ options) => {
|
||||
// SvelteKit passes codeSplitting to Rollup 4 which no longer recognises it
|
||||
if (msg.includes("codeSplitting")) return;
|
||||
// Large chunks are fine for a desktop app — no network penalty
|
||||
if (msg.includes("chunks are larger than")) return;
|
||||
// Dynamic/static import mix in CodeMirror — harmless, module stays in main chunk
|
||||
if (msg.includes("dynamically imported by") && msg.includes("codemirror")) return;
|
||||
baseWarn(msg, options);
|
||||
};
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
customLogger: logger,
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user