generated from nhcarrigan/template
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
97b8243d24
|
|||
| 7ebd9dc97a | |||
|
fe7027c585
|
|||
| 89a0bdd8f1 | |||
|
2e3f203508
|
|||
| b745100bd5 |
+63
-63
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.7.0",
|
"version": "1.9.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.9.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();
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DRAFTS_STORE_FILE: &str = "hikari-drafts.json";
|
||||||
|
const DRAFTS_STORE_KEY: &str = "drafts";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Draft {
|
||||||
|
pub id: String,
|
||||||
|
pub content: String,
|
||||||
|
pub saved_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_all_drafts(app: &AppHandle) -> Result<Vec<Draft>, String> {
|
||||||
|
let store = app
|
||||||
|
.store(DRAFTS_STORE_FILE)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
match store.get(DRAFTS_STORE_KEY) {
|
||||||
|
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||||
|
None => Ok(vec![]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_all_drafts(app: &AppHandle, drafts: &[Draft]) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store(DRAFTS_STORE_FILE)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let value = serde_json::to_value(drafts).map_err(|e| e.to_string())?;
|
||||||
|
store.set(DRAFTS_STORE_KEY, value);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_drafts(app: AppHandle) -> Result<Vec<Draft>, String> {
|
||||||
|
let mut drafts = load_all_drafts(&app)?;
|
||||||
|
// Sort newest first — ISO 8601 timestamps sort lexicographically
|
||||||
|
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||||
|
Ok(drafts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_draft(app: AppHandle, content: String) -> Result<Draft, String> {
|
||||||
|
let mut drafts = load_all_drafts(&app)?;
|
||||||
|
|
||||||
|
let draft = Draft {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
content,
|
||||||
|
saved_at: Utc::now().to_rfc3339(),
|
||||||
|
};
|
||||||
|
|
||||||
|
drafts.push(draft.clone());
|
||||||
|
save_all_drafts(&app, &drafts)?;
|
||||||
|
|
||||||
|
Ok(draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_draft(app: AppHandle, draft_id: String) -> Result<(), String> {
|
||||||
|
let mut drafts = load_all_drafts(&app)?;
|
||||||
|
drafts.retain(|d| d.id != draft_id);
|
||||||
|
save_all_drafts(&app, &drafts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_all_drafts(app: AppHandle) -> Result<(), String> {
|
||||||
|
save_all_drafts(&app, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_draft(id: &str, content: &str, saved_at: &str) -> Draft {
|
||||||
|
Draft {
|
||||||
|
id: id.to_string(),
|
||||||
|
content: content.to_string(),
|
||||||
|
saved_at: saved_at.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_draft_serialization() {
|
||||||
|
let draft = make_draft("test-id", "Hello world", "2026-01-01T00:00:00+00:00");
|
||||||
|
let json = serde_json::to_string(&draft).expect("Failed to serialize");
|
||||||
|
let parsed: Draft = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||||
|
|
||||||
|
assert_eq!(parsed.id, draft.id);
|
||||||
|
assert_eq!(parsed.content, draft.content);
|
||||||
|
assert_eq!(parsed.saved_at, draft.saved_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_draft_clone() {
|
||||||
|
let original = make_draft("clone-id", "Clone me", "2026-01-01T00:00:00+00:00");
|
||||||
|
let cloned = original.clone();
|
||||||
|
|
||||||
|
assert_eq!(original.id, cloned.id);
|
||||||
|
assert_eq!(original.content, cloned.content);
|
||||||
|
assert_eq!(original.saved_at, cloned.saved_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sort_newest_first() {
|
||||||
|
let mut drafts = [
|
||||||
|
make_draft("a", "First", "2026-01-01T00:00:00+00:00"),
|
||||||
|
make_draft("b", "Third", "2026-01-03T00:00:00+00:00"),
|
||||||
|
make_draft("c", "Second", "2026-01-02T00:00:00+00:00"),
|
||||||
|
];
|
||||||
|
|
||||||
|
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||||
|
|
||||||
|
assert_eq!(drafts[0].id, "b");
|
||||||
|
assert_eq!(drafts[1].id, "c");
|
||||||
|
assert_eq!(drafts[2].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retain_excludes_deleted() {
|
||||||
|
let mut drafts = vec![
|
||||||
|
make_draft("keep-1", "Keep me", "2026-01-01T00:00:00+00:00"),
|
||||||
|
make_draft("delete-me", "Delete me", "2026-01-02T00:00:00+00:00"),
|
||||||
|
make_draft("keep-2", "Keep me too", "2026-01-03T00:00:00+00:00"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let target_id = "delete-me".to_string();
|
||||||
|
drafts.retain(|d| d.id != target_id);
|
||||||
|
|
||||||
|
assert_eq!(drafts.len(), 2);
|
||||||
|
assert!(drafts.iter().all(|d| d.id != "delete-me"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_by_id() {
|
||||||
|
let drafts = [
|
||||||
|
make_draft("draft-1", "First draft", "2026-01-01T00:00:00+00:00"),
|
||||||
|
make_draft("draft-2", "Second draft", "2026-01-02T00:00:00+00:00"),
|
||||||
|
make_draft("draft-3", "Third draft", "2026-01-03T00:00:00+00:00"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let found = drafts.iter().find(|d| d.id == "draft-2");
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().content, "Second draft");
|
||||||
|
|
||||||
|
let not_found = drafts.iter().find(|d| d.id == "draft-999");
|
||||||
|
assert!(not_found.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiline_content() {
|
||||||
|
let content = "Line 1\nLine 2\nLine 3";
|
||||||
|
let draft = make_draft("multi", content, "2026-01-01T00:00:00+00:00");
|
||||||
|
|
||||||
|
assert!(draft.content.contains('\n'));
|
||||||
|
assert_eq!(draft.content.split('\n').count(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_after_delete_all() {
|
||||||
|
let mut drafts = vec![
|
||||||
|
make_draft("a", "A", "2026-01-01T00:00:00+00:00"),
|
||||||
|
make_draft("b", "B", "2026-01-02T00:00:00+00:00"),
|
||||||
|
];
|
||||||
|
|
||||||
|
drafts.clear();
|
||||||
|
|
||||||
|
assert!(drafts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_uuid_format() {
|
||||||
|
// UUIDs should be non-empty and contain hyphens
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
assert!(!id.is_empty());
|
||||||
|
assert!(id.contains('-'));
|
||||||
|
assert_eq!(id.len(), 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timestamp_is_rfc3339() {
|
||||||
|
let ts = Utc::now().to_rfc3339();
|
||||||
|
// RFC 3339 timestamps contain T and + or Z
|
||||||
|
assert!(ts.contains('T'));
|
||||||
|
assert!(ts.ends_with("+00:00") || ts.ends_with('Z'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ mod config;
|
|||||||
mod cost_tracking;
|
mod cost_tracking;
|
||||||
mod debug_logger;
|
mod debug_logger;
|
||||||
mod discord_rpc;
|
mod discord_rpc;
|
||||||
|
mod drafts;
|
||||||
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;
|
||||||
@@ -27,6 +29,7 @@ use commands::load_saved_achievements;
|
|||||||
use commands::*;
|
use commands::*;
|
||||||
use debug_logger::TauriLogLayer;
|
use debug_logger::TauriLogLayer;
|
||||||
use discord_rpc::DiscordRpcManager;
|
use discord_rpc::DiscordRpcManager;
|
||||||
|
use drafts::*;
|
||||||
use git::*;
|
use git::*;
|
||||||
use notifications::*;
|
use notifications::*;
|
||||||
use quick_actions::*;
|
use quick_actions::*;
|
||||||
@@ -120,6 +123,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,
|
||||||
@@ -212,6 +216,10 @@ pub fn run() {
|
|||||||
remove_mcp_server,
|
remove_mcp_server,
|
||||||
add_mcp_server,
|
add_mcp_server,
|
||||||
get_mcp_server_details,
|
get_mcp_server_details,
|
||||||
|
list_drafts,
|
||||||
|
save_draft,
|
||||||
|
delete_draft,
|
||||||
|
delete_all_drafts,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+154
-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,63 @@ 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>,
|
||||||
|
/// Set to true by stop()/interrupt() before killing the process so handle_stdout knows
|
||||||
|
/// the disconnect was intentional and should not emit a second Disconnected event.
|
||||||
|
intentional_stop: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WslBridge {
|
impl WslBridge {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
WslBridge {
|
WslBridge {
|
||||||
process: 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)),
|
||||||
|
intentional_stop: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_with_conversation_id(conversation_id: String) -> Self {
|
pub fn new_with_conversation_id(conversation_id: String) -> Self {
|
||||||
WslBridge {
|
WslBridge {
|
||||||
process: 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)),
|
||||||
|
intentional_stop: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
||||||
// If a process handle exists but the process has already exited (e.g. due to a
|
// If a process handle exists but the process has already exited (e.g. due to a
|
||||||
// failed working directory), clean up the stale handle so we can restart cleanly.
|
// failed working directory), clean up the stale handle so we can restart cleanly.
|
||||||
if let Some(ref mut process) = self.process {
|
{
|
||||||
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 +235,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 +303,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 +314,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 +389,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 +409,11 @@ 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 flags so the watchdog and stdout handler start fresh.
|
||||||
|
self.received_init.store(false, Ordering::SeqCst);
|
||||||
|
self.intentional_stop.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
// Note: We no longer reset stats here - stats persist across reconnects
|
// Note: We no longer reset stats here - stats persist across reconnects
|
||||||
// Stats are only reset when explicitly disconnecting via stop()
|
// Stats are only reset when explicitly disconnecting via stop()
|
||||||
@@ -413,8 +430,17 @@ impl WslBridge {
|
|||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let stats_clone = self.stats.clone();
|
let stats_clone = self.stats.clone();
|
||||||
let conv_id = self.conversation_id.clone();
|
let conv_id = self.conversation_id.clone();
|
||||||
|
let received_init_clone = self.received_init.clone();
|
||||||
|
let intentional_stop_clone = self.intentional_stop.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
handle_stdout(stdout, app_clone, stats_clone, conv_id);
|
handle_stdout(
|
||||||
|
stdout,
|
||||||
|
app_clone,
|
||||||
|
stats_clone,
|
||||||
|
conv_id,
|
||||||
|
received_init_clone,
|
||||||
|
intentional_stop_clone,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,12 +452,31 @@ impl WslBridge {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit Connected immediately so the frontend can send the greeting message.
|
||||||
|
// This is intentionally optimistic — Claude Code buffers stdout until stdin receives
|
||||||
|
// data on Windows/WSL, so we must send something to stdin first or system:init never
|
||||||
|
// arrives. The received_init flag below tracks whether init actually arrived.
|
||||||
emit_connection_status(
|
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 +555,15 @@ 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.
|
||||||
|
|
||||||
|
// Signal handle_stdout that this is an intentional stop so it doesn't emit
|
||||||
|
// a second Disconnected event after stdout closes due to the kill.
|
||||||
|
self.intentional_stop.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let maybe_process = self.process.lock().take();
|
||||||
|
if let Some(mut process) = maybe_process {
|
||||||
// Estimate cost for interrupted request before killing
|
// Estimate cost for interrupted request before killing
|
||||||
self.estimate_interrupted_request_cost(app);
|
self.estimate_interrupted_request_cost(app);
|
||||||
|
|
||||||
@@ -640,7 +693,10 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&mut self, app: &AppHandle) {
|
pub fn stop(&mut self, app: &AppHandle) {
|
||||||
if let Some(mut process) = self.process.take() {
|
// Signal handle_stdout that this is an intentional stop so it doesn't emit
|
||||||
|
// a second Disconnected event after stdout closes due to the kill.
|
||||||
|
self.intentional_stop.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(mut process) = self.process.lock().take() {
|
||||||
let _ = process.kill();
|
let _ = process.kill();
|
||||||
let _ = process.wait();
|
let _ = process.wait();
|
||||||
}
|
}
|
||||||
@@ -671,7 +727,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 +750,17 @@ fn handle_stdout(
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
stats: Arc<RwLock<UsageStats>>,
|
stats: Arc<RwLock<UsageStats>>,
|
||||||
conversation_id: Option<String>,
|
conversation_id: Option<String>,
|
||||||
|
received_init: Arc<AtomicBool>,
|
||||||
|
intentional_stop: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
let reader = BufReader::new(stdout);
|
let reader = BufReader::new(stdout);
|
||||||
|
|
||||||
for line in reader.lines() {
|
for line in reader.lines() {
|
||||||
match line {
|
match line {
|
||||||
Ok(line) if !line.is_empty() => {
|
Ok(line) if !line.is_empty() => {
|
||||||
if let Err(e) = 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 +772,45 @@ fn handle_stdout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this was an intentional stop (stop()/interrupt() was called), the caller already
|
||||||
|
// emitted a Disconnected event. Skip all post-loop emissions to prevent duplicates.
|
||||||
|
if intentional_stop.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If stdout closed before system:init arrived the process exited without initialising.
|
||||||
|
// Emit an error line so the user understands why the connection failed.
|
||||||
|
if !received_init.load(Ordering::SeqCst) {
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:output",
|
||||||
|
OutputEvent {
|
||||||
|
line_type: "error".to_string(),
|
||||||
|
content: "Claude Code exited before initialising. Check the working directory and Claude Code installation, then try connecting again.".to_string(),
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If Claude exited while a prompt was in-flight, the user's message was never processed.
|
||||||
|
// Emit a specific error so they know to resend their prompt.
|
||||||
|
let had_pending_request = stats.read().current_request_input.is_some();
|
||||||
|
if had_pending_request {
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:output",
|
||||||
|
OutputEvent {
|
||||||
|
line_type: "error".to_string(),
|
||||||
|
content: "Claude Code exited before finishing your request — your last prompt was not processed. Please reconnect and try again.".to_string(),
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,6 +1015,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 +1028,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 +2162,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 +2181,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 +2458,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.9.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);
|
||||||
@@ -132,7 +135,7 @@
|
|||||||
setSkipNextGreeting(true);
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
await invoke("interrupt_claude", { conversationId });
|
await invoke("interrupt_claude", { conversationId });
|
||||||
claudeStore.addLine("system", "Interrupted");
|
claudeStore.addLine("system", "Process interrupted via stop button");
|
||||||
characterState.setState("idle");
|
characterState.setState("idle");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to interrupt:", error);
|
console.error("Failed to interrupt:", error);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { draftsStore, type Draft } from "$lib/stores/drafts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onInsert: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose, onInsert }: Props = $props();
|
||||||
|
|
||||||
|
let confirmingDeleteId = $state<string | null>(null);
|
||||||
|
let confirmingAll = $state(false);
|
||||||
|
|
||||||
|
const drafts = $derived(draftsStore.drafts);
|
||||||
|
const isLoading = $derived(draftsStore.isLoading);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
draftsStore.loadDrafts();
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInsert(draft: Draft): void {
|
||||||
|
onInsert(draft.content);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(draftId: string): Promise<void> {
|
||||||
|
await draftsStore.deleteDraft(draftId);
|
||||||
|
confirmingDeleteId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAll(): Promise<void> {
|
||||||
|
await draftsStore.deleteAllDrafts();
|
||||||
|
confirmingAll = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateContent(content: string): string {
|
||||||
|
return content.length > 120 ? content.slice(0, 120) + "…" : content;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
|
onclick={onClose}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="draft-panel-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||||
|
<h2 id="draft-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
|
Saved Drafts
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if $drafts.length > 0}
|
||||||
|
{#if confirmingAll}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={handleDeleteAll}
|
||||||
|
class="px-3 py-1.5 text-sm font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
Confirm Delete All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmingAll = false)}
|
||||||
|
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmingAll = true)}
|
||||||
|
class="px-3 py-1.5 text-sm font-medium text-red-400 hover:text-red-300 transition-colors border border-red-400/30 rounded-lg hover:border-red-300/50 hover:bg-red-400/10"
|
||||||
|
>
|
||||||
|
Delete All
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
{#if $isLoading}
|
||||||
|
<div class="flex items-center justify-center p-8">
|
||||||
|
<div class="text-[var(--text-tertiary)]">Loading drafts...</div>
|
||||||
|
</div>
|
||||||
|
{:else if $drafts.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<svg
|
||||||
|
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-[var(--text-secondary)]">No saved drafts yet</p>
|
||||||
|
<p class="text-sm text-[var(--text-tertiary)] mt-1">
|
||||||
|
Use "Save as Draft" to store messages for later
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="divide-y divide-[var(--border-color)]">
|
||||||
|
{#each $drafts as draft (draft.id)}
|
||||||
|
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mb-1">
|
||||||
|
{draftsStore.formatTimestamp(draft.saved_at)}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-sm text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words"
|
||||||
|
>
|
||||||
|
{truncateContent(draft.content)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={() => handleInsert(draft)}
|
||||||
|
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
|
||||||
|
title="Insert this draft"
|
||||||
|
>
|
||||||
|
Insert
|
||||||
|
</button>
|
||||||
|
{#if confirmingDeleteId === draft.id}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(draft.id)}
|
||||||
|
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmingDeleteId = null)}
|
||||||
|
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (confirmingDeleteId = draft.id)}
|
||||||
|
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
||||||
|
title="Delete draft"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[role="dialog"] {
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-color) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -34,7 +34,9 @@
|
|||||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||||
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
||||||
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
|
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
|
||||||
|
import DraftPanel from "$lib/components/DraftPanel.svelte";
|
||||||
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
||||||
|
import { draftsStore } from "$lib/stores/drafts";
|
||||||
import type { Attachment } from "$lib/types/messages";
|
import type { Attachment } from "$lib/types/messages";
|
||||||
|
|
||||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
let showSnippetLibrary = $state(false);
|
let showSnippetLibrary = $state(false);
|
||||||
let showQuickActions = $state(false);
|
let showQuickActions = $state(false);
|
||||||
let showClipboardHistory = $state(false);
|
let showClipboardHistory = $state(false);
|
||||||
|
let showDraftPanel = $state(false);
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
|
|
||||||
// Cost estimation for pre-submission display
|
// Cost estimation for pre-submission display
|
||||||
@@ -164,6 +167,25 @@
|
|||||||
attachments = storedAttachments;
|
attachments = storedAttachments;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Per-tab draft persistence — restore the draft text whenever the active
|
||||||
|
// conversation changes, and save it back on every keystroke.
|
||||||
|
claudeStore.activeConversationId.subscribe((conversationId) => {
|
||||||
|
if (conversationId) {
|
||||||
|
const conv = get(claudeStore.conversations).get(conversationId);
|
||||||
|
inputValue = conv?.draftText ?? "";
|
||||||
|
} else {
|
||||||
|
inputValue = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearInput() {
|
||||||
|
inputValue = "";
|
||||||
|
const activeId = get(claudeStore.activeConversationId);
|
||||||
|
if (activeId) {
|
||||||
|
claudeStore.setDraftText(activeId, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleInputChange() {
|
function handleInputChange() {
|
||||||
// If input is empty, allow history navigation again
|
// If input is empty, allow history navigation again
|
||||||
// Otherwise, mark that user has manually typed
|
// Otherwise, mark that user has manually typed
|
||||||
@@ -176,6 +198,12 @@
|
|||||||
historyIndex = -1;
|
historyIndex = -1;
|
||||||
tempInput = "";
|
tempInput = "";
|
||||||
|
|
||||||
|
// Save the current draft so it persists if the user switches tabs.
|
||||||
|
const activeId = get(claudeStore.activeConversationId);
|
||||||
|
if (activeId) {
|
||||||
|
claudeStore.setDraftText(activeId, inputValue);
|
||||||
|
}
|
||||||
|
|
||||||
if (isSlashCommand(inputValue)) {
|
if (isSlashCommand(inputValue)) {
|
||||||
matchingCommands = getMatchingCommands(inputValue);
|
matchingCommands = getMatchingCommands(inputValue);
|
||||||
showCommandMenu = matchingCommands.length > 0;
|
showCommandMenu = matchingCommands.length > 0;
|
||||||
@@ -195,7 +223,7 @@
|
|||||||
async function executeSlashCommand(): Promise<boolean> {
|
async function executeSlashCommand(): Promise<boolean> {
|
||||||
const { command, args } = parseSlashCommand(inputValue);
|
const { command, args } = parseSlashCommand(inputValue);
|
||||||
if (command) {
|
if (command) {
|
||||||
inputValue = "";
|
clearInput();
|
||||||
showCommandMenu = false;
|
showCommandMenu = false;
|
||||||
matchingCommands = [];
|
matchingCommands = [];
|
||||||
await command.execute(args);
|
await command.execute(args);
|
||||||
@@ -228,7 +256,7 @@
|
|||||||
"error",
|
"error",
|
||||||
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
|
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
|
||||||
);
|
);
|
||||||
inputValue = "";
|
clearInput();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +272,7 @@
|
|||||||
userHasTyped = false;
|
userHasTyped = false;
|
||||||
|
|
||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
inputValue = "";
|
clearInput();
|
||||||
|
|
||||||
// Capture attachments before clearing
|
// Capture attachments before clearing
|
||||||
const currentAttachments = [...attachments];
|
const currentAttachments = [...attachments];
|
||||||
@@ -326,7 +354,7 @@ User: ${formattedMessage}`;
|
|||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
await invoke("interrupt_claude", { conversationId });
|
await invoke("interrupt_claude", { conversationId });
|
||||||
claudeStore.addLine("system", "Process interrupted - reconnecting...");
|
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
|
||||||
characterState.setState("idle");
|
characterState.setState("idle");
|
||||||
|
|
||||||
// Show connecting status while we reconnect
|
// Show connecting status while we reconnect
|
||||||
@@ -703,6 +731,22 @@ User: ${formattedMessage}`;
|
|||||||
userHasTyped = true;
|
userHasTyped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDraftInsert(content: string): void {
|
||||||
|
inputValue = content;
|
||||||
|
userHasTyped = true;
|
||||||
|
const activeId = get(claudeStore.activeConversationId);
|
||||||
|
if (activeId) {
|
||||||
|
claudeStore.setDraftText(activeId, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAsDraft(): Promise<void> {
|
||||||
|
const content = inputValue.trim();
|
||||||
|
if (!content) return;
|
||||||
|
await draftsStore.saveDraft(content);
|
||||||
|
clearInput();
|
||||||
|
}
|
||||||
|
|
||||||
function handleClipboardInsert(content: string): void {
|
function handleClipboardInsert(content: string): void {
|
||||||
// Insert clipboard content at cursor position or append to input
|
// Insert clipboard content at cursor position or append to input
|
||||||
if (inputValue.trim()) {
|
if (inputValue.trim()) {
|
||||||
@@ -919,6 +963,29 @@ User: ${formattedMessage}`;
|
|||||||
<span>Clipboard</span>
|
<span>Clipboard</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showDraftPanel = true)}
|
||||||
|
class="control-button"
|
||||||
|
title="Saved Drafts"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Drafts</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<CliVersion />
|
<CliVersion />
|
||||||
<SystemClock />
|
<SystemClock />
|
||||||
</div>
|
</div>
|
||||||
@@ -959,6 +1026,29 @@ User: ${formattedMessage}`;
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleSaveAsDraft}
|
||||||
|
disabled={!inputValue.trim()}
|
||||||
|
class="attach-button"
|
||||||
|
title="Save as Draft"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||||
|
<polyline points="17 21 17 13 7 13 7 21" />
|
||||||
|
<polyline points="7 3 7 8 15 8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
@@ -1024,6 +1114,10 @@ User: ${formattedMessage}`;
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showDraftPanel}
|
||||||
|
<DraftPanel onClose={() => (showDraftPanel = false)} onInsert={handleDraftInsert} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if contextMenuShow && textareaElement}
|
{#if contextMenuShow && textareaElement}
|
||||||
<TextInputContextMenu
|
<TextInputContextMenu
|
||||||
x={contextMenuX}
|
x={contextMenuX}
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -276,10 +281,16 @@
|
|||||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :global(ul),
|
.markdown-content :global(ul) {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-content :global(ol) {
|
.markdown-content :global(ol) {
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
padding-left: 1.5em;
|
padding-left: 1.5em;
|
||||||
|
list-style-type: decimal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :global(li) {
|
.markdown-content :global(li) {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
import { configStore } from "$lib/stores/config";
|
import { configStore } from "$lib/stores/config";
|
||||||
|
|
||||||
let permissions: PermissionRequest[] = $state([]);
|
let permissions: PermissionRequest[] = [];
|
||||||
let selectedPermissions = new SvelteSet<string>();
|
let selectedPermissions = new SvelteSet<string>();
|
||||||
let grantedToolsList: string[] = $state([]);
|
let grantedToolsList: string[] = [];
|
||||||
let workingDirectory = $state("");
|
let workingDirectory = "";
|
||||||
|
|
||||||
conversationsStore.pendingPermissions.subscribe((perms) => {
|
conversationsStore.pendingPermissions.subscribe((perms) => {
|
||||||
permissions = perms;
|
permissions = perms;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,8 @@
|
|||||||
import { characterState } from "$lib/stores/character";
|
|
||||||
import { notificationManager } from "./notificationManager";
|
import { notificationManager } from "./notificationManager";
|
||||||
import type { CharacterState } from "$lib/types/states";
|
|
||||||
import type { ConnectionStatus } from "$lib/types/messages";
|
import type { ConnectionStatus } from "$lib/types/messages";
|
||||||
|
|
||||||
// Track previous states to detect transitions
|
// Track previous connection status to detect transitions
|
||||||
let previousCharacterState: CharacterState | null = null;
|
|
||||||
let previousConnectionStatus: ConnectionStatus | null = null;
|
let previousConnectionStatus: ConnectionStatus | null = null;
|
||||||
let taskStartTime: number | null = null;
|
|
||||||
let hasNotifiedTaskStart = false;
|
|
||||||
|
|
||||||
export function handleCharacterStateChange(newState: CharacterState): void {
|
|
||||||
// Detect state transitions
|
|
||||||
if (previousCharacterState === newState) return;
|
|
||||||
|
|
||||||
// Task completion: any state -> success
|
|
||||||
if (newState === "success" && previousCharacterState !== null) {
|
|
||||||
const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0;
|
|
||||||
// Only notify for tasks that took more than 2 seconds
|
|
||||||
if (taskDuration > 2000) {
|
|
||||||
notificationManager.notifySuccess();
|
|
||||||
}
|
|
||||||
taskStartTime = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error occurred
|
|
||||||
if (newState === "error" && previousCharacterState !== "error") {
|
|
||||||
notificationManager.notifyError();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permission needed
|
|
||||||
if (newState === "permission") {
|
|
||||||
notificationManager.notifyPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starting long tasks - only notify once per response
|
|
||||||
if (
|
|
||||||
(newState === "coding" || newState === "searching") &&
|
|
||||||
previousCharacterState !== "coding" &&
|
|
||||||
previousCharacterState !== "searching" &&
|
|
||||||
!hasNotifiedTaskStart
|
|
||||||
) {
|
|
||||||
taskStartTime = Date.now();
|
|
||||||
hasNotifiedTaskStart = true;
|
|
||||||
notificationManager.notifyTaskStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
previousCharacterState = newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
|
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
|
||||||
// Only notify on successful connection after being disconnected
|
// Only notify on successful connection after being disconnected
|
||||||
@@ -67,37 +23,13 @@ export function handleToolExecution(_toolName: string): void {
|
|||||||
// But we could add specific rules here if needed
|
// But we could add specific rules here if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset notification state for a new response
|
// No-op: sound tracking is now per-conversation in tauri.ts
|
||||||
export function handleNewUserMessage(): void {
|
export function handleNewUserMessage(): void {}
|
||||||
hasNotifiedTaskStart = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store unsubscribe functions
|
// No-op: all per-conversation sounds are driven by tauri.ts event listeners
|
||||||
let unsubscribeCharacterState: (() => void) | null = null;
|
export function initializeNotificationRules(): void {}
|
||||||
|
|
||||||
// Initialize listeners
|
// Cleanup — reset connection tracking on teardown
|
||||||
export function initializeNotificationRules(): void {
|
|
||||||
// Clean up any existing subscriptions first
|
|
||||||
cleanupNotificationRules();
|
|
||||||
|
|
||||||
// Subscribe to character state changes
|
|
||||||
unsubscribeCharacterState = characterState.subscribe((state) => {
|
|
||||||
handleCharacterStateChange(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
// We'll connect to connection status in the next step
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup function to prevent duplicate listeners
|
|
||||||
export function cleanupNotificationRules(): void {
|
export function cleanupNotificationRules(): void {
|
||||||
if (unsubscribeCharacterState) {
|
|
||||||
unsubscribeCharacterState();
|
|
||||||
unsubscribeCharacterState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset state tracking
|
|
||||||
previousCharacterState = null;
|
|
||||||
previousConnectionStatus = null;
|
previousConnectionStatus = null;
|
||||||
taskStartTime = null;
|
|
||||||
hasNotifiedTaskStart = false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export const claudeStore = {
|
|||||||
isToolGranted: conversationsStore.isToolGranted,
|
isToolGranted: conversationsStore.isToolGranted,
|
||||||
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
||||||
|
|
||||||
|
// Sound tracking
|
||||||
|
resetSoundState: conversationsStore.resetSoundState,
|
||||||
|
setTaskStartTime: conversationsStore.setTaskStartTime,
|
||||||
|
markSuccessSoundFired: conversationsStore.markSuccessSoundFired,
|
||||||
|
markTaskStartSoundFired: conversationsStore.markTaskStartSoundFired,
|
||||||
|
|
||||||
|
// Draft text (per-tab input persistence)
|
||||||
|
setDraftText: conversationsStore.setDraftText,
|
||||||
|
|
||||||
// Conversation management
|
// Conversation management
|
||||||
createConversation: conversationsStore.createConversation,
|
createConversation: conversationsStore.createConversation,
|
||||||
deleteConversation: conversationsStore.deleteConversation,
|
deleteConversation: conversationsStore.deleteConversation,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -523,3 +523,41 @@ describe("pending retry message", () => {
|
|||||||
expect(pendingRetryMessage).toBeNull();
|
expect(pendingRetryMessage).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("draft text persistence", () => {
|
||||||
|
it("initialises draft text as empty string", () => {
|
||||||
|
const conversation = { draftText: "" };
|
||||||
|
expect(conversation.draftText).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores draft text per conversation", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { draftText: "Hello world" }],
|
||||||
|
["conv-2", { draftText: "" }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.draftText).toBe("Hello world");
|
||||||
|
expect(conversations.get("conv-2")?.draftText).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates draft text independently per conversation", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { draftText: "Draft A" }],
|
||||||
|
["conv-2", { draftText: "Draft B" }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const convA = conversations.get("conv-1");
|
||||||
|
if (convA) convA.draftText = "Updated A";
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.draftText).toBe("Updated A");
|
||||||
|
expect(conversations.get("conv-2")?.draftText).toBe("Draft B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears draft text after submission", () => {
|
||||||
|
const conversation = { draftText: "My prompt" };
|
||||||
|
|
||||||
|
conversation.draftText = "";
|
||||||
|
|
||||||
|
expect(conversation.draftText).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export interface Conversation {
|
|||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
summary: ConversationSummary | null;
|
summary: ConversationSummary | null;
|
||||||
startedAt: Date;
|
startedAt: Date;
|
||||||
|
taskStartTime: number | null;
|
||||||
|
successSoundFired: boolean;
|
||||||
|
taskStartSoundFired: boolean;
|
||||||
|
draftText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConversationsStore() {
|
function createConversationsStore() {
|
||||||
@@ -75,6 +79,10 @@ function createConversationsStore() {
|
|||||||
attachments: [],
|
attachments: [],
|
||||||
summary: null,
|
summary: null,
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
|
taskStartTime: null,
|
||||||
|
successSoundFired: false,
|
||||||
|
taskStartSoundFired: false,
|
||||||
|
draftText: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +204,7 @@ function createConversationsStore() {
|
|||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
const conv = convs.get(activeId);
|
const conv = convs.get(activeId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
conv.pendingPermissions.push(request);
|
conv.pendingPermissions = [...conv.pendingPermissions, request];
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
@@ -219,7 +227,7 @@ function createConversationsStore() {
|
|||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
const conv = convs.get(conversationId);
|
const conv = convs.get(conversationId);
|
||||||
if (conv) {
|
if (conv) {
|
||||||
conv.pendingPermissions.push(request);
|
conv.pendingPermissions = [...conv.pendingPermissions, request];
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
@@ -364,9 +372,15 @@ function createConversationsStore() {
|
|||||||
if (currentId !== id) {
|
if (currentId !== id) {
|
||||||
activeConversationId.set(id);
|
activeConversationId.set(id);
|
||||||
|
|
||||||
// Update the global character state to match the conversation's state
|
// Update the global character state to match the conversation's state.
|
||||||
|
// Map success/error → idle since those are transient states that have
|
||||||
|
// already been displayed — restoring them would re-trigger sound rules.
|
||||||
if (targetConv) {
|
if (targetConv) {
|
||||||
characterState.setState(targetConv.characterState);
|
const stateToRestore =
|
||||||
|
targetConv.characterState === "success" || targetConv.characterState === "error"
|
||||||
|
? "idle"
|
||||||
|
: targetConv.characterState;
|
||||||
|
characterState.setState(stateToRestore);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -816,6 +830,59 @@ function createConversationsStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Sound tracking methods
|
||||||
|
resetSoundState: (conversationId: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.taskStartTime = null;
|
||||||
|
conv.successSoundFired = false;
|
||||||
|
conv.taskStartSoundFired = false;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setTaskStartTime: (conversationId: string, time: number | null) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.taskStartTime = time;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markSuccessSoundFired: (conversationId: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.successSoundFired = true;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markTaskStartSoundFired: (conversationId: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.taskStartSoundFired = true;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setDraftText: (conversationId: string, text: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.draftText = text;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Add initialization helper
|
// Add initialization helper
|
||||||
initialize: () => {
|
initialize: () => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
import { draftsStore, type Draft } from "./drafts";
|
||||||
|
|
||||||
|
const makeDraft = (id: string, content: string, saved_at: string): Draft => ({
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
saved_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Draft interface", () => {
|
||||||
|
it("defines all required fields", () => {
|
||||||
|
const draft: Draft = {
|
||||||
|
id: "draft-123",
|
||||||
|
content: "Hello world",
|
||||||
|
saved_at: "2026-01-01T00:00:00+00:00",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(draft.id).toBe("draft-123");
|
||||||
|
expect(draft.content).toBe("Hello world");
|
||||||
|
expect(draft.saved_at).toBe("2026-01-01T00:00:00+00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports multiline content", () => {
|
||||||
|
const draft: Draft = {
|
||||||
|
id: "multi",
|
||||||
|
content: "Line 1\nLine 2\nLine 3",
|
||||||
|
saved_at: "2026-01-01T00:00:00+00:00",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(draft.content.includes("\n")).toBe(true);
|
||||||
|
expect(draft.content.split("\n")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("draftsStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("store structure", () => {
|
||||||
|
it("has all expected methods", () => {
|
||||||
|
expect(typeof draftsStore.loadDrafts).toBe("function");
|
||||||
|
expect(typeof draftsStore.saveDraft).toBe("function");
|
||||||
|
expect(typeof draftsStore.deleteDraft).toBe("function");
|
||||||
|
expect(typeof draftsStore.deleteAllDrafts).toBe("function");
|
||||||
|
expect(typeof draftsStore.formatTimestamp).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has subscribable stores", () => {
|
||||||
|
expect(typeof draftsStore.drafts.subscribe).toBe("function");
|
||||||
|
expect(typeof draftsStore.isLoading.subscribe).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadDrafts", () => {
|
||||||
|
it("loads drafts from backend", async () => {
|
||||||
|
const mockDrafts: Draft[] = [
|
||||||
|
makeDraft("draft-1", "Hello world", "2026-01-01T00:00:00+00:00"),
|
||||||
|
];
|
||||||
|
|
||||||
|
setMockInvokeResult("list_drafts", mockDrafts);
|
||||||
|
|
||||||
|
await draftsStore.loadDrafts();
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("list_drafts");
|
||||||
|
expect(get(draftsStore.drafts)).toEqual(mockDrafts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles load errors gracefully", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("list_drafts", new Error("Failed to load"));
|
||||||
|
|
||||||
|
await draftsStore.loadDrafts();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to load drafts:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoading during load", async () => {
|
||||||
|
const loadingStates: boolean[] = [];
|
||||||
|
const unsubscribe = draftsStore.isLoading.subscribe((val) => loadingStates.push(val));
|
||||||
|
|
||||||
|
setMockInvokeResult("list_drafts", []);
|
||||||
|
await draftsStore.loadDrafts();
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
expect(loadingStates).toContain(true);
|
||||||
|
expect(loadingStates.at(-1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveDraft", () => {
|
||||||
|
it("saves draft and reloads list", async () => {
|
||||||
|
const mockDraft = makeDraft("new-id", "My draft content", "2026-01-01T00:00:00+00:00");
|
||||||
|
|
||||||
|
setMockInvokeResult("save_draft", mockDraft);
|
||||||
|
setMockInvokeResult("list_drafts", [mockDraft]);
|
||||||
|
|
||||||
|
const result = await draftsStore.saveDraft("My draft content");
|
||||||
|
|
||||||
|
expect(result).toEqual(mockDraft);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("save_draft", { content: "My draft content" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on error", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("save_draft", new Error("Save failed"));
|
||||||
|
|
||||||
|
const result = await draftsStore.saveDraft("content");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to save draft:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteDraft", () => {
|
||||||
|
it("deletes draft by ID and reloads", async () => {
|
||||||
|
setMockInvokeResult("delete_draft", undefined);
|
||||||
|
setMockInvokeResult("list_drafts", []);
|
||||||
|
|
||||||
|
const result = await draftsStore.deleteDraft("draft-123");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("delete_draft", { draftId: "draft-123" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles delete errors gracefully", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("delete_draft", new Error("Delete failed"));
|
||||||
|
|
||||||
|
const result = await draftsStore.deleteDraft("draft-123");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete draft:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteAllDrafts", () => {
|
||||||
|
it("deletes all drafts and clears store", async () => {
|
||||||
|
// First populate the store
|
||||||
|
setMockInvokeResult("list_drafts", [makeDraft("d1", "Draft 1", "2026-01-01T00:00:00+00:00")]);
|
||||||
|
await draftsStore.loadDrafts();
|
||||||
|
expect(get(draftsStore.drafts)).toHaveLength(1);
|
||||||
|
|
||||||
|
setMockInvokeResult("delete_all_drafts", undefined);
|
||||||
|
const result = await draftsStore.deleteAllDrafts();
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("delete_all_drafts");
|
||||||
|
expect(get(draftsStore.drafts)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles delete-all errors gracefully", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("delete_all_drafts", new Error("Delete all failed"));
|
||||||
|
|
||||||
|
const result = await draftsStore.deleteAllDrafts();
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete all drafts:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTimestamp", () => {
|
||||||
|
it("formats a valid ISO timestamp", () => {
|
||||||
|
const result = draftsStore.formatTimestamp("2026-01-15T14:30:00+00:00");
|
||||||
|
// Should produce a human-readable string (locale-dependent)
|
||||||
|
expect(typeof result).toBe("string");
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to raw string on invalid timestamp", () => {
|
||||||
|
const invalid = "not-a-date";
|
||||||
|
const result = draftsStore.formatTimestamp(invalid);
|
||||||
|
expect(result).toBe(invalid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("draft content handling", () => {
|
||||||
|
it("supports content with special characters", () => {
|
||||||
|
const draft = makeDraft(
|
||||||
|
"special",
|
||||||
|
"echo \"Hello\" && echo 'World'",
|
||||||
|
"2026-01-01T00:00:00+00:00"
|
||||||
|
);
|
||||||
|
expect(draft.content).toContain('"');
|
||||||
|
expect(draft.content).toContain("'");
|
||||||
|
expect(draft.content).toContain("&&");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports very long content", () => {
|
||||||
|
const longContent = "a".repeat(1000);
|
||||||
|
const draft = makeDraft("long", longContent, "2026-01-01T00:00:00+00:00");
|
||||||
|
expect(draft.content).toHaveLength(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export interface Draft {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
saved_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDraftsStore() {
|
||||||
|
const drafts = writable<Draft[]>([]);
|
||||||
|
const isLoading = writable(false);
|
||||||
|
|
||||||
|
async function loadDrafts(): Promise<void> {
|
||||||
|
isLoading.set(true);
|
||||||
|
try {
|
||||||
|
const list = await invoke<Draft[]>("list_drafts");
|
||||||
|
drafts.set(list);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load drafts:", error);
|
||||||
|
} finally {
|
||||||
|
isLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft(content: string): Promise<Draft | null> {
|
||||||
|
try {
|
||||||
|
const draft = await invoke<Draft>("save_draft", { content });
|
||||||
|
await loadDrafts();
|
||||||
|
return draft;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save draft:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDraft(draftId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await invoke("delete_draft", { draftId });
|
||||||
|
await loadDrafts();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete draft:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAllDrafts(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await invoke("delete_all_drafts");
|
||||||
|
drafts.set([]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete all drafts:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(isoString: string): string {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
drafts: { subscribe: drafts.subscribe },
|
||||||
|
isLoading: { subscribe: isLoading.subscribe },
|
||||||
|
loadDrafts,
|
||||||
|
saveDraft,
|
||||||
|
deleteDraft,
|
||||||
|
deleteAllDrafts,
|
||||||
|
formatTimestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const draftsStore = createDraftsStore();
|
||||||
+63
-1
@@ -21,6 +21,7 @@ import {
|
|||||||
handleConnectionStatusChange,
|
handleConnectionStatusChange,
|
||||||
handleNewUserMessage,
|
handleNewUserMessage,
|
||||||
} from "$lib/notifications/rules";
|
} from "$lib/notifications/rules";
|
||||||
|
import { notificationManager } from "$lib/notifications/notificationManager";
|
||||||
|
|
||||||
interface StateChangePayload {
|
interface StateChangePayload {
|
||||||
state: CharacterState;
|
state: CharacterState;
|
||||||
@@ -220,7 +221,7 @@ export async function initializeTauriListeners() {
|
|||||||
claudeStore.addLineToConversation(
|
claudeStore.addLineToConversation(
|
||||||
targetConversationId,
|
targetConversationId,
|
||||||
"system",
|
"system",
|
||||||
"Disconnected from Claude Code"
|
"Disconnected from Claude Code unexpectedly — the process may have crashed or been stopped by the system"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear todos on real disconnect (not on reconnects for permissions)
|
// Clear todos on real disconnect (not on reconnects for permissions)
|
||||||
@@ -270,6 +271,67 @@ export async function initializeTauriListeners() {
|
|||||||
|
|
||||||
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
||||||
|
|
||||||
|
// Per-conversation sound tracking — fires for any tab (active or background).
|
||||||
|
// All sounds are driven from state-change events rather than a global store
|
||||||
|
// subscription, so background tabs receive their sounds correctly and
|
||||||
|
// switching tabs never replays a sound that has already fired.
|
||||||
|
const resolvedConversationId = conversation_id || get(claudeStore.activeConversationId) || null;
|
||||||
|
if (resolvedConversationId) {
|
||||||
|
const conv = get(claudeStore.conversations).get(resolvedConversationId);
|
||||||
|
if (conv) {
|
||||||
|
const previousState = conv.characterState;
|
||||||
|
|
||||||
|
// New response starting — clear all per-task sound flags.
|
||||||
|
// Only reset when entering from a clean-slate state, not mid-task.
|
||||||
|
// Transitioning from coding/searching/mcp/typing → thinking means we're
|
||||||
|
// still within the same task (between tool calls), so the sound must not replay.
|
||||||
|
const cleanSlateStates: CharacterState[] = ["idle", "success", "error"];
|
||||||
|
if (mappedState === "thinking" && cleanSlateStates.includes(previousState)) {
|
||||||
|
claudeStore.resetSoundState(resolvedConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record when a long-running phase begins (used for the 2-second
|
||||||
|
// minimum duration check before playing the completion sound).
|
||||||
|
if (
|
||||||
|
(mappedState === "coding" || mappedState === "searching") &&
|
||||||
|
previousState !== "coding" &&
|
||||||
|
previousState !== "searching"
|
||||||
|
) {
|
||||||
|
claudeStore.setTaskStartTime(resolvedConversationId, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task-start sound — fires once when work enters a long-running phase.
|
||||||
|
if (
|
||||||
|
(mappedState === "coding" || mappedState === "searching") &&
|
||||||
|
previousState !== "coding" &&
|
||||||
|
previousState !== "searching" &&
|
||||||
|
!conv.taskStartSoundFired
|
||||||
|
) {
|
||||||
|
notificationManager.notifyTaskStart();
|
||||||
|
claudeStore.markTaskStartSoundFired(resolvedConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error sound — fires each time a new error state is entered.
|
||||||
|
if (mappedState === "error" && previousState !== "error") {
|
||||||
|
notificationManager.notifyError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission sound — fires each time a permission request arrives.
|
||||||
|
if (mappedState === "permission") {
|
||||||
|
notificationManager.notifyPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completion sound — fires once per task after sufficient duration.
|
||||||
|
if (mappedState === "success" && !conv.successSoundFired) {
|
||||||
|
const duration = conv.taskStartTime ? Date.now() - conv.taskStartTime : 0;
|
||||||
|
if (duration > 2000) {
|
||||||
|
notificationManager.notifySuccess();
|
||||||
|
}
|
||||||
|
claudeStore.markSuccessSoundFired(resolvedConversationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Always update the conversation's state
|
// Always update the conversation's state
|
||||||
if (conversation_id) {
|
if (conversation_id) {
|
||||||
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+59
-4
@@ -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);
|
||||||
@@ -297,7 +337,7 @@
|
|||||||
setSkipNextGreeting(true);
|
setSkipNextGreeting(true);
|
||||||
|
|
||||||
await invoke("interrupt_claude", { conversationId });
|
await invoke("interrupt_claude", { conversationId });
|
||||||
claudeStore.addLine("system", "Process interrupted");
|
claudeStore.addLine("system", "Process interrupted by keyboard shortcut (Ctrl+C)");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to interrupt:", error);
|
console.error("Failed to interrupt:", error);
|
||||||
}
|
}
|
||||||
@@ -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`
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
case "list_clipboard_entries":
|
case "list_clipboard_entries":
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
|
case "list_drafts":
|
||||||
|
return Promise.resolve([]);
|
||||||
case "cleanup_temp_files":
|
case "cleanup_temp_files":
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
case "validate_directory":
|
case "validate_directory":
|
||||||
|
|||||||
Reference in New Issue
Block a user