generated from nhcarrigan/template
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2e3f203508
|
|||
| b745100bd5 |
+63
-63
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -27,69 +27,69 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.8.1",
|
"@codemirror/commands": "6.10.2",
|
||||||
"@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.1",
|
"@codemirror/language": "6.12.2",
|
||||||
"@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.11",
|
"@codemirror/view": "6.39.15",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "1.2.3",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "2.10.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "2.6.0",
|
||||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
"@tauri-apps/plugin-fs": "2.4.5",
|
||||||
"@tauri-apps/plugin-notification": "^2",
|
"@tauri-apps/plugin-notification": "2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "2.5.3",
|
||||||
"@tauri-apps/plugin-os": "^2",
|
"@tauri-apps/plugin-os": "2.3.2",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
"@tauri-apps/plugin-shell": "2.3.5",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
"@tauri-apps/plugin-store": "2.4.2",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "6.0.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "11.11.1",
|
||||||
"lucide-svelte": "^0.563.0",
|
"lucide-svelte": "0.575.0",
|
||||||
"marked": "^17.0.1"
|
"marked": "17.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "9.39.3",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "3.0.10",
|
||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "2.53.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "5.1.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "4.2.1",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "2.10.0",
|
||||||
"@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.2",
|
"eslint": "9.39.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.14.0",
|
"eslint-plugin-svelte": "3.15.0",
|
||||||
"globals": "^17.0.0",
|
"globals": "17.3.0",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "28.1.0",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "3.5.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "5.53.5",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "4.4.3",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "4.2.1",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "^8.53.0",
|
"typescript-eslint": "8.56.1",
|
||||||
"vite": "^6.0.3",
|
"vite": "6.4.1",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+695
-682
File diff suppressed because it is too large
Load Diff
Generated
+493
-298
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.7.0"
|
version = "1.8.0"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+152
-2
@@ -7,6 +7,7 @@ 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;
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ 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();
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -73,6 +76,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -85,18 +89,23 @@ 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();
|
||||||
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
|
// Fallback to just "claude" if which fails
|
||||||
// This maintains backwards compatibility
|
// 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)
|
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
|
||||||
@@ -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
|
// 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",
|
||||||
@@ -680,6 +805,7 @@ 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))?;
|
||||||
@@ -742,6 +868,7 @@ 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))?;
|
||||||
@@ -773,6 +900,7 @@ 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()
|
||||||
@@ -821,6 +949,7 @@ 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))?;
|
||||||
@@ -830,6 +959,7 @@ 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))?;
|
||||||
@@ -870,6 +1000,7 @@ 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))?;
|
||||||
@@ -879,6 +1010,7 @@ 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))?;
|
||||||
@@ -923,6 +1055,7 @@ 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))?;
|
||||||
@@ -933,6 +1066,7 @@ 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))?;
|
||||||
@@ -942,6 +1076,7 @@ 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))?;
|
||||||
@@ -986,6 +1121,7 @@ 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))?;
|
||||||
@@ -996,6 +1132,7 @@ 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))?;
|
||||||
@@ -1005,6 +1142,7 @@ 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))?;
|
||||||
@@ -1050,6 +1188,7 @@ 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))?;
|
||||||
@@ -1060,6 +1199,7 @@ 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))?;
|
||||||
@@ -1069,6 +1209,7 @@ 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))?;
|
||||||
@@ -1246,6 +1387,7 @@ 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))?;
|
||||||
@@ -1366,6 +1508,7 @@ 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>,
|
||||||
@@ -1396,6 +1539,11 @@ 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())
|
||||||
@@ -1420,6 +1568,7 @@ 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,
|
||||||
@@ -1436,6 +1585,7 @@ 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,
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub disable_1m_context: bool,
|
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 {
|
||||||
@@ -159,6 +169,9 @@ impl Default for HikariConfig {
|
|||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: 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
|
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 {
|
||||||
@@ -268,6 +285,7 @@ mod tests {
|
|||||||
assert!(config.discord_rpc_enabled);
|
assert!(config.discord_rpc_enabled);
|
||||||
assert!(!config.use_worktree);
|
assert!(!config.use_worktree);
|
||||||
assert!(!config.disable_1m_context);
|
assert!(!config.disable_1m_context);
|
||||||
|
assert!(config.trusted_workspaces.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -302,6 +320,9 @@ mod tests {
|
|||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
use_worktree: true,
|
use_worktree: true,
|
||||||
disable_1m_context: false,
|
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();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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,
|
||||||
@@ -37,6 +39,7 @@ 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()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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;
|
||||||
@@ -120,6 +121,7 @@ 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,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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!(
|
||||||
@@ -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> {
|
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")
|
||||||
@@ -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
|
// 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")
|
||||||
@@ -117,6 +121,7 @@ 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")
|
||||||
@@ -140,6 +145,7 @@ 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("*")
|
||||||
|
|||||||
@@ -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 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
|
||||||
@@ -40,7 +42,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").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 let Ok(result) = output {
|
||||||
if result.status.success() {
|
if result.status.success() {
|
||||||
@@ -57,6 +59,7 @@ 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()
|
||||||
|
|||||||
+108
-26
@@ -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::{SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, 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").args(["-lc", "which claude"]).output() {
|
if let Ok(output) = Command::new("bash").hide_window().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,52 +102,58 @@ fn find_claude_binary() -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct WslBridge {
|
pub struct WslBridge {
|
||||||
process: Option<Child>,
|
process: Arc<Mutex<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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WslBridge {
|
impl WslBridge {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
WslBridge {
|
WslBridge {
|
||||||
process: None,
|
process: Arc::new(Mutex::new(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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_with_conversation_id(conversation_id: String) -> Self {
|
pub fn new_with_conversation_id(conversation_id: String) -> Self {
|
||||||
WslBridge {
|
WslBridge {
|
||||||
process: None,
|
process: Arc::new(Mutex::new(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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
{
|
||||||
if process.try_wait().map(|s| s.is_some()).unwrap_or(false) {
|
let mut proc_guard = self.process.lock();
|
||||||
self.process = None;
|
if let Some(ref mut proc) = *proc_guard {
|
||||||
self.stdin = None;
|
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
|
// Load saved achievements and stats when starting a new session
|
||||||
@@ -224,6 +230,7 @@ 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,6 +298,7 @@ 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 {
|
||||||
@@ -301,6 +309,7 @@ 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 {
|
||||||
@@ -375,8 +384,7 @@ 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
|
||||||
#[cfg(target_os = "windows")]
|
cmd.hide_window();
|
||||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
};
|
};
|
||||||
@@ -396,7 +404,10 @@ impl WslBridge {
|
|||||||
let stderr = child.stderr.take();
|
let stderr = child.stderr.take();
|
||||||
|
|
||||||
self.stdin = stdin;
|
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
|
// 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()
|
||||||
@@ -413,8 +424,9 @@ 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();
|
||||||
thread::spawn(move || {
|
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(
|
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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,7 +541,10 @@ 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
|
||||||
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
|
// Estimate cost for interrupted request before killing
|
||||||
self.estimate_interrupted_request_cost(app);
|
self.estimate_interrupted_request_cost(app);
|
||||||
|
|
||||||
@@ -640,7 +674,7 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&mut self, app: &AppHandle) {
|
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.kill();
|
||||||
let _ = process.wait();
|
let _ = process.wait();
|
||||||
}
|
}
|
||||||
@@ -671,7 +705,7 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_running(&self) -> bool {
|
pub fn is_running(&self) -> bool {
|
||||||
self.process.is_some()
|
self.process.lock().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_working_directory(&self) -> &str {
|
pub fn get_working_directory(&self) -> &str {
|
||||||
@@ -694,13 +728,16 @@ 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>,
|
||||||
) {
|
) {
|
||||||
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) = 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);
|
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);
|
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,6 +969,7 @@ fn process_json_line(
|
|||||||
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))?;
|
||||||
@@ -928,6 +982,9 @@ 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",
|
||||||
@@ -2059,7 +2116,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").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
|
// Wait for it to exit
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
@@ -2078,7 +2135,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").spawn().expect("Failed to spawn 'true'");
|
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
|
||||||
|
|
||||||
// Let it exit
|
// Let it exit
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
@@ -2355,4 +2412,29 @@ mod tests {
|
|||||||
let content = serde_json::json!([]);
|
let content = serde_json::json!([]);
|
||||||
assert_eq!(extract_tool_result_text(&content), None);
|
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 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
|
||||||
@@ -36,6 +38,7 @@ $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")
|
||||||
@@ -65,6 +68,7 @@ $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,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.7.0",
|
"version": "1.8.0",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -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.50";
|
const SUPPORTED_CLI_VERSION = "2.1.53";
|
||||||
|
|
||||||
let installedVersion = $state("Loading...");
|
let installedVersion = $state("Loading...");
|
||||||
|
|
||||||
|
|||||||
@@ -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 } from "$lib/stores/config";
|
import { isStreamerMode, configStore } 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,6 +14,9 @@
|
|||||||
|
|
||||||
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);
|
||||||
@@ -150,7 +153,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="compact-container {getStateGlow()}">
|
<div
|
||||||
|
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()}">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
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,6 +56,9 @@
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
@@ -62,6 +66,7 @@
|
|||||||
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;
|
||||||
@@ -240,6 +245,20 @@
|
|||||||
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 -->
|
||||||
@@ -319,6 +338,14 @@
|
|||||||
<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>
|
||||||
@@ -905,6 +932,52 @@
|
|||||||
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 -->
|
||||||
|
|||||||
@@ -12,6 +12,25 @@
|
|||||||
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>();
|
||||||
|
|
||||||
@@ -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
|
// 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") {
|
||||||
@@ -165,21 +249,19 @@
|
|||||||
// 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 tabs = Array.from($conversations.keys());
|
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
||||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
|
||||||
if (currentIndex !== -1) {
|
if (currentIndex !== -1) {
|
||||||
const nextIndex = (currentIndex + 1) % tabs.length;
|
const nextIndex = (currentIndex + 1) % tabOrder.length;
|
||||||
claudeStore.switchConversation(tabs[nextIndex]);
|
claudeStore.switchConversation(tabOrder[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 tabs = Array.from($conversations.keys());
|
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
||||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
|
||||||
if (currentIndex !== -1) {
|
if (currentIndex !== -1) {
|
||||||
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
|
||||||
claudeStore.switchConversation(tabs[prevIndex]);
|
claudeStore.switchConversation(tabOrder[prevIndex]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,15 +272,22 @@
|
|||||||
</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 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
|
<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
|
{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'}
|
||||||
onclick={() => switchTab(id)}
|
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
|
||||||
|
{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}
|
||||||
@@ -211,7 +300,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"
|
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32 select-text"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -296,5 +385,20 @@
|
|||||||
|
|
||||||
.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>
|
||||||
|
|||||||
@@ -35,7 +35,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderer.codespan = ({ text }) => {
|
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({
|
marked.setOptions({
|
||||||
|
|||||||
@@ -190,10 +190,13 @@
|
|||||||
<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 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
|
>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"
|
||||||
@@ -201,10 +204,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
>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)]"
|
||||||
>
|
>
|
||||||
@@ -214,10 +220,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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"}
|
{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"
|
||||||
@@ -266,6 +276,7 @@
|
|||||||
{: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"
|
||||||
@@ -274,10 +285,7 @@
|
|||||||
<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">
|
||||||
<svelte:component
|
<TransportIcon class="w-4 h-4 {getTransportColor(server.transport)}" />
|
||||||
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")}
|
||||||
@@ -323,25 +331,19 @@
|
|||||||
<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>
|
||||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
|
||||||
>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>
|
||||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
|
||||||
>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">
|
||||||
<svelte:component
|
<TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
|
||||||
this={getTransportIcon(selectedServer.transport)}
|
|
||||||
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
|
|
||||||
/>
|
|
||||||
{selectedServer.transport.toUpperCase()}
|
{selectedServer.transport.toUpperCase()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,9 +351,7 @@
|
|||||||
<!-- URL or Command -->
|
<!-- URL or Command -->
|
||||||
{#if selectedServer.url}
|
{#if selectedServer.url}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</p>
|
||||||
>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,9 +362,7 @@
|
|||||||
|
|
||||||
{#if selectedServer.command}
|
{#if selectedServer.command}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</p>
|
||||||
>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)]"
|
||||||
>
|
>
|
||||||
@@ -376,9 +374,9 @@
|
|||||||
<!-- Environment Variables -->
|
<!-- Environment Variables -->
|
||||||
{#if selectedServer.env}
|
{#if selectedServer.env}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
||||||
>Environment</label
|
Environment
|
||||||
>
|
</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,
|
||||||
@@ -391,9 +389,9 @@
|
|||||||
<!-- Full Server Details -->
|
<!-- Full Server Details -->
|
||||||
{#if serverDetails}
|
{#if serverDetails}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
||||||
>Full Details</label
|
Full Details
|
||||||
>
|
</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>
|
||||||
@@ -416,18 +414,3 @@
|
|||||||
{/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>
|
|
||||||
|
|||||||
@@ -430,18 +430,3 @@
|
|||||||
{/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,6 +471,7 @@
|
|||||||
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()}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
} 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";
|
||||||
@@ -61,6 +63,8 @@
|
|||||||
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({
|
||||||
@@ -103,6 +107,9 @@
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
@@ -156,11 +163,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConnect() {
|
async function doConnect(targetDir: string) {
|
||||||
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]),
|
||||||
@@ -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() {
|
async function handleDisconnect() {
|
||||||
try {
|
try {
|
||||||
const conversationId = get(claudeStore.activeConversationId);
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
@@ -771,6 +820,14 @@
|
|||||||
<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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
bind:this={menuElement}
|
bind:this={menuElement}
|
||||||
class="menu-content"
|
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: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}>
|
<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()}>
|
<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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
bind:this={menuElement}
|
bind:this={menuElement}
|
||||||
class="menu-content"
|
class="menu-content"
|
||||||
|
|||||||
@@ -50,9 +50,9 @@
|
|||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<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}>
|
<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()}>
|
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
|
||||||
<h2 class="dialog-title">{title}</h2>
|
<h2 class="dialog-title">{title}</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ describe("config store", () => {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: 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");
|
||||||
@@ -244,6 +247,9 @@ describe("config store", () => {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
@@ -791,6 +797,9 @@ describe("config store", () => {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: 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);
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export interface HikariConfig {
|
|||||||
use_worktree: boolean;
|
use_worktree: boolean;
|
||||||
// Disable 1M context window
|
// Disable 1M context window
|
||||||
disable_1m_context: boolean;
|
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 = {
|
||||||
@@ -93,6 +98,9 @@ const defaultConfig: HikariConfig = {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ 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;
|
||||||
|
|||||||
+58
-3
@@ -12,6 +12,7 @@
|
|||||||
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";
|
||||||
@@ -37,6 +38,45 @@
|
|||||||
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);
|
||||||
@@ -473,16 +513,27 @@
|
|||||||
});
|
});
|
||||||
</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 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
|
<StatusBar
|
||||||
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
|
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
|
||||||
onToggleCompact={enterCompactMode}
|
onToggleCompact={enterCompactMode}
|
||||||
@@ -491,8 +542,12 @@
|
|||||||
<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 bg-[var(--bg-secondary)]/50"
|
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center {backgroundDataUrl
|
||||||
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>
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ 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",
|
||||||
|
|||||||
+15
-1
@@ -1,13 +1,27 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig, createLogger } 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`
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user