generated from nhcarrigan/template
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9af61a4a29
|
|||
| fa906684c2 | |||
|
97b8243d24
|
|||
| 7ebd9dc97a | |||
|
fe7027c585
|
|||
| 89a0bdd8f1 | |||
|
2e3f203508
|
|||
| b745100bd5 | |||
|
1bb7eb4d26
|
|||
| a4e6788573 | |||
|
d2e0915a75
|
|||
| d8cf5504d6 |
@@ -20,19 +20,26 @@ When working with issues, pull requests, or other repository operations for this
|
|||||||
When asked to commit changes for this project:
|
When asked to commit changes for this project:
|
||||||
|
|
||||||
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
|
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
|
||||||
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up
|
- **Always sign commits** with Hikari's GPG key: `--gpg-sign=5380E4EE7307C808`
|
||||||
- **Never add `Co-Authored-By` lines** for Gitea commits
|
- **Never add `Co-Authored-By` lines** for Gitea commits
|
||||||
- **Always ask for confirmation** before committing
|
- **Always ask for confirmation** before committing
|
||||||
|
- **Always ask for confirmation** before pushing
|
||||||
|
|
||||||
Example commit command:
|
Example commit command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
|
git commit --author="Hikari <hikari@nhcarrigan.com>" --gpg-sign=5380E4EE7307C808 -m "your commit message"
|
||||||
|
```
|
||||||
|
|
||||||
|
Example push command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git <branch>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing Requirements
|
## Testing Requirements
|
||||||
|
|
||||||
All new features, fixes, and significant changes should include tests whenever possible:
|
**All changes MUST include tests.** This is non-negotiable — no feature, bug fix, or refactor should be committed without corresponding test coverage. If a change cannot be tested (e.g. pure UI layout, Tauri IPC calls that are impossible to mock), document why in a comment.
|
||||||
|
|
||||||
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
|
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
|
||||||
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
|
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
|
||||||
@@ -130,16 +137,16 @@ describe("FeatureName", () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding Tests for New Features
|
### Adding Tests for All Changes
|
||||||
|
|
||||||
When developing new features, always add corresponding tests:
|
Every change — features, bug fixes, refactors — must include tests:
|
||||||
|
|
||||||
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
||||||
2. **During implementation**: Write tests alongside the code
|
2. **During implementation**: Write tests alongside the code
|
||||||
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
||||||
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
||||||
|
|
||||||
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
|
**Do not commit changes without tests.** The goal is to maintain near-100% coverage as the codebase grows, so future refactoring can be done with confidence!
|
||||||
|
|
||||||
## Quality Assurance
|
## Quality Assurance
|
||||||
|
|
||||||
|
|||||||
+62
-62
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.5.1",
|
"version": "1.10.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.15.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
+691
-678
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.5.1"
|
version = "1.10.0"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+411
-11
@@ -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))?;
|
||||||
@@ -736,12 +862,33 @@ pub async fn read_file_content(path: String) -> Result<String, String> {
|
|||||||
.map_err(|e| format!("Failed to read file: {}", e))
|
.map_err(|e| format!("Failed to read file: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the first `# Heading` from a WSL file path (for Windows).
|
||||||
|
/// Returns `None` if the file cannot be read or has no top-level heading.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn read_wsl_file_first_heading(path: &str) -> Option<String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let output = Command::new("wsl")
|
||||||
|
.hide_window()
|
||||||
|
.args(["-e", "bash", "-c", &format!("head -20 '{}'", path)])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = String::from_utf8_lossy(&output.stdout);
|
||||||
|
extract_first_heading(&content)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read file content via WSL (for Windows with WSL paths)
|
/// Read file content via WSL (for Windows with WSL paths)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
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 +920,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 +969,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 +979,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 +1020,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 +1030,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 +1075,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 +1086,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 +1096,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 +1141,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 +1152,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 +1162,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 +1208,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 +1219,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 +1229,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))?;
|
||||||
@@ -1212,9 +1373,29 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct MemoryFileInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub heading: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct MemoryFilesResponse {
|
pub struct MemoryFilesResponse {
|
||||||
pub files: Vec<String>,
|
pub files: Vec<MemoryFileInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the first `# Heading` from a string of file content.
|
||||||
|
fn extract_first_heading(content: &str) -> Option<String> {
|
||||||
|
content.lines().find_map(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if let Some(rest) = trimmed.strip_prefix("# ") {
|
||||||
|
let heading = rest.trim().to_string();
|
||||||
|
if !heading.is_empty() {
|
||||||
|
return Some(heading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1246,6 +1427,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))?;
|
||||||
@@ -1256,12 +1438,19 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let files: Vec<String> = stdout
|
let paths: Vec<String> = stdout
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|line| !line.trim().is_empty())
|
.filter(|line| !line.trim().is_empty())
|
||||||
.map(|line| line.trim().to_string())
|
.map(|line| line.trim().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Read first heading from each file via WSL
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for path in paths {
|
||||||
|
let heading = read_wsl_file_first_heading(&path);
|
||||||
|
files.push(MemoryFileInfo { path, heading });
|
||||||
|
}
|
||||||
|
|
||||||
Ok(MemoryFilesResponse { files })
|
Ok(MemoryFilesResponse { files })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1283,10 +1472,13 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
|||||||
return Ok(MemoryFilesResponse { files: Vec::new() });
|
return Ok(MemoryFilesResponse { files: Vec::new() });
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut memory_files = Vec::new();
|
let mut memory_paths = Vec::new();
|
||||||
|
|
||||||
// Recursively find all memory directories
|
// Recursively find all memory directories
|
||||||
fn find_memory_files(dir: &std::path::Path, files: &mut Vec<String>) -> std::io::Result<()> {
|
fn find_memory_files(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
files: &mut Vec<String>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1319,16 +1511,25 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) {
|
if let Err(e) = find_memory_files(&projects_dir, &mut memory_paths) {
|
||||||
return Err(format!("Failed to list memory files: {}", e));
|
return Err(format!("Failed to list memory files: {}", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort files alphabetically
|
// Sort files alphabetically
|
||||||
memory_files.sort();
|
memory_paths.sort();
|
||||||
|
|
||||||
Ok(MemoryFilesResponse {
|
// Read first heading from each file
|
||||||
files: memory_files,
|
let files = memory_paths
|
||||||
})
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let heading = fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|content| extract_first_heading(&content));
|
||||||
|
MemoryFileInfo { path, heading }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(MemoryFilesResponse { files })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1360,6 +1561,144 @@ pub async fn get_claude_version() -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Auth Commands ====================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ClaudeAuthStatus {
|
||||||
|
pub is_logged_in: bool,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub org_id: Option<String>,
|
||||||
|
pub org_name: Option<String>,
|
||||||
|
pub api_key_source: Option<String>,
|
||||||
|
pub api_provider: Option<String>,
|
||||||
|
pub subscription_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
|
||||||
|
tracing::debug!("Getting Claude auth status");
|
||||||
|
|
||||||
|
let output = create_claude_command()
|
||||||
|
.args(["auth", "status"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run claude auth status: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
|
|
||||||
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) {
|
||||||
|
let is_logged_in = json
|
||||||
|
.get("loggedIn")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let email = json
|
||||||
|
.get("email")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let org_id = json
|
||||||
|
.get("orgId")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let org_name = json
|
||||||
|
.get("orgName")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let api_key_source = json
|
||||||
|
.get("apiKeySource")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let api_provider = json
|
||||||
|
.get("apiProvider")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let subscription_type = json
|
||||||
|
.get("subscriptionType")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
tracing::info!("Claude auth status: logged_in={}", is_logged_in);
|
||||||
|
Ok(ClaudeAuthStatus {
|
||||||
|
is_logged_in,
|
||||||
|
email,
|
||||||
|
org_id,
|
||||||
|
org_name,
|
||||||
|
api_key_source,
|
||||||
|
api_provider,
|
||||||
|
subscription_type,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Non-JSON output: fall back to heuristic
|
||||||
|
let lower = raw.to_lowercase();
|
||||||
|
let is_logged_in = output.status.success()
|
||||||
|
&& !lower.contains("not logged in")
|
||||||
|
&& !lower.contains("not authenticated")
|
||||||
|
&& !lower.contains("no account");
|
||||||
|
tracing::info!("Claude auth status (non-JSON): logged_in={}", is_logged_in);
|
||||||
|
Ok(ClaudeAuthStatus {
|
||||||
|
is_logged_in,
|
||||||
|
email: None,
|
||||||
|
org_id: None,
|
||||||
|
org_name: None,
|
||||||
|
api_key_source: None,
|
||||||
|
api_provider: None,
|
||||||
|
subscription_type: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_login() -> Result<String, String> {
|
||||||
|
tracing::info!("Running claude auth login");
|
||||||
|
|
||||||
|
let output = create_claude_command()
|
||||||
|
.args(["auth", "login"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run claude auth login: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
let message = if stdout.is_empty() { "Login successful".to_string() } else { stdout };
|
||||||
|
tracing::info!("Claude auth login succeeded");
|
||||||
|
Ok(message)
|
||||||
|
} else {
|
||||||
|
let error = if stderr.is_empty() { stdout } else { stderr };
|
||||||
|
tracing::error!("Claude auth login failed: {}", error);
|
||||||
|
Err(format!("Login failed: {}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn auth_logout() -> Result<String, String> {
|
||||||
|
tracing::info!("Running claude auth logout");
|
||||||
|
|
||||||
|
let output = create_claude_command()
|
||||||
|
.args(["auth", "logout"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run claude auth logout: {}", e))?;
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
let message = if stdout.is_empty() { "Logged out successfully".to_string() } else { stdout };
|
||||||
|
tracing::info!("Claude auth logout succeeded");
|
||||||
|
Ok(message)
|
||||||
|
} else {
|
||||||
|
let error = if stderr.is_empty() { stdout } else { stderr };
|
||||||
|
tracing::error!("Claude auth logout failed: {}", error);
|
||||||
|
Err(format!("Logout failed: {}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Plugin Management Commands ====================
|
// ==================== Plugin Management Commands ====================
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -2622,4 +2961,65 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
|
|||||||
assert_eq!(servers[0].name, "asana");
|
assert_eq!(servers[0].name, "asana");
|
||||||
assert_eq!(servers[1].name, "gitea");
|
assert_eq!(servers[1].name, "gitea");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== extract_first_heading tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_returns_heading() {
|
||||||
|
let content = "# My Memory File\n\nSome content here.";
|
||||||
|
assert_eq!(
|
||||||
|
extract_first_heading(content),
|
||||||
|
Some("My Memory File".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_ignores_non_h1() {
|
||||||
|
let content = "## Section Header\n### Sub-section\nSome content.";
|
||||||
|
assert_eq!(extract_first_heading(content), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_finds_first_h1_after_other_lines() {
|
||||||
|
let content = "Some intro text\n\n# The Real Title\n\nMore content.";
|
||||||
|
assert_eq!(
|
||||||
|
extract_first_heading(content),
|
||||||
|
Some("The Real Title".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_trims_whitespace() {
|
||||||
|
let content = "# Trimmed Heading \n\nContent.";
|
||||||
|
assert_eq!(
|
||||||
|
extract_first_heading(content),
|
||||||
|
Some("Trimmed Heading".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_returns_none_for_empty_content() {
|
||||||
|
assert_eq!(extract_first_heading(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_returns_none_for_empty_heading() {
|
||||||
|
let content = "# \n\nContent after empty heading.";
|
||||||
|
assert_eq!(extract_first_heading(content), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_returns_none_when_no_headings() {
|
||||||
|
let content = "Just some plain text.\nNo headings here at all.";
|
||||||
|
assert_eq!(extract_first_heading(content), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_handles_leading_whitespace_on_line() {
|
||||||
|
let content = " # Indented Heading\n\nContent.";
|
||||||
|
assert_eq!(
|
||||||
|
extract_first_heading(content),
|
||||||
|
Some("Indented Heading".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ pub struct ClaudeStartOptions {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub resume_session_id: Option<String>,
|
pub resume_session_id: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub use_worktree: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_1m_context: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_output_tokens: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -113,6 +122,42 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default = "default_discord_rpc_enabled")]
|
#[serde(default = "default_discord_rpc_enabled")]
|
||||||
pub discord_rpc_enabled: bool,
|
pub discord_rpc_enabled: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub use_worktree: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_1m_context: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_output_tokens: Option<u64>,
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub show_thinking_blocks: bool,
|
||||||
|
|
||||||
|
// Custom terminal font settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_font_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_font_family: Option<String>,
|
||||||
|
|
||||||
|
// Custom UI font settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_ui_font_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_ui_font_family: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -145,6 +190,17 @@ impl Default for HikariConfig {
|
|||||||
budget_action: BudgetAction::Warn,
|
budget_action: BudgetAction::Warn,
|
||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: None,
|
||||||
|
trusted_workspaces: Vec::new(),
|
||||||
|
background_image_path: None,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
|
show_thinking_blocks: false,
|
||||||
|
custom_font_path: None,
|
||||||
|
custom_font_family: None,
|
||||||
|
custom_ui_font_path: None,
|
||||||
|
custom_ui_font_family: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +237,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 {
|
||||||
@@ -252,6 +312,14 @@ mod tests {
|
|||||||
assert_eq!(config.budget_action, BudgetAction::Warn);
|
assert_eq!(config.budget_action, BudgetAction::Warn);
|
||||||
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
|
||||||
assert!(config.discord_rpc_enabled);
|
assert!(config.discord_rpc_enabled);
|
||||||
|
assert!(!config.use_worktree);
|
||||||
|
assert!(!config.disable_1m_context);
|
||||||
|
assert!(config.trusted_workspaces.is_empty());
|
||||||
|
assert!(!config.show_thinking_blocks);
|
||||||
|
assert!(config.custom_font_path.is_none());
|
||||||
|
assert!(config.custom_font_family.is_none());
|
||||||
|
assert!(config.custom_ui_font_path.is_none());
|
||||||
|
assert!(config.custom_ui_font_family.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -284,6 +352,17 @@ mod tests {
|
|||||||
budget_action: BudgetAction::Block,
|
budget_action: BudgetAction::Block,
|
||||||
budget_warning_threshold: 0.75,
|
budget_warning_threshold: 0.75,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
|
use_worktree: true,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: Some(32000),
|
||||||
|
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
|
||||||
|
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
||||||
|
background_image_opacity: 0.25,
|
||||||
|
show_thinking_blocks: true,
|
||||||
|
custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()),
|
||||||
|
custom_font_family: Some("MyFont".to_string()),
|
||||||
|
custom_ui_font_path: None,
|
||||||
|
custom_ui_font_family: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
@@ -195,6 +199,9 @@ pub fn run() {
|
|||||||
close_application,
|
close_application,
|
||||||
list_memory_files,
|
list_memory_files,
|
||||||
get_claude_version,
|
get_claude_version,
|
||||||
|
get_auth_status,
|
||||||
|
auth_login,
|
||||||
|
auth_logout,
|
||||||
list_plugins,
|
list_plugins,
|
||||||
install_plugin,
|
install_plugin,
|
||||||
uninstall_plugin,
|
uninstall_plugin,
|
||||||
@@ -209,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,8 +86,9 @@ impl ContextWarning {
|
|||||||
/// Get the context window limit (in tokens) for a given model
|
/// Get the context window limit (in tokens) for a given model
|
||||||
fn get_context_window_limit(model: &str) -> u64 {
|
fn get_context_window_limit(model: &str) -> u64 {
|
||||||
match model {
|
match model {
|
||||||
// Claude 4.6 family - 200K standard (1M beta available via header)
|
// Claude 4.6 family
|
||||||
"claude-opus-4-6" => 200_000,
|
"claude-opus-4-6" => 200_000,
|
||||||
|
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
|
||||||
// Claude 4.5 family - 200K standard context
|
// Claude 4.5 family - 200K standard context
|
||||||
"claude-opus-4-5-20251101"
|
"claude-opus-4-5-20251101"
|
||||||
| "claude-sonnet-4-5-20250929"
|
| "claude-sonnet-4-5-20250929"
|
||||||
@@ -502,6 +503,7 @@ pub fn calculate_cost(
|
|||||||
let (input_price_per_million, output_price_per_million) = match model {
|
let (input_price_per_million, output_price_per_million) = match model {
|
||||||
// Current generation (Claude 4.6)
|
// Current generation (Claude 4.6)
|
||||||
"claude-opus-4-6" => (5.0, 25.0),
|
"claude-opus-4-6" => (5.0, 25.0),
|
||||||
|
"claude-sonnet-4-6" => (3.0, 15.0),
|
||||||
|
|
||||||
// Previous generation (Claude 4.5)
|
// Previous generation (Claude 4.5)
|
||||||
"claude-opus-4-5-20251101" => (5.0, 25.0),
|
"claude-opus-4-5-20251101" => (5.0, 25.0),
|
||||||
|
|||||||
@@ -63,6 +63,26 @@ pub struct PermissionDenial {
|
|||||||
pub tool_input: serde_json::Value,
|
pub tool_input: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rate limit information from a `rate_limit_event` message.
|
||||||
|
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct RateLimitInfo {
|
||||||
|
#[serde(default)]
|
||||||
|
pub requests_limit: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub requests_remaining: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub requests_reset: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens_limit: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens_remaining: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tokens_reset: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub retry_after_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum ClaudeMessage {
|
pub enum ClaudeMessage {
|
||||||
@@ -100,6 +120,11 @@ pub enum ClaudeMessage {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
usage: Option<UsageInfo>,
|
usage: Option<UsageInfo>,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "rate_limit_event")]
|
||||||
|
RateLimitEvent {
|
||||||
|
#[serde(default)]
|
||||||
|
rate_limit_info: RateLimitInfo,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -280,6 +305,8 @@ pub struct AgentEndEvent {
|
|||||||
pub duration_ms: Option<u64>,
|
pub duration_ms: Option<u64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub num_turns: Option<u32>,
|
pub num_turns: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_assistant_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -446,4 +473,77 @@ mod tests {
|
|||||||
assert!(serialized.contains("\"input_tokens\":100"));
|
assert!(serialized.contains("\"input_tokens\":100"));
|
||||||
assert!(serialized.contains("\"output_tokens\":50"));
|
assert!(serialized.contains("\"output_tokens\":50"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_info_default() {
|
||||||
|
let info = RateLimitInfo::default();
|
||||||
|
assert!(info.requests_limit.is_none());
|
||||||
|
assert!(info.requests_remaining.is_none());
|
||||||
|
assert!(info.requests_reset.is_none());
|
||||||
|
assert!(info.tokens_limit.is_none());
|
||||||
|
assert!(info.tokens_remaining.is_none());
|
||||||
|
assert!(info.tokens_reset.is_none());
|
||||||
|
assert!(info.retry_after_ms.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_empty_info() {
|
||||||
|
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_no_info() {
|
||||||
|
// rate_limit_info field is optional via #[serde(default)]
|
||||||
|
let json = r#"{"type":"rate_limit_event"}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_deserialization_with_data() {
|
||||||
|
let json = r#"{
|
||||||
|
"type": "rate_limit_event",
|
||||||
|
"rate_limit_info": {
|
||||||
|
"requests_limit": 1000,
|
||||||
|
"requests_remaining": 0,
|
||||||
|
"requests_reset": "2024-01-01T00:01:00Z",
|
||||||
|
"tokens_limit": 50000,
|
||||||
|
"tokens_remaining": 0,
|
||||||
|
"tokens_reset": "2024-01-01T00:01:00Z",
|
||||||
|
"retry_after_ms": 60000
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||||
|
assert_eq!(rate_limit_info.requests_limit, Some(1000));
|
||||||
|
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
rate_limit_info.requests_reset,
|
||||||
|
Some("2024-01-01T00:01:00Z".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
|
||||||
|
} else {
|
||||||
|
panic!("Expected RateLimitEvent variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limit_event_ignores_unknown_fields() {
|
||||||
|
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
|
||||||
|
let json = r#"{
|
||||||
|
"type": "rate_limit_event",
|
||||||
|
"rate_limit_info": {
|
||||||
|
"requests_remaining": 0,
|
||||||
|
"some_future_field": "some_value"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||||
|
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||||
|
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||||
|
} else {
|
||||||
|
panic!("Expected RateLimitEvent variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+576
-73
@@ -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,
|
||||||
@@ -39,6 +39,12 @@ const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"
|
|||||||
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
|
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
|
||||||
|
|
||||||
fn detect_wsl() -> bool {
|
fn detect_wsl() -> bool {
|
||||||
|
// A native Windows binary is never running inside WSL, even if launched from a WSL
|
||||||
|
// terminal that has WSL_DISTRO_NAME set in its environment.
|
||||||
|
if cfg!(target_os = "windows") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Check /proc/version for WSL indicators
|
// Check /proc/version for WSL indicators
|
||||||
if let Ok(version) = std::fs::read_to_string("/proc/version") {
|
if let Ok(version) = std::fs::read_to_string("/proc/version") {
|
||||||
let version_lower = version.to_lowercase();
|
let version_lower = version.to_lowercase();
|
||||||
@@ -61,23 +67,29 @@ fn detect_wsl() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn find_claude_binary() -> Option<String> {
|
fn find_claude_binary() -> Option<String> {
|
||||||
// Check common installation locations for claude
|
// Check common installation locations for claude (when HOME is available)
|
||||||
let home = std::env::var("HOME").ok()?;
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
let paths_to_check = [
|
let paths_to_check = [
|
||||||
format!("{}/.local/bin/claude", home),
|
format!("{}/.local/bin/claude", home),
|
||||||
format!("{}/.claude/local/claude", home),
|
format!("{}/.claude/local/claude", home),
|
||||||
"/usr/local/bin/claude".to_string(),
|
];
|
||||||
"/usr/bin/claude".to_string(),
|
for path in &paths_to_check {
|
||||||
];
|
if std::path::Path::new(path).exists() {
|
||||||
|
return Some(path.clone());
|
||||||
for path in &paths_to_check {
|
}
|
||||||
if std::path::Path::new(path).exists() {
|
|
||||||
return Some(path.clone());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to checking PATH via which
|
// Check system-wide locations
|
||||||
if let Ok(output) = Command::new("which").arg("claude").output() {
|
for path in &["/usr/local/bin/claude", "/usr/bin/claude"] {
|
||||||
|
if std::path::Path::new(path).exists() {
|
||||||
|
return Some((*path).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a login shell to resolve claude via the user's PATH - GUI apps don't
|
||||||
|
// inherit shell PATH, so bare `which` may miss ~/.local/bin entries
|
||||||
|
if let Ok(output) = Command::new("bash").hide_window().args(["-lc", "which claude"]).output() {
|
||||||
if 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() {
|
||||||
@@ -90,48 +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 self.process.is_some() {
|
// If a process handle exists but the process has already exited (e.g. due to a
|
||||||
return Err("Process already running".to_string());
|
// failed working directory), clean up the stale handle so we can restart cleanly.
|
||||||
}
|
{
|
||||||
|
let mut proc_guard = self.process.lock();
|
||||||
// Check if Claude binary is installed before attempting to start
|
if let Some(ref mut proc) = *proc_guard {
|
||||||
if Command::new("which").arg("claude").output().ok().is_none_or(|output| !output.status.success()) {
|
if proc.try_wait().map(|s| s.is_some()).unwrap_or(false) {
|
||||||
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
|
*proc_guard = None;
|
||||||
|
self.stdin = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if proc_guard.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
|
||||||
@@ -208,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",
|
||||||
@@ -249,6 +277,11 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add worktree flag if requested
|
||||||
|
if options.use_worktree {
|
||||||
|
cmd.arg("--worktree");
|
||||||
|
}
|
||||||
|
|
||||||
cmd.current_dir(working_dir);
|
cmd.current_dir(working_dir);
|
||||||
|
|
||||||
// Set API key as environment variable if specified
|
// Set API key as environment variable if specified
|
||||||
@@ -258,10 +291,46 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable 1M context window if requested
|
||||||
|
if options.disable_1m_context {
|
||||||
|
cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set max output tokens if specified
|
||||||
|
if let Some(max_tokens) = options.max_output_tokens {
|
||||||
|
cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", max_tokens.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
} else {
|
} else {
|
||||||
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
||||||
tracing::debug!("Windows path - using wsl");
|
tracing::debug!("Windows path - using wsl");
|
||||||
|
|
||||||
|
// Check if Claude binary is installed inside WSL
|
||||||
|
let binary_check = Command::new("wsl")
|
||||||
|
.hide_window()
|
||||||
|
.args(["-e", "bash", "-lc", "which claude"])
|
||||||
|
.output();
|
||||||
|
if let Ok(output) = binary_check {
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the working directory exists inside WSL before spawning
|
||||||
|
let dir_check = Command::new("wsl")
|
||||||
|
.hide_window()
|
||||||
|
.args(["-e", "test", "-d", working_dir])
|
||||||
|
.output();
|
||||||
|
if let Ok(output) = dir_check {
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"Working directory does not exist: {}",
|
||||||
|
working_dir
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut cmd = Command::new("wsl");
|
let mut cmd = Command::new("wsl");
|
||||||
|
|
||||||
// Build the claude command with all arguments
|
// Build the claude command with all arguments
|
||||||
@@ -274,6 +343,16 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable 1M context window if requested
|
||||||
|
if options.disable_1m_context {
|
||||||
|
claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set max output tokens if specified
|
||||||
|
if let Some(max_tokens) = options.max_output_tokens {
|
||||||
|
claude_cmd.push_str(&format!("CLAUDE_CODE_MAX_OUTPUT_TOKENS={} ", max_tokens));
|
||||||
|
}
|
||||||
|
|
||||||
claude_cmd.push_str(
|
claude_cmd.push_str(
|
||||||
"claude --output-format stream-json --input-format stream-json --verbose",
|
"claude --output-format stream-json --input-format stream-json --verbose",
|
||||||
);
|
);
|
||||||
@@ -311,12 +390,16 @@ impl WslBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add worktree flag if requested
|
||||||
|
if options.use_worktree {
|
||||||
|
claude_cmd.push_str(" --worktree");
|
||||||
|
}
|
||||||
|
|
||||||
// Use bash -lc to load login profile (ensures PATH includes claude)
|
// Use bash -lc to load login profile (ensures PATH includes claude)
|
||||||
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
|
||||||
};
|
};
|
||||||
@@ -336,7 +419,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()
|
||||||
@@ -353,8 +440,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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,12 +462,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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +565,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);
|
||||||
|
|
||||||
@@ -580,7 +703,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();
|
||||||
}
|
}
|
||||||
@@ -611,7 +737,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 {
|
||||||
@@ -634,13 +760,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -652,6 +782,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,17 +874,28 @@ fn handle_stderr(
|
|||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
duration_ms: None,
|
duration_ms: None,
|
||||||
num_turns: None,
|
num_turns: None,
|
||||||
|
last_assistant_message: stop_data.last_assistant_message,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Still emit the stderr line as output
|
// Hook events are informational — emit with distinct types instead of error
|
||||||
|
let line_type = if line.contains("[WorktreeCreate Hook]")
|
||||||
|
|| line.contains("[WorktreeRemove Hook]")
|
||||||
|
{
|
||||||
|
"worktree"
|
||||||
|
} else if line.contains("[ConfigChange Hook]") {
|
||||||
|
"config-change"
|
||||||
|
} else {
|
||||||
|
"error"
|
||||||
|
};
|
||||||
|
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"claude:output",
|
"claude:output",
|
||||||
OutputEvent {
|
OutputEvent {
|
||||||
line_type: "error".to_string(),
|
line_type: line_type.to_string(),
|
||||||
content: line,
|
content: line,
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
@@ -768,32 +948,84 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct SubagentStopData {
|
struct SubagentStopData {
|
||||||
parent_tool_use_id: Option<String>,
|
parent_tool_use_id: Option<String>,
|
||||||
|
last_assistant_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the content of a Rust Debug-formatted `Some("...")` field from a hook line.
|
||||||
|
/// Handles escaped characters (e.g. `\"` → `"`, `\\` → `\`, `\n` → newline).
|
||||||
|
/// Returns `None` if the field is absent or formatted as `None`.
|
||||||
|
fn extract_debug_string_value(line: &str, key: &str) -> Option<String> {
|
||||||
|
let prefix = format!("{}=Some(\"", key);
|
||||||
|
let start_idx = line.find(&prefix)? + prefix.len();
|
||||||
|
let rest = &line[start_idx..];
|
||||||
|
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut chars = rest.chars();
|
||||||
|
loop {
|
||||||
|
match chars.next() {
|
||||||
|
Some('"') => return Some(result),
|
||||||
|
Some('\\') => match chars.next() {
|
||||||
|
Some('n') => result.push('\n'),
|
||||||
|
Some('t') => result.push('\t'),
|
||||||
|
Some('"') => result.push('"'),
|
||||||
|
Some('\\') => result.push('\\'),
|
||||||
|
Some(c) => {
|
||||||
|
result.push('\\');
|
||||||
|
result.push(c);
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
},
|
||||||
|
Some(c) => result.push(c),
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
|
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
|
||||||
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ...
|
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), last_assistant_message=Some("..."), ...
|
||||||
|
|
||||||
// Extract parent_tool_use_id if present
|
let parent_tool_use_id = extract_debug_string_value(line, "parent_tool_use_id");
|
||||||
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
|
let last_assistant_message = extract_debug_string_value(line, "last_assistant_message");
|
||||||
line.split("parent_tool_use_id=Some(\"")
|
|
||||||
.nth(1)?
|
|
||||||
.split('"')
|
|
||||||
.next()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(SubagentStopData {
|
Some(SubagentStopData {
|
||||||
parent_tool_use_id,
|
parent_tool_use_id,
|
||||||
|
last_assistant_message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract text content from a ToolResult's `content` field.
|
||||||
|
/// The content may be a JSON string or an array of typed content blocks.
|
||||||
|
fn extract_tool_result_text(content: &serde_json::Value) -> Option<String> {
|
||||||
|
match content {
|
||||||
|
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
|
||||||
|
serde_json::Value::Array(blocks) => {
|
||||||
|
let texts: Vec<String> = blocks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|block| {
|
||||||
|
if block.get("type")?.as_str()? == "text" {
|
||||||
|
block.get("text")?.as_str().map(String::from)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if texts.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(texts.join("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn process_json_line(
|
fn process_json_line(
|
||||||
line: &str,
|
line: &str,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
stats: &Arc<RwLock<UsageStats>>,
|
stats: &Arc<RwLock<UsageStats>>,
|
||||||
conversation_id: &Option<String>,
|
conversation_id: &Option<String>,
|
||||||
|
received_init: &Arc<AtomicBool>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let message: ClaudeMessage = serde_json::from_str(line)
|
let message: ClaudeMessage = serde_json::from_str(line)
|
||||||
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
|
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
|
||||||
@@ -806,6 +1038,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",
|
||||||
@@ -1042,17 +1277,37 @@ fn process_json_line(
|
|||||||
stats.write().increment_code_blocks();
|
stats.write().increment_code_blocks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let is_prompt_too_long = text.starts_with("Prompt is too long");
|
||||||
|
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"claude:output",
|
"claude:output",
|
||||||
OutputEvent {
|
OutputEvent {
|
||||||
line_type: "assistant".to_string(),
|
line_type: if is_prompt_too_long {
|
||||||
|
"error".to_string()
|
||||||
|
} else {
|
||||||
|
"assistant".to_string()
|
||||||
|
},
|
||||||
content: text.clone(),
|
content: text.clone(),
|
||||||
tool_name: None,
|
tool_name: None,
|
||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
cost: message_cost.clone(), // Include cost with assistant text
|
cost: message_cost.clone(),
|
||||||
parent_tool_use_id: parent_tool_use_id.clone(),
|
parent_tool_use_id: parent_tool_use_id.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if is_prompt_too_long {
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:output",
|
||||||
|
OutputEvent {
|
||||||
|
line_type: "compact-prompt".to_string(),
|
||||||
|
content: String::new(),
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ContentBlock::Thinking { thinking } => {
|
ContentBlock::Thinking { thinking } => {
|
||||||
state = CharacterState::Thinking;
|
state = CharacterState::Thinking;
|
||||||
@@ -1070,8 +1325,8 @@ fn process_json_line(
|
|||||||
}
|
}
|
||||||
ContentBlock::ToolResult {
|
ContentBlock::ToolResult {
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
|
content,
|
||||||
is_error,
|
is_error,
|
||||||
..
|
|
||||||
} => {
|
} => {
|
||||||
// Emit agent-end for all tool results
|
// Emit agent-end for all tool results
|
||||||
// The frontend will ignore IDs that don't match known agents
|
// The frontend will ignore IDs that don't match known agents
|
||||||
@@ -1089,6 +1344,7 @@ fn process_json_line(
|
|||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
duration_ms: None,
|
duration_ms: None,
|
||||||
num_turns: None,
|
num_turns: None,
|
||||||
|
last_assistant_message: extract_tool_result_text(content),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1481,6 +1737,23 @@ fn process_json_line(
|
|||||||
emit_state_change(app, state, None, conversation_id.clone());
|
emit_state_change(app, state, None, conversation_id.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ClaudeMessage::RateLimitEvent { rate_limit_info } => {
|
||||||
|
tracing::warn!("Rate limit event received: {:?}", rate_limit_info);
|
||||||
|
|
||||||
|
let content = format_rate_limit_message(rate_limit_info);
|
||||||
|
let _ = app.emit(
|
||||||
|
"claude:output",
|
||||||
|
OutputEvent {
|
||||||
|
line_type: "rate-limit".to_string(),
|
||||||
|
content,
|
||||||
|
tool_name: None,
|
||||||
|
conversation_id: conversation_id.clone(),
|
||||||
|
cost: None,
|
||||||
|
parent_tool_use_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ClaudeMessage::User { message } => {
|
ClaudeMessage::User { message } => {
|
||||||
// Increment message count for user messages
|
// Increment message count for user messages
|
||||||
stats.write().increment_messages();
|
stats.write().increment_messages();
|
||||||
@@ -1489,8 +1762,8 @@ fn process_json_line(
|
|||||||
for block in &message.content {
|
for block in &message.content {
|
||||||
if let ContentBlock::ToolResult {
|
if let ContentBlock::ToolResult {
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
|
content,
|
||||||
is_error,
|
is_error,
|
||||||
..
|
|
||||||
} = block
|
} = block
|
||||||
{
|
{
|
||||||
let now = SystemTime::now()
|
let now = SystemTime::now()
|
||||||
@@ -1507,6 +1780,7 @@ fn process_json_line(
|
|||||||
conversation_id: conversation_id.clone(),
|
conversation_id: conversation_id.clone(),
|
||||||
duration_ms: None,
|
duration_ms: None,
|
||||||
num_turns: None,
|
num_turns: None,
|
||||||
|
last_assistant_message: extract_tool_result_text(content),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1589,6 +1863,35 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_rate_limit_message(info: &crate::types::RateLimitInfo) -> String {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
|
||||||
|
if let (Some(remaining), Some(limit)) = (info.requests_remaining, info.requests_limit) {
|
||||||
|
parts.push(format!("requests: {}/{}", remaining, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(remaining), Some(limit)) = (info.tokens_remaining, info.tokens_limit) {
|
||||||
|
parts.push(format!("tokens: {}/{}", remaining, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(reset) = &info.requests_reset {
|
||||||
|
parts.push(format!("resets at {}", reset));
|
||||||
|
} else if let Some(reset) = &info.tokens_reset {
|
||||||
|
parts.push(format!("resets at {}", reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(retry_ms) = info.retry_after_ms {
|
||||||
|
let secs = retry_ms / 1000;
|
||||||
|
parts.push(format!("retry after {}s", secs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
"Rate limit reached".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Rate limit reached — {}", parts.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||||
// Helper function to check if a path is a memory file
|
// Helper function to check if a path is a memory file
|
||||||
fn is_memory_path(path: &str) -> bool {
|
fn is_memory_path(path: &str) -> bool {
|
||||||
@@ -1649,12 +1952,7 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
|||||||
}
|
}
|
||||||
"Bash" => {
|
"Bash" => {
|
||||||
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
|
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
|
||||||
let truncated = if cmd.len() > 50 {
|
format!("Running: {}", cmd)
|
||||||
format!("{}...", &cmd[..50])
|
|
||||||
} else {
|
|
||||||
cmd.to_string()
|
|
||||||
};
|
|
||||||
format!("Running: {}", truncated)
|
|
||||||
} else {
|
} else {
|
||||||
"Running command...".to_string()
|
"Running command...".to_string()
|
||||||
}
|
}
|
||||||
@@ -1815,9 +2113,7 @@ mod tests {
|
|||||||
let long_cmd = "a".repeat(100);
|
let long_cmd = "a".repeat(100);
|
||||||
let input = serde_json::json!({"command": long_cmd});
|
let input = serde_json::json!({"command": long_cmd});
|
||||||
let desc = format_tool_description("Bash", &input);
|
let desc = format_tool_description("Bash", &input);
|
||||||
assert!(desc.starts_with("Running: "));
|
assert_eq!(desc, format!("Running: {}", long_cmd));
|
||||||
assert!(desc.ends_with("..."));
|
|
||||||
assert!(desc.len() < 70);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1874,19 +2170,66 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_claude_binary_check_command_structure() {
|
fn test_stale_process_detection_with_try_wait() {
|
||||||
// Test that we're using the correct command to check for Claude binary
|
// Spawn a real process that exits immediately so we can verify try_wait detects it
|
||||||
let output = Command::new("which").arg("claude").output();
|
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
|
||||||
|
|
||||||
// The command should execute successfully (even if claude is not found)
|
// Wait for it to exit
|
||||||
// We're just verifying the command structure is valid
|
let _ = child.wait();
|
||||||
assert!(output.is_ok(), "which command should execute without error");
|
|
||||||
|
|
||||||
// Verify the check logic returns a boolean
|
// try_wait on an already-exited process should return Some(_)
|
||||||
// This is the same logic used in start() to check if claude is installed
|
let status = child.try_wait();
|
||||||
let _result = output.ok().is_none_or(|o| !o.status.success());
|
assert!(
|
||||||
// If claude is not installed, _result will be true (show error)
|
status.is_ok(),
|
||||||
// If claude is installed, _result will be false (proceed with connection)
|
"try_wait should not error on an exited process"
|
||||||
|
);
|
||||||
|
// The process has already been waited on, so try_wait might return None or Some
|
||||||
|
// depending on the OS - what matters is that the call succeeds
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stale_process_is_some_after_exit() {
|
||||||
|
// Verify the logic used in start(): a process that has exited is detected
|
||||||
|
// and the handle is cleaned up so start() can proceed
|
||||||
|
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
|
||||||
|
|
||||||
|
// Let it exit
|
||||||
|
let _ = child.wait();
|
||||||
|
|
||||||
|
// This mirrors the check in start()
|
||||||
|
let has_exited = child
|
||||||
|
.try_wait()
|
||||||
|
.map(|s| s.is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// After wait(), try_wait() returns None (already reaped), which means
|
||||||
|
// unwrap_or(false) → false. The important thing is the call doesn't panic
|
||||||
|
// and the control flow logic compiles and runs correctly.
|
||||||
|
let _ = has_exited; // suppress unused warning
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the WSL binary check command structure without executing it (for testing)
|
||||||
|
#[cfg(test)]
|
||||||
|
fn build_wsl_binary_check_args() -> Vec<&'static str> {
|
||||||
|
vec!["-e", "bash", "-lc", "which claude"]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wsl_binary_check_command_structure() {
|
||||||
|
// Windows path: verify Claude is detected inside WSL via `wsl -e bash -lc "which claude"`
|
||||||
|
let args = build_wsl_binary_check_args();
|
||||||
|
assert_eq!(args[0], "-e");
|
||||||
|
assert_eq!(args[1], "bash");
|
||||||
|
assert_eq!(args[2], "-lc");
|
||||||
|
assert_eq!(args[3], "which claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_linux_binary_check_does_not_panic() {
|
||||||
|
// Linux/WSL path: find_claude_binary() searches Linux filesystem paths.
|
||||||
|
// We just verify it runs without panicking; whether it returns Some depends
|
||||||
|
// on whether Claude is actually installed in this environment.
|
||||||
|
let _result = find_claude_binary();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1989,5 +2332,165 @@ mod tests {
|
|||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let data = result.unwrap();
|
let data = result.unwrap();
|
||||||
assert_eq!(data.parent_tool_use_id, None);
|
assert_eq!(data.parent_tool_use_id, None);
|
||||||
|
assert_eq!(data.last_assistant_message, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_subagent_stop_hook_with_last_message() {
|
||||||
|
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=Some("Task completed successfully."), session_id=123"#;
|
||||||
|
let result = parse_subagent_stop_hook(line);
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
let data = result.unwrap();
|
||||||
|
assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
data.last_assistant_message,
|
||||||
|
Some("Task completed successfully.".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_subagent_stop_hook_with_last_message_none() {
|
||||||
|
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=None, session_id=123"#;
|
||||||
|
let result = parse_subagent_stop_hook(line);
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
let data = result.unwrap();
|
||||||
|
assert_eq!(data.last_assistant_message, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_debug_string_value_simple() {
|
||||||
|
let line = r#"key=Some("hello world")"#;
|
||||||
|
assert_eq!(
|
||||||
|
extract_debug_string_value(line, "key"),
|
||||||
|
Some("hello world".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_debug_string_value_with_escaped_quotes() {
|
||||||
|
let line = r#"key=Some("say \"hi\" there")"#;
|
||||||
|
assert_eq!(
|
||||||
|
extract_debug_string_value(line, "key"),
|
||||||
|
Some(r#"say "hi" there"#.to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_debug_string_value_none_variant() {
|
||||||
|
let line = "key=None";
|
||||||
|
assert_eq!(extract_debug_string_value(line, "key"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_debug_string_value_missing_key() {
|
||||||
|
let line = "other=Some(\"value\")";
|
||||||
|
assert_eq!(extract_debug_string_value(line, "key"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_subagent_stop_hook_with_commas_in_message() {
|
||||||
|
let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_01"), last_assistant_message=Some("Found 3 files, all passing.")"#;
|
||||||
|
let result = parse_subagent_stop_hook(line);
|
||||||
|
|
||||||
|
assert!(result.is_some());
|
||||||
|
let data = result.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
data.last_assistant_message,
|
||||||
|
Some("Found 3 files, all passing.".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract_tool_result_text tests
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tool_result_text_plain_string() {
|
||||||
|
let content = serde_json::json!("Hello from agent");
|
||||||
|
assert_eq!(
|
||||||
|
extract_tool_result_text(&content),
|
||||||
|
Some("Hello from agent".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tool_result_text_empty_string() {
|
||||||
|
let content = serde_json::json!("");
|
||||||
|
assert_eq!(extract_tool_result_text(&content), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tool_result_text_array_single_text_block() {
|
||||||
|
let content = serde_json::json!([{"type": "text", "text": "Agent completed the task."}]);
|
||||||
|
assert_eq!(
|
||||||
|
extract_tool_result_text(&content),
|
||||||
|
Some("Agent completed the task.".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tool_result_text_array_multiple_text_blocks() {
|
||||||
|
let content = serde_json::json!([
|
||||||
|
{"type": "text", "text": "First part."},
|
||||||
|
{"type": "text", "text": "Second part."}
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
extract_tool_result_text(&content),
|
||||||
|
Some("First part.\nSecond part.".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tool_result_text_array_non_text_block() {
|
||||||
|
let content = serde_json::json!([{"type": "image", "source": {"type": "base64"}}]);
|
||||||
|
assert_eq!(extract_tool_result_text(&content), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tool_result_text_array_mixed_blocks() {
|
||||||
|
let content = serde_json::json!([
|
||||||
|
{"type": "image", "source": {}},
|
||||||
|
{"type": "text", "text": "Found results."}
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
extract_tool_result_text(&content),
|
||||||
|
Some("Found results.".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tool_result_text_null() {
|
||||||
|
let content = serde_json::Value::Null;
|
||||||
|
assert_eq!(extract_tool_result_text(&content), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tool_result_text_empty_array() {
|
||||||
|
let content = serde_json::json!([]);
|
||||||
|
assert_eq!(extract_tool_result_text(&content), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the 50K tool result persistence threshold (CLI v2.1.51+).
|
||||||
|
// Results > 50K chars are now persisted to disk; the stream may send null
|
||||||
|
// or a large inline string. Both must be handled without panicking.
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tool_result_text_large_content_above_50k_threshold() {
|
||||||
|
let large_text = "x".repeat(60_000);
|
||||||
|
let content = serde_json::Value::String(large_text.clone());
|
||||||
|
assert_eq!(extract_tool_result_text(&content), Some(large_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tool_result_deserializes_with_null_content() {
|
||||||
|
let json = r#"{"type":"tool_result","tool_use_id":"toolu_abc","content":null}"#;
|
||||||
|
let block: ContentBlock = serde_json::from_str(json).unwrap();
|
||||||
|
if let ContentBlock::ToolResult { tool_use_id, content, is_error } = block {
|
||||||
|
assert_eq!(tool_use_id, "toolu_abc");
|
||||||
|
assert!(content.is_null());
|
||||||
|
assert_eq!(is_error, None);
|
||||||
|
// Persisted-to-disk results produce null content → no preview shown
|
||||||
|
assert_eq!(extract_tool_result_text(&content), None);
|
||||||
|
} else {
|
||||||
|
panic!("Expected ToolResult variant");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.5.1",
|
"version": "1.10.0",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
+1
-5
@@ -154,11 +154,7 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family:
|
font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
|
||||||
"Segoe UI",
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
sans-serif;
|
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
|
import { searchState } from "$lib/stores/search";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
import {
|
import {
|
||||||
slashCommands,
|
slashCommands,
|
||||||
parseSlashCommand,
|
parseSlashCommand,
|
||||||
@@ -40,6 +45,28 @@ vi.mock("$lib/stores/character", () => ({
|
|||||||
|
|
||||||
vi.mock("$lib/tauri", () => ({
|
vi.mock("$lib/tauri", () => ({
|
||||||
setSkipNextGreeting: vi.fn(),
|
setSkipNextGreeting: vi.fn(),
|
||||||
|
updateDiscordRpc: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/conversations", () => ({
|
||||||
|
conversationsStore: {
|
||||||
|
activeConversation: { subscribe: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/config", () => ({
|
||||||
|
configStore: {
|
||||||
|
getConfig: vi.fn().mockReturnValue({
|
||||||
|
auto_granted_tools: [],
|
||||||
|
model: "claude-sonnet",
|
||||||
|
api_key: null,
|
||||||
|
custom_instructions: null,
|
||||||
|
mcp_servers_json: null,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("$lib/stores/search", () => ({
|
vi.mock("$lib/stores/search", () => ({
|
||||||
@@ -415,4 +442,425 @@ describe("slashCommands", () => {
|
|||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("command execute functions", () => {
|
||||||
|
let getMock: ReturnType<typeof vi.fn>;
|
||||||
|
let invokeMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getMock = vi.mocked(get);
|
||||||
|
invokeMock = vi.mocked(invoke);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/clear execute", () => {
|
||||||
|
it("clears terminal and shows confirmation message", () => {
|
||||||
|
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear")!;
|
||||||
|
clearCmd.execute("");
|
||||||
|
expect(claudeStore.clearTerminal).toHaveBeenCalledWith();
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Terminal cleared");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/help execute", () => {
|
||||||
|
it("shows available commands header", () => {
|
||||||
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||||
|
helpCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
expect.stringContaining("Available commands:")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes all command usages in help text", () => {
|
||||||
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||||
|
helpCmd.execute("");
|
||||||
|
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
||||||
|
const helpText = callArgs[1] as string;
|
||||||
|
expect(helpText).toContain("/cd");
|
||||||
|
expect(helpText).toContain("/clear");
|
||||||
|
expect(helpText).toContain("/help");
|
||||||
|
expect(helpText).toContain("/search");
|
||||||
|
expect(helpText).toContain("/new");
|
||||||
|
expect(helpText).toContain("/summarise");
|
||||||
|
expect(helpText).toContain("/skill");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes command descriptions in help text", () => {
|
||||||
|
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||||
|
helpCmd.execute("");
|
||||||
|
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
||||||
|
const helpText = callArgs[1] as string;
|
||||||
|
expect(helpText).toContain("Change the working directory");
|
||||||
|
expect(helpText).toContain("Show available slash commands");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/search execute", () => {
|
||||||
|
it("clears search when called with empty args", () => {
|
||||||
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||||
|
searchCmd.execute("");
|
||||||
|
expect(searchState.clear).toHaveBeenCalledWith();
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears search when called with whitespace-only args", () => {
|
||||||
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||||
|
searchCmd.execute(" ");
|
||||||
|
expect(searchState.clear).toHaveBeenCalledWith();
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets query when called with a search term", () => {
|
||||||
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||||
|
searchCmd.execute("hello world");
|
||||||
|
expect(searchState.setQuery).toHaveBeenCalledWith("hello world");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello world"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace from query before setting", () => {
|
||||||
|
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||||
|
searchCmd.execute(" hello ");
|
||||||
|
expect(searchState.setQuery).toHaveBeenCalledWith("hello");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/cd execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
await cdCmd.execute("/some/path");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows current directory when called with empty args", async () => {
|
||||||
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
await cdCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Current directory: /home/naomi/code"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows current directory when called with whitespace-only args", async () => {
|
||||||
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
await cdCmd.execute(" ");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Current directory: /home/naomi/code"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates path and changes directory on success", async () => {
|
||||||
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(null);
|
||||||
|
invokeMock.mockResolvedValue("/validated/path");
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
await cdCmd.execute("/new/path");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"validate_directory",
|
||||||
|
expect.objectContaining({ path: "/new/path" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when directory change fails", async () => {
|
||||||
|
getMock.mockReturnValueOnce("conv-123");
|
||||||
|
invokeMock.mockRejectedValueOnce(new Error("invalid path"));
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
await cdCmd.execute("/bad/path");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"error",
|
||||||
|
expect.stringContaining("Failed to change directory:")
|
||||||
|
);
|
||||||
|
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/new execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||||
|
await newCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when starting new conversation fails", async () => {
|
||||||
|
getMock.mockReturnValueOnce("conv-123");
|
||||||
|
invokeMock.mockRejectedValueOnce(new Error("invoke failed"));
|
||||||
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||||
|
await newCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"error",
|
||||||
|
expect.stringContaining("Failed to start new conversation:")
|
||||||
|
);
|
||||||
|
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/summarise execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||||
|
await summariseCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a summary prompt when there is an active conversation", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||||
|
await summariseCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Requesting conversation summary..."
|
||||||
|
);
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"send_prompt",
|
||||||
|
expect.objectContaining({ conversationId: "conv-123" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when send_prompt invoke fails", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockRejectedValue(new Error("network error"));
|
||||||
|
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||||
|
await summariseCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"error",
|
||||||
|
expect.stringContaining("Failed to request summary:")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/skill execute", () => {
|
||||||
|
it("shows error when no active conversation", async () => {
|
||||||
|
getMock.mockReturnValue(null);
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("onboard-mentee");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists available skills when called with no name", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(["onboard-mentee", "other-skill"]);
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("list_skills");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
expect.stringContaining("onboard-mentee")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows empty message when no skills are found", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue([]);
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
expect.stringContaining("No skills found")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes skill when called with a name and no data", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("onboard-mentee");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Invoking skill: onboard-mentee"
|
||||||
|
);
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"send_prompt",
|
||||||
|
expect.objectContaining({ conversationId: "conv-123" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes skill with additional data in the prompt", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("onboard-mentee some extra data");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: expect.stringContaining("some extra data"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error when listing skills fails", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockRejectedValue(new Error("list error"));
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"error",
|
||||||
|
expect.stringContaining("Failed to list skills:")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error and resets character state when invoking skill fails", async () => {
|
||||||
|
getMock.mockReturnValue("conv-123");
|
||||||
|
invokeMock.mockRejectedValue(new Error("invoke error"));
|
||||||
|
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||||
|
await skillCmd.execute("onboard-mentee");
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"error",
|
||||||
|
expect.stringContaining("Failed to invoke skill:")
|
||||||
|
);
|
||||||
|
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/cd success path", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes directory and shows success message", async () => {
|
||||||
|
getMock
|
||||||
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||||
|
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||||
|
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||||
|
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
||||||
|
invokeMock
|
||||||
|
.mockResolvedValueOnce("/new/path") // validate_directory
|
||||||
|
.mockResolvedValueOnce(undefined) // stop_claude
|
||||||
|
.mockResolvedValueOnce(undefined); // start_claude
|
||||||
|
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
const promise = cdCmd.execute("/new/path");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Changed directory to: /new/path"
|
||||||
|
);
|
||||||
|
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends context restoration message when conversation history exists", async () => {
|
||||||
|
getMock
|
||||||
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||||
|
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||||
|
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||||
|
vi.mocked(claudeStore.getConversationHistory).mockReturnValue(
|
||||||
|
"previous conversation history"
|
||||||
|
);
|
||||||
|
invokeMock
|
||||||
|
.mockResolvedValueOnce("/new/path") // validate_directory
|
||||||
|
.mockResolvedValueOnce(undefined) // stop_claude
|
||||||
|
.mockResolvedValueOnce(undefined) // start_claude
|
||||||
|
.mockResolvedValueOnce(undefined); // send_prompt
|
||||||
|
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
const promise = cdCmd.execute("/new/path");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"send_prompt",
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining("previous conversation history"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
"Changed directory to: /new/path"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
||||||
|
const activeConv = {
|
||||||
|
name: "Test Conversation",
|
||||||
|
model: "claude-sonnet",
|
||||||
|
startedAt: new Date("2026-03-03T12:00:00Z"),
|
||||||
|
grantedTools: new Set<string>(),
|
||||||
|
};
|
||||||
|
getMock
|
||||||
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||||
|
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||||
|
.mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation)
|
||||||
|
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
||||||
|
invokeMock
|
||||||
|
.mockResolvedValueOnce("/new/path")
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
const { updateDiscordRpc } = await import("$lib/tauri");
|
||||||
|
|
||||||
|
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||||
|
const promise = cdCmd.execute("/new/path");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
||||||
|
"Test Conversation",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Date)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/new success path", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new conversation and shows success message", async () => {
|
||||||
|
getMock
|
||||||
|
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||||
|
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||||
|
invokeMock
|
||||||
|
.mockResolvedValueOnce("/working/dir") // get_working_directory
|
||||||
|
.mockResolvedValueOnce(undefined) // interrupt_claude
|
||||||
|
.mockResolvedValueOnce(undefined); // start_claude
|
||||||
|
|
||||||
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||||
|
const promise = newCmd.execute("");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!");
|
||||||
|
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
||||||
|
const activeConv = {
|
||||||
|
name: "My Conv",
|
||||||
|
model: "claude-sonnet",
|
||||||
|
startedAt: new Date("2026-03-03T12:00:00Z"),
|
||||||
|
grantedTools: new Set<string>(["tool1"]),
|
||||||
|
};
|
||||||
|
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv);
|
||||||
|
invokeMock
|
||||||
|
.mockResolvedValueOnce("/working/dir")
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
const { updateDiscordRpc } = await import("$lib/tauri");
|
||||||
|
|
||||||
|
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||||
|
const promise = newCmd.execute("");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
||||||
|
"My Conv",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Date)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
custom_instructions: config.custom_instructions || null,
|
custom_instructions: config.custom_instructions || null,
|
||||||
mcp_servers_json: config.mcp_servers_json || null,
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
max_output_tokens: config.max_output_tokens ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,6 +138,9 @@ async function startNewConversation(): Promise<void> {
|
|||||||
custom_instructions: config.custom_instructions || null,
|
custom_instructions: config.custom_instructions || null,
|
||||||
mcp_servers_json: config.mcp_servers_json || null,
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
max_output_tokens: config.max_output_tokens ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* AchievementNotification Component Tests
|
||||||
|
*
|
||||||
|
* Tests the rarity classification and colour mapping logic used by the
|
||||||
|
* AchievementNotification component.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Listens for "achievement:unlocked" Tauri events
|
||||||
|
* - Queues and displays achievement notifications one at a time
|
||||||
|
* - Each notification shows the achievement's name, icon, description, and rarity
|
||||||
|
* - A gradient border and badge colour correspond to the achievement's rarity
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Achievement notification slides in from the right
|
||||||
|
* - [ ] Notification auto-dismisses after 5 seconds
|
||||||
|
* - [ ] Dismiss button works immediately
|
||||||
|
* - [ ] Multiple achievements queue and display sequentially
|
||||||
|
* - [ ] Legendary achievements have a yellow-orange gradient
|
||||||
|
* - [ ] Epic achievements have a purple-pink gradient
|
||||||
|
* - [ ] Rare achievements have a blue-indigo gradient
|
||||||
|
* - [ ] Common achievements have a green-emerald gradient
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
function getAchievementRarity(id: string): string {
|
||||||
|
if (id === "TokenMaster") return "legendary";
|
||||||
|
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"BlossomingCoder",
|
||||||
|
"CodeWizard",
|
||||||
|
"MasterBuilder",
|
||||||
|
"EnduranceChamp",
|
||||||
|
"DeepDive",
|
||||||
|
"CreativeCoder",
|
||||||
|
].includes(id)
|
||||||
|
)
|
||||||
|
return "rare";
|
||||||
|
return "common";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRarityColor(rarity: string): string {
|
||||||
|
switch (rarity) {
|
||||||
|
case "legendary":
|
||||||
|
return "from-yellow-400 to-orange-500";
|
||||||
|
case "epic":
|
||||||
|
return "from-purple-400 to-pink-500";
|
||||||
|
case "rare":
|
||||||
|
return "from-blue-400 to-indigo-500";
|
||||||
|
default:
|
||||||
|
return "from-green-400 to-emerald-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("getAchievementRarity", () => {
|
||||||
|
describe("legendary tier", () => {
|
||||||
|
it("classifies TokenMaster as legendary", () => {
|
||||||
|
expect(getAchievementRarity("TokenMaster")).toBe("legendary");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("epic tier", () => {
|
||||||
|
it("classifies CodeMachine as epic", () => {
|
||||||
|
expect(getAchievementRarity("CodeMachine")).toBe("epic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies Unstoppable as epic", () => {
|
||||||
|
expect(getAchievementRarity("Unstoppable")).toBe("epic");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rare tier", () => {
|
||||||
|
it("classifies BlossomingCoder as rare", () => {
|
||||||
|
expect(getAchievementRarity("BlossomingCoder")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies CodeWizard as rare", () => {
|
||||||
|
expect(getAchievementRarity("CodeWizard")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies MasterBuilder as rare", () => {
|
||||||
|
expect(getAchievementRarity("MasterBuilder")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies EnduranceChamp as rare", () => {
|
||||||
|
expect(getAchievementRarity("EnduranceChamp")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies DeepDive as rare", () => {
|
||||||
|
expect(getAchievementRarity("DeepDive")).toBe("rare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies CreativeCoder as rare", () => {
|
||||||
|
expect(getAchievementRarity("CreativeCoder")).toBe("rare");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("common tier", () => {
|
||||||
|
it("classifies unknown IDs as common", () => {
|
||||||
|
expect(getAchievementRarity("FirstChat")).toBe("common");
|
||||||
|
expect(getAchievementRarity("SomeNewAchievement")).toBe("common");
|
||||||
|
expect(getAchievementRarity("")).toBe("common");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRarityColor", () => {
|
||||||
|
it("returns yellow-to-orange gradient for legendary", () => {
|
||||||
|
expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns purple-to-pink gradient for epic", () => {
|
||||||
|
expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns blue-to-indigo gradient for rare", () => {
|
||||||
|
expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns green-to-emerald gradient for common", () => {
|
||||||
|
expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to green-to-emerald gradient for unknown rarities", () => {
|
||||||
|
expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500");
|
||||||
|
expect(getRarityColor("")).toBe("from-green-400 to-emerald-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("end-to-end rarity pipeline", () => {
|
||||||
|
it("produces the correct colour for a legendary achievement", () => {
|
||||||
|
const color = getRarityColor(getAchievementRarity("TokenMaster"));
|
||||||
|
expect(color).toBe("from-yellow-400 to-orange-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the correct colour for an epic achievement", () => {
|
||||||
|
const color = getRarityColor(getAchievementRarity("CodeMachine"));
|
||||||
|
expect(color).toBe("from-purple-400 to-pink-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the correct colour for a rare achievement", () => {
|
||||||
|
const color = getRarityColor(getAchievementRarity("CodeWizard"));
|
||||||
|
expect(color).toBe("from-blue-400 to-indigo-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the correct colour for a common achievement", () => {
|
||||||
|
const color = getRarityColor(getAchievementRarity("FirstChat"));
|
||||||
|
expect(color).toBe("from-green-400 to-emerald-500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -270,6 +270,14 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
|
<img
|
||||||
|
src={agent.characterAvatar}
|
||||||
|
alt={agent.characterName}
|
||||||
|
class="w-5 h-5 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<span class="text-[10px] font-medium text-[var(--text-primary)]">
|
||||||
|
{agent.characterName}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||||
agent.status
|
agent.status
|
||||||
@@ -310,6 +318,16 @@
|
|||||||
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Last assistant message snippet -->
|
||||||
|
{#if agent.lastAssistantMessage}
|
||||||
|
<p
|
||||||
|
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
|
||||||
|
title={agent.lastAssistantMessage}
|
||||||
|
>
|
||||||
|
{agent.lastAssistantMessage}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onClose }: Props = $props();
|
||||||
|
</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 p-6 max-h-[90vh] overflow-y-auto"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="cast-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 id="cast-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||||
|
Meet the Team
|
||||||
|
</h2>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Principal cast: Hikari + Naomi -->
|
||||||
|
<div class="grid grid-cols-1 gap-3 mb-6 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://cdn.nhcarrigan.com/hikari.png"
|
||||||
|
alt="Hikari"
|
||||||
|
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="font-semibold text-[var(--text-primary)]">Hikari</span>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||||
|
>
|
||||||
|
Chief Operating Officer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">
|
||||||
|
Holds the line so the others don't have to. Never without her clipboard — or her
|
||||||
|
glasses.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://cdn.nhcarrigan.com/profile.png"
|
||||||
|
alt="Naomi"
|
||||||
|
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="font-semibold text-[var(--text-primary)]">Naomi</span>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||||
|
>
|
||||||
|
Chief hEx-ecutive Officer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">
|
||||||
|
A 525-year-old vampire running a tech company from behind a VTuber avatar. Fixes server
|
||||||
|
crashes at 4 AM.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subagent girls grid -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3">
|
||||||
|
Subagent Squad
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{#each CHARACTER_POOL as character (character.name)}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-center"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={character.avatar}
|
||||||
|
alt={character.name}
|
||||||
|
class="w-14 h-14 object-cover rounded-full border-2 border-[var(--border-color)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-[var(--text-primary)]">{character.name}</span>
|
||||||
|
<span
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||||
|
>
|
||||||
|
{character.title}
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)] leading-snug">{character.description}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[role="dialog"] {
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,15 +2,43 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let version = $state("Loading...");
|
const SUPPORTED_CLI_VERSION = "2.1.53";
|
||||||
|
|
||||||
|
let installedVersion = $state("Loading...");
|
||||||
|
|
||||||
|
function compareVersions(a: string, b: string): number {
|
||||||
|
const aParts = a.split(".").map(Number);
|
||||||
|
const bParts = b.split(".").map(Number);
|
||||||
|
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||||
|
const aVal = aParts[i] ?? 0;
|
||||||
|
const bVal = bParts[i] ?? 0;
|
||||||
|
if (aVal > bVal) return 1;
|
||||||
|
if (aVal < bVal) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayVersion = $derived(installedVersion.split(" (")[0]);
|
||||||
|
|
||||||
|
let supportedBadgeState = $derived.by(() => {
|
||||||
|
if (installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||||
|
if (!semverMatch) return "neutral";
|
||||||
|
const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION);
|
||||||
|
if (cmp > 0) return "ahead";
|
||||||
|
if (cmp < 0) return "behind";
|
||||||
|
return "current";
|
||||||
|
});
|
||||||
|
|
||||||
async function fetchVersion() {
|
async function fetchVersion() {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<string>("get_claude_version");
|
const result = await invoke<string>("get_claude_version");
|
||||||
version = result;
|
installedVersion = result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get Claude CLI version:", error);
|
console.error("Failed to get Claude CLI version:", error);
|
||||||
version = "Unknown";
|
installedVersion = "Unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,25 +47,60 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="cli-version">
|
<div class="cli-versions">
|
||||||
<svg
|
<div class="cli-version">
|
||||||
class="terminal-icon"
|
<svg
|
||||||
width="14"
|
class="terminal-icon"
|
||||||
height="14"
|
width="14"
|
||||||
viewBox="0 0 24 24"
|
height="14"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
stroke-width="2"
|
stroke="currentColor"
|
||||||
stroke-linecap="round"
|
stroke-width="2"
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
>
|
stroke-linejoin="round"
|
||||||
<polyline points="4 17 10 11 4 5" />
|
>
|
||||||
<line x1="12" y1="19" x2="20" y2="19" />
|
<polyline points="4 17 10 11 4 5" />
|
||||||
</svg>
|
<line x1="12" y1="19" x2="20" y2="19" />
|
||||||
<span class="version-text">CLI {version}</span>
|
</svg>
|
||||||
|
<span class="version-text">CLI {displayVersion}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
||||||
|
<svg
|
||||||
|
class="terminal-icon"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||||
|
</svg>
|
||||||
|
<span class="version-text">Supported {SUPPORTED_CLI_VERSION}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if supportedBadgeState === "ahead"}
|
||||||
|
<span class="version-warning ahead"
|
||||||
|
>Your version is newer, some features may not be supported</span
|
||||||
|
>
|
||||||
|
{:else if supportedBadgeState === "behind"}
|
||||||
|
<span class="version-warning behind"
|
||||||
|
>Your version is out of date, please update to ensure compatibility</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.cli-versions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.cli-version {
|
.cli-version {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -57,6 +120,21 @@
|
|||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cli-version.supported.current {
|
||||||
|
border-color: var(--success-color, #4caf50);
|
||||||
|
color: var(--success-color, #4caf50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-version.supported.ahead {
|
||||||
|
border-color: var(--warning-color, #ff9800);
|
||||||
|
color: var(--warning-color, #ff9800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cli-version.supported.behind {
|
||||||
|
border-color: var(--error-color, #f44336);
|
||||||
|
color: var(--error-color, #f44336);
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-icon {
|
.terminal-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -65,4 +143,18 @@
|
|||||||
.version-text {
|
.version-text {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-warning {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-warning.ahead {
|
||||||
|
color: var(--warning-color, #ff9800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-warning.behind {
|
||||||
|
color: var(--error-color, #f44336);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* CliVersion Component Tests
|
||||||
|
*
|
||||||
|
* Tests the version comparison logic used by the CliVersion component,
|
||||||
|
* which compares the installed CLI version against the supported version.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Displays the installed Claude CLI version
|
||||||
|
* - Displays the highest audited/supported CLI version
|
||||||
|
* - Shows a warning when the installed version is ahead of or behind supported
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Installed version is fetched and displayed on mount
|
||||||
|
* - [ ] "current" badge shows in green when versions match
|
||||||
|
* - [ ] "ahead" badge shows in amber when installed is newer than supported
|
||||||
|
* - [ ] "behind" badge shows in red when installed is older than supported
|
||||||
|
* - [ ] Warning message appears for "ahead" and "behind" states
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
const SUPPORTED_CLI_VERSION = "2.1.53";
|
||||||
|
|
||||||
|
function compareVersions(a: string, b: string): number {
|
||||||
|
const aParts = a.split(".").map(Number);
|
||||||
|
const bParts = b.split(".").map(Number);
|
||||||
|
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||||
|
const aVal = aParts[i] ?? 0;
|
||||||
|
const bVal = bParts[i] ?? 0;
|
||||||
|
if (aVal > bVal) return 1;
|
||||||
|
if (aVal < bVal) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("SUPPORTED_CLI_VERSION", () => {
|
||||||
|
it("is defined and non-empty", () => {
|
||||||
|
expect(SUPPORTED_CLI_VERSION).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the expected audited version", () => {
|
||||||
|
expect(SUPPORTED_CLI_VERSION).toBe("2.1.53");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compareVersions", () => {
|
||||||
|
describe("equal versions", () => {
|
||||||
|
it("returns 0 for identical versions", () => {
|
||||||
|
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for the supported CLI version against itself", () => {
|
||||||
|
expect(compareVersions(SUPPORTED_CLI_VERSION, SUPPORTED_CLI_VERSION)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for 0.0.0 vs 0.0.0", () => {
|
||||||
|
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("major version differences", () => {
|
||||||
|
it("returns 1 when a has a higher major version", () => {
|
||||||
|
expect(compareVersions("2.0.0", "1.0.0")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 when a has a lower major version", () => {
|
||||||
|
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("minor version differences", () => {
|
||||||
|
it("returns 1 when a has a higher minor version", () => {
|
||||||
|
expect(compareVersions("1.2.0", "1.1.0")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 when a has a lower minor version", () => {
|
||||||
|
expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("patch version differences", () => {
|
||||||
|
it("returns 1 when a has a higher patch version", () => {
|
||||||
|
expect(compareVersions("1.0.2", "1.0.1")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 when a has a lower patch version", () => {
|
||||||
|
expect(compareVersions("1.0.1", "1.0.2")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("major version takes precedence", () => {
|
||||||
|
it("returns 1 when a has a higher major but lower minor", () => {
|
||||||
|
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 when a has a lower major but higher minor", () => {
|
||||||
|
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unequal segment counts", () => {
|
||||||
|
it("treats missing segments as 0 (a shorter than b)", () => {
|
||||||
|
expect(compareVersions("1.0", "1.0.0")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats missing segments as 0 (a longer than b)", () => {
|
||||||
|
expect(compareVersions("1.0.0", "1.0")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correctly compares when a has an extra non-zero segment", () => {
|
||||||
|
expect(compareVersions("1.0.1", "1.0")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correctly compares when b has an extra non-zero segment", () => {
|
||||||
|
expect(compareVersions("1.0", "1.0.1")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("supported CLI version comparisons", () => {
|
||||||
|
it("returns 1 for a version ahead of supported", () => {
|
||||||
|
expect(compareVersions("2.2.0", SUPPORTED_CLI_VERSION)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns -1 for a version behind supported", () => {
|
||||||
|
expect(compareVersions("2.1.0", SUPPORTED_CLI_VERSION)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for exactly the supported version", () => {
|
||||||
|
expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()}">
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
type Theme,
|
type Theme,
|
||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
|
applyCustomFont,
|
||||||
|
applyCustomUiFont,
|
||||||
applyCustomThemeColors,
|
applyCustomThemeColors,
|
||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
@@ -12,6 +14,8 @@
|
|||||||
} from "$lib/stores/config";
|
} from "$lib/stores/config";
|
||||||
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 { open } from "@tauri-apps/plugin-dialog";
|
||||||
import CostSummary from "./CostSummary.svelte";
|
import CostSummary from "./CostSummary.svelte";
|
||||||
|
|
||||||
let config: HikariConfig = $state({
|
let config: HikariConfig = $state({
|
||||||
@@ -52,9 +56,40 @@
|
|||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
|
custom_font_path: null,
|
||||||
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
|
let customFontPathInput = $state("");
|
||||||
|
let customFontFamilyInput = $state("");
|
||||||
|
let customFontStatus: string | null = $state(null);
|
||||||
|
let customUiFontPathInput = $state("");
|
||||||
|
let customUiFontFamilyInput = $state("");
|
||||||
|
let customUiFontStatus: string | null = $state(null);
|
||||||
|
|
||||||
|
interface AuthStatus {
|
||||||
|
is_logged_in: boolean;
|
||||||
|
email: string | null;
|
||||||
|
org_id: string | null;
|
||||||
|
org_name: string | null;
|
||||||
|
api_key_source: string | null;
|
||||||
|
api_provider: string | null;
|
||||||
|
subscription_type: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authStatus: AuthStatus | null = $state(null);
|
||||||
|
let authLoading = $state(false);
|
||||||
|
let authActionLoading = $state(false);
|
||||||
|
let authError: string | null = $state(null);
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
@@ -65,10 +100,17 @@
|
|||||||
|
|
||||||
configStore.config.subscribe((c) => {
|
configStore.config.subscribe((c) => {
|
||||||
config = { ...c };
|
config = { ...c };
|
||||||
|
customFontPathInput = c.custom_font_path ?? "";
|
||||||
|
customFontFamilyInput = c.custom_font_family ?? "";
|
||||||
|
customUiFontPathInput = c.custom_ui_font_path ?? "";
|
||||||
|
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.isSidebarOpen.subscribe((open) => {
|
configStore.isSidebarOpen.subscribe((open) => {
|
||||||
isOpen = open;
|
isOpen = open;
|
||||||
|
if (open && authStatus === null) {
|
||||||
|
void refreshAuthStatus();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.saveError.subscribe((error) => {
|
configStore.saveError.subscribe((error) => {
|
||||||
@@ -83,8 +125,9 @@
|
|||||||
{ value: "", label: "Default (from ~/.claude)" },
|
{ value: "", label: "Default (from ~/.claude)" },
|
||||||
// Current generation (Claude 4.6)
|
// Current generation (Claude 4.6)
|
||||||
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
|
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
|
||||||
|
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Recommended)" },
|
||||||
// Previous generation (Claude 4.5)
|
// Previous generation (Claude 4.5)
|
||||||
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
|
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||||
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
|
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
|
||||||
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
|
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
|
||||||
// Previous generation (Claude 4.x)
|
// Previous generation (Claude 4.x)
|
||||||
@@ -110,6 +153,44 @@
|
|||||||
"Task",
|
"Task",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function refreshAuthStatus() {
|
||||||
|
authLoading = true;
|
||||||
|
authError = null;
|
||||||
|
try {
|
||||||
|
authStatus = await invoke<AuthStatus>("get_auth_status");
|
||||||
|
} catch (e) {
|
||||||
|
authError = String(e);
|
||||||
|
} finally {
|
||||||
|
authLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthLogin() {
|
||||||
|
authActionLoading = true;
|
||||||
|
authError = null;
|
||||||
|
try {
|
||||||
|
await invoke<string>("auth_login");
|
||||||
|
await refreshAuthStatus();
|
||||||
|
} catch (e) {
|
||||||
|
authError = String(e);
|
||||||
|
} finally {
|
||||||
|
authActionLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthLogout() {
|
||||||
|
authActionLoading = true;
|
||||||
|
authError = null;
|
||||||
|
try {
|
||||||
|
await invoke<string>("auth_logout");
|
||||||
|
await refreshAuthStatus();
|
||||||
|
} catch (e) {
|
||||||
|
authError = String(e);
|
||||||
|
} finally {
|
||||||
|
authActionLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
saveError = null;
|
saveError = null;
|
||||||
@@ -181,6 +262,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 -->
|
||||||
@@ -227,6 +322,109 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Account Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Account
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if authLoading}
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] py-2">Checking auth status...</div>
|
||||||
|
{:else if authStatus}
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span
|
||||||
|
class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 {authStatus.is_logged_in
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-red-500'}"
|
||||||
|
></span>
|
||||||
|
<span class="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
{authStatus.is_logged_in ? "Logged in" : "Not logged in"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key}
|
||||||
|
<dl class="text-xs space-y-1 mb-3">
|
||||||
|
{#if authStatus.email}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Email</dt>
|
||||||
|
<dd class="text-[var(--text-primary)] break-all">{authStatus.email}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.org_name}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.org_id}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org UUID</dt>
|
||||||
|
<dd class="text-[var(--text-secondary)] font-mono text-[10px] break-all">
|
||||||
|
{authStatus.org_id}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.api_key_source}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">{authStatus.api_key_source}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if authStatus.subscription_type}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Plan</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">{authStatus.subscription_type}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Override</dt>
|
||||||
|
<dd class="text-[var(--text-primary)]">
|
||||||
|
{#if config.api_key}
|
||||||
|
{config.streamer_mode ? "Custom key set 🔒" : "Custom key set"}
|
||||||
|
{:else}
|
||||||
|
None
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] py-2">Auth status unavailable</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if authError}
|
||||||
|
<div class="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">
|
||||||
|
{authError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={refreshAuthStatus}
|
||||||
|
disabled={authLoading || authActionLoading}
|
||||||
|
class="px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
{#if authStatus && !authStatus.is_logged_in}
|
||||||
|
<button
|
||||||
|
onclick={handleAuthLogin}
|
||||||
|
disabled={authActionLoading}
|
||||||
|
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{authActionLoading ? "Logging in..." : "Login"}
|
||||||
|
</button>
|
||||||
|
{:else if authStatus && authStatus.is_logged_in}
|
||||||
|
<button
|
||||||
|
onclick={handleAuthLogout}
|
||||||
|
disabled={authActionLoading}
|
||||||
|
class="px-3 py-1.5 text-sm bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{authActionLoading ? "Logging out..." : "Logout"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Agent Settings Section -->
|
<!-- Agent Settings Section -->
|
||||||
<section class="mb-6">
|
<section class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
@@ -321,6 +519,56 @@
|
|||||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Worktree Isolation -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.use_worktree}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Worktree isolation</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disable 1M Context Window -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.disable_1m_context}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Disable 1M context window</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_1M_CONTEXT=1</code> to opt out of the extended
|
||||||
|
context window
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Max Output Tokens -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
|
||||||
|
Max output tokens
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="max-output-tokens"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="Default (32000)"
|
||||||
|
bind:value={config.max_output_tokens}
|
||||||
|
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Sets <code class="font-mono">CLAUDE_CODE_MAX_OUTPUT_TOKENS</code> — increase if responses are
|
||||||
|
being cut off mid-reply
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Greeting Section -->
|
<!-- Greeting Section -->
|
||||||
@@ -705,6 +953,130 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Terminal Font -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom Terminal Font</span>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customFontPathInput}
|
||||||
|
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customFontFamilyInput}
|
||||||
|
placeholder="Font family name (e.g. FiraCode)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customFontStatus = null;
|
||||||
|
try {
|
||||||
|
await configStore.setCustomFont(
|
||||||
|
customFontPathInput || null,
|
||||||
|
customFontFamilyInput || null
|
||||||
|
);
|
||||||
|
customFontStatus = "Font applied!";
|
||||||
|
} catch (e) {
|
||||||
|
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customFontStatus = null;
|
||||||
|
customFontPathInput = "";
|
||||||
|
customFontFamilyInput = "";
|
||||||
|
try {
|
||||||
|
await configStore.setCustomFont(null, null);
|
||||||
|
await applyCustomFont(null, null);
|
||||||
|
customFontStatus = "Font reset to default.";
|
||||||
|
} catch (e) {
|
||||||
|
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if customFontStatus}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{customFontStatus}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is
|
||||||
|
required to apply the font.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom UI Font -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom UI Font</span>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customUiFontPathInput}
|
||||||
|
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customUiFontFamilyInput}
|
||||||
|
placeholder="Font family name (e.g. Inter)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customUiFontStatus = null;
|
||||||
|
try {
|
||||||
|
await configStore.setCustomUiFont(
|
||||||
|
customUiFontPathInput || null,
|
||||||
|
customUiFontFamilyInput || null
|
||||||
|
);
|
||||||
|
customUiFontStatus = "Font applied!";
|
||||||
|
} catch (e) {
|
||||||
|
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Apply UI Font
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customUiFontStatus = null;
|
||||||
|
customUiFontPathInput = "";
|
||||||
|
customUiFontFamilyInput = "";
|
||||||
|
try {
|
||||||
|
await configStore.setCustomUiFont(null, null);
|
||||||
|
await applyCustomUiFont(null, null);
|
||||||
|
customUiFontStatus = "Font reset to default.";
|
||||||
|
} catch (e) {
|
||||||
|
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if customUiFontStatus}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{customUiFontStatus}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs,
|
||||||
|
direct font file URLs, or local file paths.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Show Thinking Blocks Toggle -->
|
<!-- Show Thinking Blocks Toggle -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
@@ -720,6 +1092,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,111 @@
|
|||||||
|
/**
|
||||||
|
* ConversationTabs Component Tests
|
||||||
|
*
|
||||||
|
* Tests the connection status colour mapping and unread message detection
|
||||||
|
* logic used by the ConversationTabs component.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Displays one tab per conversation
|
||||||
|
* - Each tab shows a coloured dot for its connection state
|
||||||
|
* - Inactive tabs with new messages show an animated blue dot badge
|
||||||
|
* - Tabs can be renamed by double-clicking
|
||||||
|
* - Tabs can be reordered by drag-and-drop
|
||||||
|
* - New tabs created with Ctrl+T, closed with Ctrl+W
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Connected tabs show a green dot
|
||||||
|
* - [ ] Connecting tabs show a yellow dot
|
||||||
|
* - [ ] Disconnected tabs show a red dot
|
||||||
|
* - [ ] Active tab never shows the unread badge
|
||||||
|
* - [ ] Inactive tab shows blue pulsing dot when it receives new messages
|
||||||
|
* - [ ] Switching to a tab clears the unread indicator
|
||||||
|
* - [ ] Double-clicking a tab name enables inline editing
|
||||||
|
* - [ ] Tabs can be dragged to reorder
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
type ConnectionStatus = "connected" | "connecting" | "disconnected";
|
||||||
|
|
||||||
|
function getConnectionStatusColor(status: ConnectionStatus | string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "connected":
|
||||||
|
return "bg-green-500";
|
||||||
|
case "connecting":
|
||||||
|
return "bg-yellow-500";
|
||||||
|
case "disconnected":
|
||||||
|
return "bg-red-500";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnreadMessages(
|
||||||
|
id: string,
|
||||||
|
conversationLineCount: number,
|
||||||
|
activeConversationId: string | null,
|
||||||
|
lastSeenMessageCount: Map<string, number>
|
||||||
|
): boolean {
|
||||||
|
if (id === activeConversationId) return false;
|
||||||
|
const lastSeen = lastSeenMessageCount.get(id) ?? 0;
|
||||||
|
return conversationLineCount > lastSeen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("getConnectionStatusColor", () => {
|
||||||
|
it("returns green for connected status", () => {
|
||||||
|
expect(getConnectionStatusColor("connected")).toBe("bg-green-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns yellow for connecting status", () => {
|
||||||
|
expect(getConnectionStatusColor("connecting")).toBe("bg-yellow-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns red for disconnected status", () => {
|
||||||
|
expect(getConnectionStatusColor("disconnected")).toBe("bg-red-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns grey for unknown status (fallback)", () => {
|
||||||
|
expect(getConnectionStatusColor("error")).toBe("bg-gray-500");
|
||||||
|
expect(getConnectionStatusColor("")).toBe("bg-gray-500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasUnreadMessages", () => {
|
||||||
|
it("returns false for the active conversation regardless of message count", () => {
|
||||||
|
const lastSeen = new Map([["tab-1", 0]]);
|
||||||
|
expect(hasUnreadMessages("tab-1", 10, "tab-1", lastSeen)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when an inactive tab has more messages than last seen", () => {
|
||||||
|
const lastSeen = new Map([["tab-1", 5]]);
|
||||||
|
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when an inactive tab has no new messages", () => {
|
||||||
|
const lastSeen = new Map([["tab-1", 10]]);
|
||||||
|
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when an inactive tab has fewer messages than last seen", () => {
|
||||||
|
const lastSeen = new Map([["tab-1", 15]]);
|
||||||
|
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a tab with no last-seen record as having 0 messages seen", () => {
|
||||||
|
const lastSeen = new Map<string, number>();
|
||||||
|
// Tab has 1 message but no entry in lastSeen → treated as 0 seen → unread
|
||||||
|
expect(hasUnreadMessages("tab-1", 1, "tab-2", lastSeen)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for an untracked tab with 0 messages", () => {
|
||||||
|
const lastSeen = new Map<string, number>();
|
||||||
|
expect(hasUnreadMessages("tab-1", 0, "tab-2", lastSeen)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null activeConversationId (no active tab)", () => {
|
||||||
|
const lastSeen = new Map([["tab-1", 3]]);
|
||||||
|
expect(hasUnreadMessages("tab-1", 10, null, lastSeen)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* HighlightedText Component Tests
|
||||||
|
*
|
||||||
|
* Tests the text-splitting logic used by the HighlightedText component,
|
||||||
|
* which highlights search query matches within a string.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Splits text into an array of {text, isMatch} parts
|
||||||
|
* - Matches are case-insensitive
|
||||||
|
* - Special regex characters in the query are escaped
|
||||||
|
* - Non-matching text is preserved verbatim around matches
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Matching text is highlighted (yellow background) in the terminal
|
||||||
|
* - [ ] Highlighting is case-insensitive
|
||||||
|
* - [ ] Multiple matches on the same line all get highlighted
|
||||||
|
* - [ ] Non-matching text renders normally alongside matches
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
interface TextPart {
|
||||||
|
text: string;
|
||||||
|
isMatch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHighlightedParts(text: string, query: string): TextPart[] {
|
||||||
|
if (!query) {
|
||||||
|
return [{ text, isMatch: false }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||||
|
const parts: TextPart[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push({
|
||||||
|
text: text.slice(lastIndex, match.index),
|
||||||
|
isMatch: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
text: match[1],
|
||||||
|
isMatch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = regex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push({
|
||||||
|
text: text.slice(lastIndex),
|
||||||
|
isMatch: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("getHighlightedParts", () => {
|
||||||
|
describe("empty query", () => {
|
||||||
|
it("returns the whole text as a single non-match when query is empty string", () => {
|
||||||
|
const result = getHighlightedParts("hello world", "");
|
||||||
|
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty non-match part when both text and query are empty", () => {
|
||||||
|
const result = getHighlightedParts("", "");
|
||||||
|
expect(result).toEqual([{ text: "", isMatch: false }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no match", () => {
|
||||||
|
it("returns the whole text as a single non-match when query is not found", () => {
|
||||||
|
const result = getHighlightedParts("hello world", "xyz");
|
||||||
|
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("single match", () => {
|
||||||
|
it("returns three parts for a match in the middle", () => {
|
||||||
|
const result = getHighlightedParts("hello world foo", "world");
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: "hello ", isMatch: false },
|
||||||
|
{ text: "world", isMatch: true },
|
||||||
|
{ text: " foo", isMatch: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns two parts for a match at the start", () => {
|
||||||
|
const result = getHighlightedParts("hello world", "hello");
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: "hello", isMatch: true },
|
||||||
|
{ text: " world", isMatch: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns two parts for a match at the end", () => {
|
||||||
|
const result = getHighlightedParts("hello world", "world");
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: "hello ", isMatch: false },
|
||||||
|
{ text: "world", isMatch: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a single match part when the entire text matches", () => {
|
||||||
|
const result = getHighlightedParts("hello", "hello");
|
||||||
|
expect(result).toEqual([{ text: "hello", isMatch: true }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple matches", () => {
|
||||||
|
it("returns interleaved match and non-match parts for multiple occurrences", () => {
|
||||||
|
const result = getHighlightedParts("foo bar foo", "foo");
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: "foo", isMatch: true },
|
||||||
|
{ text: " bar ", isMatch: false },
|
||||||
|
{ text: "foo", isMatch: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles adjacent matches correctly", () => {
|
||||||
|
const result = getHighlightedParts("aaa", "a");
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: "a", isMatch: true },
|
||||||
|
{ text: "a", isMatch: true },
|
||||||
|
{ text: "a", isMatch: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("case-insensitive matching", () => {
|
||||||
|
it("matches uppercase query against lowercase text", () => {
|
||||||
|
const result = getHighlightedParts("hello world", "WORLD");
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: "hello ", isMatch: false },
|
||||||
|
{ text: "world", isMatch: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches lowercase query against uppercase text", () => {
|
||||||
|
const result = getHighlightedParts("HELLO WORLD", "hello");
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: "HELLO", isMatch: true },
|
||||||
|
{ text: " WORLD", isMatch: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the original casing of the matched text", () => {
|
||||||
|
const result = getHighlightedParts("Hello World", "hello");
|
||||||
|
const matchPart = result.find((p) => p.isMatch);
|
||||||
|
expect(matchPart?.text).toBe("Hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("special regex character escaping", () => {
|
||||||
|
it("treats a dot in the query as a literal dot, not a wildcard", () => {
|
||||||
|
const result = getHighlightedParts("v1.2.3 v123", "1.2");
|
||||||
|
const matchParts = result.filter((p) => p.isMatch);
|
||||||
|
expect(matchParts).toHaveLength(1);
|
||||||
|
expect(matchParts[0].text).toBe("1.2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a query with parentheses", () => {
|
||||||
|
const result = getHighlightedParts("fn(args)", "(args)");
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: "fn", isMatch: false },
|
||||||
|
{ text: "(args)", isMatch: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a query with a plus sign", () => {
|
||||||
|
const result = getHighlightedParts("a+b=c", "+");
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: "a", isMatch: false },
|
||||||
|
{ text: "+", isMatch: true },
|
||||||
|
{ text: "b=c", isMatch: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a query with a question mark", () => {
|
||||||
|
const result = getHighlightedParts("is it true?", "?");
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: "is it true", isMatch: false },
|
||||||
|
{ text: "?", isMatch: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -362,6 +390,8 @@ User: ${formattedMessage}`;
|
|||||||
custom_instructions: config.custom_instructions || null,
|
custom_instructions: config.custom_instructions || null,
|
||||||
mcp_servers_json: config.mcp_servers_json || null,
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -701,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()) {
|
||||||
@@ -917,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>
|
||||||
@@ -942,7 +1011,7 @@ User: ${formattedMessage}`;
|
|||||||
: "Connect to Claude first..."}
|
: "Connect to Claude first..."}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
rows={1}
|
rows={1}
|
||||||
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
|
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
||||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
||||||
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
||||||
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
@@ -957,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"
|
||||||
@@ -1022,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>
|
|
||||||
|
|||||||
@@ -3,17 +3,22 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
|
|
||||||
let memoryFiles: string[] = $state([]);
|
interface MemoryFileInfo {
|
||||||
|
path: string;
|
||||||
|
heading: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoryFilesResponse {
|
||||||
|
files: MemoryFileInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let memoryFiles: MemoryFileInfo[] = $state([]);
|
||||||
let selectedFile: string | null = $state(null);
|
let selectedFile: string | null = $state(null);
|
||||||
let fileContent: string = $state("");
|
let fileContent: string = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let isPanelOpen = $state(false);
|
let isPanelOpen = $state(false);
|
||||||
|
|
||||||
interface MemoryFilesResponse {
|
|
||||||
files: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMemoryFiles() {
|
async function loadMemoryFiles() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = null;
|
error = null;
|
||||||
@@ -49,6 +54,10 @@
|
|||||||
return path.split("/").pop() || path;
|
return path.split("/").pop() || path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDisplayName(file: MemoryFileInfo): string {
|
||||||
|
return file.heading ?? getFileName(file.path);
|
||||||
|
}
|
||||||
|
|
||||||
function togglePanel() {
|
function togglePanel() {
|
||||||
isPanelOpen = !isPanelOpen;
|
isPanelOpen = !isPanelOpen;
|
||||||
if (isPanelOpen && memoryFiles.length === 0) {
|
if (isPanelOpen && memoryFiles.length === 0) {
|
||||||
@@ -151,11 +160,12 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="panel-layout">
|
<div class="panel-layout">
|
||||||
<div class="file-list">
|
<div class="file-list">
|
||||||
{#each memoryFiles as file (file)}
|
{#each memoryFiles as file (file.path)}
|
||||||
<button
|
<button
|
||||||
class="file-item"
|
class="file-item"
|
||||||
class:active={selectedFile === file}
|
class:active={selectedFile === file.path}
|
||||||
onclick={() => loadFileContent(file)}
|
onclick={() => loadFileContent(file.path)}
|
||||||
|
title={getFileName(file.path)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="file-icon"
|
class="file-icon"
|
||||||
@@ -171,7 +181,7 @@
|
|||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="file-name">{getFileName(file)}</span>
|
<span class="file-name">{getDisplayName(file)}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +189,12 @@
|
|||||||
<div class="file-viewer">
|
<div class="file-viewer">
|
||||||
{#if selectedFile && fileContent}
|
{#if selectedFile && fileContent}
|
||||||
<div class="viewer-header">
|
<div class="viewer-header">
|
||||||
<h4>{getFileName(selectedFile)}</h4>
|
{#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
|
||||||
|
<h4>{getDisplayName(activeFile)}</h4>
|
||||||
|
{#if activeFile.heading}
|
||||||
|
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer-content">
|
<div class="viewer-content">
|
||||||
<Markdown content={fileContent} />
|
<Markdown content={fileContent} />
|
||||||
@@ -438,6 +453,13 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewer-filename {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-content {
|
.viewer-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// Mirror pure logic functions from MemoryBrowserPanel.svelte
|
||||||
|
|
||||||
|
interface MemoryFileInfo {
|
||||||
|
path: string;
|
||||||
|
heading: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileName(path: string): string {
|
||||||
|
return path.split("/").pop() || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(file: MemoryFileInfo): string {
|
||||||
|
return file.heading ?? getFileName(file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getFileName", () => {
|
||||||
|
it("extracts the filename from an absolute Unix path", () => {
|
||||||
|
expect(getFileName("/home/naomi/.claude/projects/foo/memory/MEMORY.md")).toBe("MEMORY.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts the filename from a nested path", () => {
|
||||||
|
expect(getFileName("/home/naomi/.claude/projects/foo/memory/debugging.md")).toBe(
|
||||||
|
"debugging.md"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the path itself when there is no slash", () => {
|
||||||
|
expect(getFileName("MEMORY.md")).toBe("MEMORY.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the path when the path ends with a slash (empty filename)", () => {
|
||||||
|
// split('/').pop() returns '' for trailing slash → falls back to full path
|
||||||
|
expect(getFileName("/some/dir/")).toBe("/some/dir/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles single-component paths", () => {
|
||||||
|
expect(getFileName("notes.md")).toBe("notes.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Windows-style paths passed as Unix strings", () => {
|
||||||
|
// If the path contains no forward slashes, the whole string is the filename
|
||||||
|
expect(getFileName("C:\\Users\\naomi\\memory\\file.md")).toBe(
|
||||||
|
"C:\\Users\\naomi\\memory\\file.md"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDisplayName", () => {
|
||||||
|
it("returns the heading when the file has one", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
|
||||||
|
heading: "Hikari Desktop - Memory",
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("Hikari Desktop - Memory");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the filename when heading is null", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "/home/naomi/.claude/projects/foo/memory/debugging.md",
|
||||||
|
heading: null,
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("debugging.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the filename when heading is an empty string stored as null", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "/home/naomi/.claude/projects/foo/memory/patterns.md",
|
||||||
|
heading: null,
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("patterns.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the heading even when it matches the filename", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
|
||||||
|
heading: "MEMORY",
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("MEMORY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a multi-word heading verbatim", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "/some/path/foo.md",
|
||||||
|
heading: "My Detailed Debugging Notes",
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("My Detailed Debugging Notes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back gracefully when path has no directory separators", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "lonely-file.md",
|
||||||
|
heading: null,
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("lonely-file.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -87,6 +87,8 @@
|
|||||||
custom_instructions: config.custom_instructions || null,
|
custom_instructions: config.custom_instructions || null,
|
||||||
mcp_servers_json: config.mcp_servers_json || null,
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
allowed_tools: newGrantedTools,
|
allowed_tools: newGrantedTools,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
import GitPanel from "./GitPanel.svelte";
|
import GitPanel from "./GitPanel.svelte";
|
||||||
import ProfilePanel from "./ProfilePanel.svelte";
|
import ProfilePanel from "./ProfilePanel.svelte";
|
||||||
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
||||||
|
import CastPanel from "./CastPanel.svelte";
|
||||||
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
||||||
import McpManagementPanel from "./McpManagementPanel.svelte";
|
import McpManagementPanel from "./McpManagementPanel.svelte";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
@@ -37,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";
|
||||||
@@ -56,9 +59,12 @@
|
|||||||
let showGitPanel = $state(false);
|
let showGitPanel = $state(false);
|
||||||
let showProfile = $state(false);
|
let showProfile = $state(false);
|
||||||
let showAgentMonitor = $state(false);
|
let showAgentMonitor = $state(false);
|
||||||
|
let showCastPanel = $state(false);
|
||||||
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({
|
||||||
@@ -99,6 +105,16 @@
|
|||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
|
custom_font_path: null,
|
||||||
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
@@ -152,11 +168,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]),
|
||||||
@@ -176,6 +188,9 @@
|
|||||||
custom_instructions: currentConfig.custom_instructions || null,
|
custom_instructions: currentConfig.custom_instructions || null,
|
||||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: currentConfig.use_worktree ?? false,
|
||||||
|
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||||
|
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,6 +209,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);
|
||||||
@@ -287,6 +348,9 @@
|
|||||||
custom_instructions: currentConfig.custom_instructions || null,
|
custom_instructions: currentConfig.custom_instructions || null,
|
||||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
|
use_worktree: currentConfig.use_worktree ?? false,
|
||||||
|
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||||
|
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -519,6 +583,20 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (showCastPanel = true)}
|
||||||
|
class="p-1 text-gray-500 icon-trans-hover"
|
||||||
|
title="Meet the Team"
|
||||||
|
>
|
||||||
|
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (showAgentMonitor = !showAgentMonitor)}
|
onclick={() => (showAgentMonitor = !showAgentMonitor)}
|
||||||
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
|
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
|
||||||
@@ -737,6 +815,10 @@
|
|||||||
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showCastPanel}
|
||||||
|
<CastPanel onClose={() => (showCastPanel = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showPluginPanel}
|
{#if showPluginPanel}
|
||||||
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -745,6 +827,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 {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* StatusBar Component Tests
|
||||||
|
*
|
||||||
|
* Tests the connection status colour and text helpers used by the
|
||||||
|
* StatusBar component to display the current Claude connection state.
|
||||||
|
*
|
||||||
|
* What this component does:
|
||||||
|
* - Shows a coloured indicator dot for the connection state
|
||||||
|
* - Shows a text label for the connection state
|
||||||
|
* - Provides connect/disconnect buttons
|
||||||
|
* - Contains the working directory input and browse button
|
||||||
|
* - Houses all toolbar action buttons (settings, stats, panels, etc.)
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Green dot and "Connected" label when Claude is running
|
||||||
|
* - [ ] Animated yellow dot and "Connecting..." label whilst connecting
|
||||||
|
* - [ ] Red dot and "Error" label on connection error
|
||||||
|
* - [ ] Grey dot and "Disconnected" label when not connected
|
||||||
|
* - [ ] Directory input is hidden when connected, visible when disconnected
|
||||||
|
* - [ ] Connect button transitions to Disconnect button on connection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
type ConnectionStatus = "connected" | "connecting" | "disconnected" | "error";
|
||||||
|
|
||||||
|
function getStatusColor(connectionStatus: ConnectionStatus): string {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case "connected":
|
||||||
|
return "bg-green-500";
|
||||||
|
case "connecting":
|
||||||
|
return "bg-yellow-500 animate-pulse";
|
||||||
|
case "error":
|
||||||
|
return "bg-red-500";
|
||||||
|
default:
|
||||||
|
return "bg-gray-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(connectionStatus: ConnectionStatus): string {
|
||||||
|
switch (connectionStatus) {
|
||||||
|
case "connected":
|
||||||
|
return "Connected";
|
||||||
|
case "connecting":
|
||||||
|
return "Connecting...";
|
||||||
|
case "error":
|
||||||
|
return "Error";
|
||||||
|
default:
|
||||||
|
return "Disconnected";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("getStatusColor", () => {
|
||||||
|
it("returns green for connected status", () => {
|
||||||
|
expect(getStatusColor("connected")).toBe("bg-green-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns animated yellow for connecting status", () => {
|
||||||
|
expect(getStatusColor("connecting")).toBe("bg-yellow-500 animate-pulse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns red for error status", () => {
|
||||||
|
expect(getStatusColor("error")).toBe("bg-red-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns grey for disconnected status", () => {
|
||||||
|
expect(getStatusColor("disconnected")).toBe("bg-gray-500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getStatusText", () => {
|
||||||
|
it("returns 'Connected' for connected status", () => {
|
||||||
|
expect(getStatusText("connected")).toBe("Connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'Connecting...' for connecting status", () => {
|
||||||
|
expect(getStatusText("connecting")).toBe("Connecting...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'Error' for error status", () => {
|
||||||
|
expect(getStatusText("error")).toBe("Error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'Disconnected' for disconnected status", () => {
|
||||||
|
expect(getStatusText("disconnected")).toBe("Disconnected");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||||
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import ConversationTabs from "./ConversationTabs.svelte";
|
import ConversationTabs from "./ConversationTabs.svelte";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
import HighlightedText from "./HighlightedText.svelte";
|
import HighlightedText from "./HighlightedText.svelte";
|
||||||
import ThinkingBlock from "./ThinkingBlock.svelte";
|
import ThinkingBlock from "./ThinkingBlock.svelte";
|
||||||
|
import ToolCallBlock from "./ToolCallBlock.svelte";
|
||||||
import { searchState, searchQuery } from "$lib/stores/search";
|
import { searchState, searchQuery } from "$lib/stores/search";
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||||
@@ -92,6 +95,14 @@
|
|||||||
return "terminal-error";
|
return "terminal-error";
|
||||||
case "thinking":
|
case "thinking":
|
||||||
return "terminal-thinking";
|
return "terminal-thinking";
|
||||||
|
case "rate-limit":
|
||||||
|
return "terminal-rate-limit";
|
||||||
|
case "compact-prompt":
|
||||||
|
return "terminal-compact-prompt";
|
||||||
|
case "worktree":
|
||||||
|
return "terminal-worktree";
|
||||||
|
case "config-change":
|
||||||
|
return "terminal-config-change";
|
||||||
default:
|
default:
|
||||||
return "terminal-default";
|
return "terminal-default";
|
||||||
}
|
}
|
||||||
@@ -109,6 +120,12 @@
|
|||||||
return "[tool]";
|
return "[tool]";
|
||||||
case "error":
|
case "error":
|
||||||
return "[error]";
|
return "[error]";
|
||||||
|
case "rate-limit":
|
||||||
|
return "[rate-limit]";
|
||||||
|
case "worktree":
|
||||||
|
return "[worktree]";
|
||||||
|
case "config-change":
|
||||||
|
return "[config]";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -187,6 +204,11 @@
|
|||||||
copiedMessageId = null;
|
copiedMessageId = null;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCompact() {
|
||||||
|
if (!currentConversationId) return;
|
||||||
|
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -209,7 +231,7 @@
|
|||||||
bind:this={terminalElement}
|
bind:this={terminalElement}
|
||||||
onscroll={handleScroll}
|
onscroll={handleScroll}
|
||||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
||||||
style="font-size: var(--terminal-font-size, 14px);"
|
style="font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
||||||
>
|
>
|
||||||
{#if lines.length === 0}
|
{#if lines.length === 0}
|
||||||
<div class="terminal-waiting italic">
|
<div class="terminal-waiting italic">
|
||||||
@@ -221,6 +243,18 @@
|
|||||||
{#if showThinking}
|
{#if showThinking}
|
||||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if line.type === "tool"}
|
||||||
|
<div
|
||||||
|
style={line.parentToolUseId
|
||||||
|
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||||
|
: ""}
|
||||||
|
>
|
||||||
|
<ToolCallBlock
|
||||||
|
toolName={line.toolName ?? null}
|
||||||
|
content={maskPaths(line.content, hidePaths)}
|
||||||
|
timestamp={line.timestamp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||||
@@ -259,10 +293,11 @@
|
|||||||
{#if getLinePrefix(line.type)}
|
{#if getLinePrefix(line.type)}
|
||||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if line.toolName}
|
{#if line.type === "compact-prompt"}
|
||||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
<button class="compact-action-btn" onclick={handleCompact}>
|
||||||
{/if}
|
⚡ Compact Conversation
|
||||||
{#if line.type === "assistant" || line.type === "user"}
|
</button>
|
||||||
|
{:else if line.type === "assistant" || line.type === "user"}
|
||||||
<div class="message-content-wrapper">
|
<div class="message-content-wrapper">
|
||||||
<Markdown
|
<Markdown
|
||||||
content={maskPaths(line.content, hidePaths)}
|
content={maskPaths(line.content, hidePaths)}
|
||||||
@@ -329,6 +364,42 @@
|
|||||||
color: var(--terminal-error, #f87171);
|
color: var(--terminal-error, #f87171);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-rate-limit {
|
||||||
|
color: var(--terminal-rate-limit, #fb923c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-compact-prompt {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-worktree {
|
||||||
|
color: var(--terminal-worktree, #34d399);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-config-change {
|
||||||
|
color: var(--terminal-config-change, #a78bfa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--terminal-error, #f87171);
|
||||||
|
color: var(--terminal-error, #f87171);
|
||||||
|
padding: 0.3em 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-action-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--terminal-error, #f87171) 15%, transparent);
|
||||||
|
color: var(--terminal-error, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-default {
|
.terminal-default {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* Terminal Component Tests
|
||||||
|
*
|
||||||
|
* Tests the pure helper functions extracted from the Terminal component:
|
||||||
|
* - getLineClass: maps line types to CSS class names
|
||||||
|
* - getLinePrefix: maps line types to display prefixes
|
||||||
|
* - formatTime: formats a Date as "HH:MM AM/PM"
|
||||||
|
* - isToolContentLong: checks if tool content exceeds collapse threshold
|
||||||
|
* - truncateToolContent: truncates long tool content with ellipsis
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] rate-limit lines appear in amber
|
||||||
|
* - [ ] error lines appear in red
|
||||||
|
* - [ ] tool lines appear in purple
|
||||||
|
* - [ ] system lines appear in grey italic
|
||||||
|
* - [ ] user lines appear in cyan
|
||||||
|
* - [ ] assistant lines appear in primary text colour
|
||||||
|
* - [ ] long tool content is collapsed by default with a toggle button
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// Mirror functions from Terminal.svelte for isolated testing
|
||||||
|
|
||||||
|
function getLineClass(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case "user":
|
||||||
|
return "terminal-user";
|
||||||
|
case "assistant":
|
||||||
|
return "terminal-assistant";
|
||||||
|
case "system":
|
||||||
|
return "terminal-system italic";
|
||||||
|
case "tool":
|
||||||
|
return "terminal-tool";
|
||||||
|
case "error":
|
||||||
|
return "terminal-error";
|
||||||
|
case "thinking":
|
||||||
|
return "terminal-thinking";
|
||||||
|
case "rate-limit":
|
||||||
|
return "terminal-rate-limit";
|
||||||
|
case "compact-prompt":
|
||||||
|
return "terminal-compact-prompt";
|
||||||
|
case "worktree":
|
||||||
|
return "terminal-worktree";
|
||||||
|
case "config-change":
|
||||||
|
return "terminal-config-change";
|
||||||
|
default:
|
||||||
|
return "terminal-default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinePrefix(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case "user":
|
||||||
|
return ">";
|
||||||
|
case "assistant":
|
||||||
|
return "";
|
||||||
|
case "system":
|
||||||
|
return "[system]";
|
||||||
|
case "tool":
|
||||||
|
return "[tool]";
|
||||||
|
case "error":
|
||||||
|
return "[error]";
|
||||||
|
case "rate-limit":
|
||||||
|
return "[rate-limit]";
|
||||||
|
case "worktree":
|
||||||
|
return "[worktree]";
|
||||||
|
case "config-change":
|
||||||
|
return "[config]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_COLLAPSE_THRESHOLD = 60;
|
||||||
|
|
||||||
|
function isToolContentLong(content: string): boolean {
|
||||||
|
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateToolContent(content: string): string {
|
||||||
|
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("getLineClass", () => {
|
||||||
|
it("returns terminal-user for user lines", () => {
|
||||||
|
expect(getLineClass("user")).toBe("terminal-user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-assistant for assistant lines", () => {
|
||||||
|
expect(getLineClass("assistant")).toBe("terminal-assistant");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-system italic for system lines", () => {
|
||||||
|
expect(getLineClass("system")).toBe("terminal-system italic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-tool for tool lines", () => {
|
||||||
|
expect(getLineClass("tool")).toBe("terminal-tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-error for error lines", () => {
|
||||||
|
expect(getLineClass("error")).toBe("terminal-error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-thinking for thinking lines", () => {
|
||||||
|
expect(getLineClass("thinking")).toBe("terminal-thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-rate-limit for rate-limit lines", () => {
|
||||||
|
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-compact-prompt for compact-prompt lines", () => {
|
||||||
|
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-worktree for worktree lines", () => {
|
||||||
|
expect(getLineClass("worktree")).toBe("terminal-worktree");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-config-change for config-change lines", () => {
|
||||||
|
expect(getLineClass("config-change")).toBe("terminal-config-change");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns terminal-default for unknown line types", () => {
|
||||||
|
expect(getLineClass("unknown")).toBe("terminal-default");
|
||||||
|
expect(getLineClass("")).toBe("terminal-default");
|
||||||
|
expect(getLineClass("random-future-type")).toBe("terminal-default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getLinePrefix", () => {
|
||||||
|
it("returns > for user lines", () => {
|
||||||
|
expect(getLinePrefix("user")).toBe(">");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for assistant lines", () => {
|
||||||
|
expect(getLinePrefix("assistant")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [system] for system lines", () => {
|
||||||
|
expect(getLinePrefix("system")).toBe("[system]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [tool] for tool lines", () => {
|
||||||
|
expect(getLinePrefix("tool")).toBe("[tool]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [error] for error lines", () => {
|
||||||
|
expect(getLinePrefix("error")).toBe("[error]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [rate-limit] for rate-limit lines", () => {
|
||||||
|
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for compact-prompt lines (button renders instead)", () => {
|
||||||
|
expect(getLinePrefix("compact-prompt")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [worktree] for worktree lines", () => {
|
||||||
|
expect(getLinePrefix("worktree")).toBe("[worktree]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [config] for config-change lines", () => {
|
||||||
|
expect(getLinePrefix("config-change")).toBe("[config]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for thinking lines (no prefix)", () => {
|
||||||
|
expect(getLinePrefix("thinking")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for unknown line types", () => {
|
||||||
|
expect(getLinePrefix("unknown")).toBe("");
|
||||||
|
expect(getLinePrefix("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTime", () => {
|
||||||
|
it("formats time in 12-hour format with AM/PM", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 14, 35);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats afternoon times correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 14, 35);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("02:35");
|
||||||
|
expect(formatted.toUpperCase()).toContain("PM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats morning times correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 9, 5);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("09:05");
|
||||||
|
expect(formatted.toUpperCase()).toContain("AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats midnight correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 0, 0);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("12:00");
|
||||||
|
expect(formatted.toUpperCase()).toContain("AM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats noon correctly", () => {
|
||||||
|
const date = new Date(2026, 1, 7, 12, 0);
|
||||||
|
const formatted = formatTime(date);
|
||||||
|
expect(formatted).toContain("12:00");
|
||||||
|
expect(formatted.toUpperCase()).toContain("PM");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isToolContentLong", () => {
|
||||||
|
it("returns false for content at or below the threshold", () => {
|
||||||
|
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
|
||||||
|
expect(isToolContentLong(exactThreshold)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for content exceeding the threshold", () => {
|
||||||
|
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
|
||||||
|
expect(isToolContentLong(overThreshold)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for short content", () => {
|
||||||
|
expect(isToolContentLong("short")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for empty content", () => {
|
||||||
|
expect(isToolContentLong("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("truncateToolContent", () => {
|
||||||
|
it("truncates content to the threshold length with an ellipsis", () => {
|
||||||
|
const long = "x".repeat(100);
|
||||||
|
const result = truncateToolContent(long);
|
||||||
|
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
|
||||||
|
const short = "hello";
|
||||||
|
const result = truncateToolContent(short);
|
||||||
|
expect(result).toBe("hello…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the unicode ellipsis character (not three dots)", () => {
|
||||||
|
const long = "x".repeat(100);
|
||||||
|
const result = truncateToolContent(long);
|
||||||
|
expect(result.endsWith("…")).toBe(true);
|
||||||
|
expect(result.endsWith("...")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,141 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
toolName: string | null;
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { toolName, content, timestamp }: Props = $props();
|
||||||
|
let isExpanded = $state(false);
|
||||||
|
|
||||||
|
function toggleExpanded() {
|
||||||
|
isExpanded = !isExpanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tool-block">
|
||||||
|
<button class="tool-header" onclick={toggleExpanded} type="button">
|
||||||
|
<span class="tool-timestamp">{formatTime(timestamp)}</span>
|
||||||
|
<svg
|
||||||
|
class="tool-icon"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{#if toolName}
|
||||||
|
<span class="tool-name">[{toolName}]</span>
|
||||||
|
{/if}
|
||||||
|
<span class="tool-label">{content}</span>
|
||||||
|
<svg
|
||||||
|
class="chevron"
|
||||||
|
class:expanded={isExpanded}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isExpanded}
|
||||||
|
<div class="tool-content">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tool-block {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--terminal-tool, #c084fc);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-header:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-timestamp {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-name {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--terminal-tool-name, #ddd6fe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-label {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
font-style: italic;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron.expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-content {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
color: var(--terminal-tool, #c084fc);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -106,6 +106,8 @@
|
|||||||
custom_instructions: config.custom_instructions || null,
|
custom_instructions: config.custom_instructions || null,
|
||||||
mcp_servers_json: config.mcp_servers_json || null,
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
allowed_tools: grantedToolsList,
|
allowed_tools: grantedToolsList,
|
||||||
|
use_worktree: config.use_worktree ?? false,
|
||||||
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
import { notificationManager } from "./notificationManager";
|
||||||
|
import { sendTerminalNotification } from "./terminalNotifier";
|
||||||
|
import { NotificationType, NOTIFICATION_SOUNDS } from "./types";
|
||||||
|
|
||||||
|
vi.mock("./soundPlayer", () => ({
|
||||||
|
soundPlayer: { play: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./terminalNotifier", () => ({
|
||||||
|
sendTerminalNotification: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("NotificationManager", () => {
|
||||||
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notify()", () => {
|
||||||
|
it("calls the first notification method by default", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"send_windows_notification",
|
||||||
|
expect.objectContaining({
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a custom message to the notification", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.ERROR, "Custom error message");
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase,
|
||||||
|
body: "Custom error message",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the next method when the first fails", async () => {
|
||||||
|
setMockInvokeResult("send_windows_notification", new Error("Method 1 failed"));
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_toast", expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to terminal notification when all methods fail", async () => {
|
||||||
|
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_notify_send", new Error("failed"));
|
||||||
|
vi.mocked(isPermissionGranted).mockRejectedValueOnce(new Error("Permission check failed"));
|
||||||
|
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(sendTerminalNotification).toHaveBeenCalledWith(
|
||||||
|
NotificationType.SUCCESS,
|
||||||
|
"Task completed successfully!"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default SUCCESS message when none is provided", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Task completed successfully!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default ERROR message", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.ERROR);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Something went wrong...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default PERMISSION message", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.PERMISSION);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Permission needed to continue",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default CONNECTION message", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.CONNECTION);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Successfully connected to Claude Code",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default TASK_START message", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.TASK_START);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Starting task...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default COST_ALERT message", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.COST_ALERT);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "You've exceeded your cost threshold!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the fallback default message for unhandled types", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.ACHIEVEMENT);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Notification",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("helper methods", () => {
|
||||||
|
it("notifySuccess calls notify with SUCCESS type and default message", async () => {
|
||||||
|
await notificationManager.notifySuccess();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
|
||||||
|
body: "Task completed successfully!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifySuccess passes a custom message", async () => {
|
||||||
|
await notificationManager.notifySuccess("All done!");
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "All done!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifyError calls notify with ERROR type", async () => {
|
||||||
|
await notificationManager.notifyError();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase,
|
||||||
|
body: "Something went wrong...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifyPermission calls notify with PERMISSION type", async () => {
|
||||||
|
await notificationManager.notifyPermission();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.PERMISSION].phrase,
|
||||||
|
body: "Permission needed to continue",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifyConnection calls notify with CONNECTION type", async () => {
|
||||||
|
await notificationManager.notifyConnection();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.CONNECTION].phrase,
|
||||||
|
body: "Successfully connected to Claude Code",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifyTaskStart calls notify with TASK_START type", async () => {
|
||||||
|
await notificationManager.notifyTaskStart();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.TASK_START].phrase,
|
||||||
|
body: "Starting task...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifyCostAlert calls notify with COST_ALERT type", async () => {
|
||||||
|
await notificationManager.notifyCostAlert();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.COST_ALERT].phrase,
|
||||||
|
body: "You've exceeded your cost threshold!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Tauri plugin notification method (Method 4)", () => {
|
||||||
|
it("sends notification when permission is already granted", async () => {
|
||||||
|
const { isPermissionGranted, sendNotification } =
|
||||||
|
await import("@tauri-apps/plugin-notification");
|
||||||
|
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
||||||
|
vi.mocked(isPermissionGranted).mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests permission and sends notification when not yet granted", async () => {
|
||||||
|
const { isPermissionGranted, requestPermission, sendNotification } =
|
||||||
|
await import("@tauri-apps/plugin-notification");
|
||||||
|
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
||||||
|
vi.mocked(isPermissionGranted).mockResolvedValueOnce(false);
|
||||||
|
vi.mocked(requestPermission).mockResolvedValueOnce("granted");
|
||||||
|
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(requestPermission).toHaveBeenCalledWith();
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ body: "Task completed successfully!" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to next method when permission is denied", async () => {
|
||||||
|
const { isPermissionGranted, requestPermission } =
|
||||||
|
await import("@tauri-apps/plugin-notification");
|
||||||
|
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
||||||
|
vi.mocked(isPermissionGranted).mockResolvedValueOnce(false);
|
||||||
|
vi.mocked(requestPermission).mockResolvedValueOnce("denied");
|
||||||
|
setMockInvokeResult("send_notify_send", new Error("failed"));
|
||||||
|
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(sendTerminalNotification).toHaveBeenCalledWith(
|
||||||
|
NotificationType.SUCCESS,
|
||||||
|
"Task completed successfully!"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -232,6 +232,53 @@ describe("notifications", () => {
|
|||||||
// Should not throw
|
// Should not throw
|
||||||
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("play warns when audio type is not in the cache", async () => {
|
||||||
|
const { soundPlayer } = await import("./soundPlayer");
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
soundPlayer.setEnabled(true);
|
||||||
|
await soundPlayer.play("nonexistent" as NotificationType);
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith("No audio found for notification type: nonexistent");
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("play catches errors from audio playback", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
class FailingAudio {
|
||||||
|
volume = 1;
|
||||||
|
preload = "auto";
|
||||||
|
|
||||||
|
cloneNode() {
|
||||||
|
const clone = new FailingAudio();
|
||||||
|
clone.volume = this.volume;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(): Promise<void> {
|
||||||
|
throw new Error("Playback blocked by browser");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalAudio = globalThis.Audio;
|
||||||
|
globalThis.Audio = FailingAudio as unknown as typeof Audio;
|
||||||
|
|
||||||
|
const { soundPlayer: freshPlayer } = await import("./soundPlayer");
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
freshPlayer.setEnabled(true);
|
||||||
|
await freshPlayer.play(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to play notification sound:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
globalThis.Audio = originalAudio;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("NotificationManager class", () => {
|
describe("NotificationManager class", () => {
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Notification Rules Tests
|
||||||
|
*
|
||||||
|
* Tests the connection status change handler, which fires a connection
|
||||||
|
* notification sound exactly once per reconnect cycle.
|
||||||
|
*
|
||||||
|
* What this module does:
|
||||||
|
* - Tracks the previous connection status in module-level state
|
||||||
|
* - Fires a notification only when transitioning from a non-connected
|
||||||
|
* state (disconnected/connecting) to "connected"
|
||||||
|
* - Ignores the initial connection (null → connected) to avoid noisy
|
||||||
|
* notifications on app start
|
||||||
|
* - Provides no-op handlers for tool execution and user messages
|
||||||
|
* (reserved for future notification rules)
|
||||||
|
* - cleanupNotificationRules() resets tracking state on teardown
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const { mockNotifyConnection } = vi.hoisted(() => ({
|
||||||
|
mockNotifyConnection: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./notificationManager", () => ({
|
||||||
|
notificationManager: {
|
||||||
|
notifyConnection: mockNotifyConnection,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
handleConnectionStatusChange,
|
||||||
|
handleToolExecution,
|
||||||
|
handleNewUserMessage,
|
||||||
|
initializeNotificationRules,
|
||||||
|
cleanupNotificationRules,
|
||||||
|
} from "./rules";
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("handleConnectionStatusChange", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockNotifyConnection.mockReset();
|
||||||
|
cleanupNotificationRules(); // Reset module-level previousConnectionStatus to null
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial connection (null → status)", () => {
|
||||||
|
it("does not notify on first connection (null → connected)", () => {
|
||||||
|
// previousConnectionStatus is null (falsy), so condition is not met
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when disconnecting from initial state (null → disconnected)", () => {
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when entering connecting from initial state (null → connecting)", () => {
|
||||||
|
handleConnectionStatusChange("connecting");
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconnection (disconnected → connected)", () => {
|
||||||
|
it("notifies when reconnecting after a disconnection", () => {
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies exactly once per reconnect", () => {
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconnection (connecting → connected)", () => {
|
||||||
|
it("notifies when transitioning from connecting to connected", () => {
|
||||||
|
handleConnectionStatusChange("connecting");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("already connected (connected → connected)", () => {
|
||||||
|
it("does not notify when already connected", () => {
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected"); // First connection — notifies
|
||||||
|
mockNotifyConnection.mockReset();
|
||||||
|
|
||||||
|
handleConnectionStatusChange("connected"); // Second — same status, no notify
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disconnecting (connected → disconnected)", () => {
|
||||||
|
it("does not notify when disconnecting", () => {
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
mockNotifyConnection.mockReset();
|
||||||
|
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple reconnect cycles", () => {
|
||||||
|
it("notifies once per reconnect cycle", () => {
|
||||||
|
// First cycle
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mockNotifyConnection.mockReset();
|
||||||
|
|
||||||
|
// Second cycle
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanupNotificationRules", () => {
|
||||||
|
it("resets state so the next connection is treated as the first", () => {
|
||||||
|
// Establish a known previous status
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
// Now cleanup
|
||||||
|
cleanupNotificationRules();
|
||||||
|
// After cleanup, previousConnectionStatus is null again
|
||||||
|
// So the next "connected" should NOT notify (treated as initial connection)
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no-op handlers", () => {
|
||||||
|
it("handleToolExecution does not throw", () => {
|
||||||
|
expect(() => handleToolExecution("Bash")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleNewUserMessage does not throw", () => {
|
||||||
|
expect(() => handleNewUserMessage()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializeNotificationRules does not throw", () => {
|
||||||
|
expect(() => initializeNotificationRules()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NotificationType } from "./types";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
|
import { sendTerminalNotification } from "./terminalNotifier";
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/claude", () => ({
|
||||||
|
claudeStore: {
|
||||||
|
addLine: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("sendTerminalNotification", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a system line for success type with sparkle emoji", () => {
|
||||||
|
sendTerminalNotification(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("✨"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a system line for error type with cross emoji", () => {
|
||||||
|
sendTerminalNotification(NotificationType.ERROR);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("❌"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a system line for permission type with lock emoji", () => {
|
||||||
|
sendTerminalNotification(NotificationType.PERMISSION);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔐"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a system line for connection type with link emoji", () => {
|
||||||
|
sendTerminalNotification(NotificationType.CONNECTION);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔗"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a system line for task_start type with rocket emoji", () => {
|
||||||
|
sendTerminalNotification(NotificationType.TASK_START);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🚀"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the optional message in the notification", () => {
|
||||||
|
sendTerminalNotification(NotificationType.SUCCESS, "Custom message text");
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
expect.stringContaining("Custom message text")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the sound phrase as the notification title", () => {
|
||||||
|
sendTerminalNotification(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
expect.stringContaining("I'm done!")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { testAllNotifications } from "./testNotifications";
|
||||||
|
|
||||||
|
vi.mock("./notificationManager", () => ({
|
||||||
|
notificationManager: {
|
||||||
|
notifySuccess: vi.fn().mockResolvedValue(undefined),
|
||||||
|
notifyError: vi.fn().mockResolvedValue(undefined),
|
||||||
|
notifyPermission: vi.fn().mockResolvedValue(undefined),
|
||||||
|
notifyConnection: vi.fn().mockResolvedValue(undefined),
|
||||||
|
notifyTaskStart: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("testNotifications", () => {
|
||||||
|
describe("window assignment", () => {
|
||||||
|
it("assigns testAllNotifications to window.testNotifications", async () => {
|
||||||
|
// The module-level if block runs on import — reimport to ensure it ran
|
||||||
|
await import("./testNotifications");
|
||||||
|
|
||||||
|
expect((window as unknown as { testNotifications: unknown }).testNotifications).toBe(
|
||||||
|
testAllNotifications
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("testAllNotifications", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is an async function", () => {
|
||||||
|
expect(typeof testAllNotifications).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules all five notification type calls", async () => {
|
||||||
|
const { notificationManager } = await import("./notificationManager");
|
||||||
|
|
||||||
|
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await testAllNotifications();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(notificationManager.notifySuccess).toHaveBeenCalledWith("Test task completed!");
|
||||||
|
expect(notificationManager.notifyError).toHaveBeenCalledWith("Test error occurred!");
|
||||||
|
expect(notificationManager.notifyPermission).toHaveBeenCalledWith("Test permission request!");
|
||||||
|
expect(notificationManager.notifyConnection).toHaveBeenCalledWith(
|
||||||
|
"Test connection established!"
|
||||||
|
);
|
||||||
|
expect(notificationManager.notifyTaskStart).toHaveBeenCalledWith("Test task starting!");
|
||||||
|
|
||||||
|
consoleLogSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { sendWSLNotification } from "./wslNotificationHelper";
|
||||||
|
|
||||||
|
// platform() is mocked in vitest.setup.ts to return "linux" by default
|
||||||
|
|
||||||
|
describe("sendWSLNotification", () => {
|
||||||
|
it("invokes send_windows_notification when platform is windows", async () => {
|
||||||
|
vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited<ReturnType<typeof platform>>);
|
||||||
|
|
||||||
|
await sendWSLNotification("Test Title", "Test body");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: "Test Title",
|
||||||
|
body: "Test body",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invoke on non-Windows platforms", async () => {
|
||||||
|
// Default mock returns "linux"
|
||||||
|
await sendWSLNotification("Test Title", "Test body");
|
||||||
|
|
||||||
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles invoke errors gracefully and logs them", async () => {
|
||||||
|
vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited<ReturnType<typeof platform>>);
|
||||||
|
vi.mocked(invoke).mockRejectedValueOnce(new Error("Windows notification failed"));
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await sendWSLNotification("Title", "Body");
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to send Windows notification:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NotificationType } from "$lib/notifications/types";
|
||||||
|
|
||||||
|
const mockPlay = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("$lib/notifications", () => ({
|
||||||
|
soundPlayer: {
|
||||||
|
play: mockPlay,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("achievement sounds", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPlay.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("playAchievementSound", () => {
|
||||||
|
it("plays the achievement notification sound", async () => {
|
||||||
|
const { playAchievementSound } = await import("./achievement");
|
||||||
|
playAchievementSound();
|
||||||
|
expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("testAchievementSound", () => {
|
||||||
|
it("calls playAchievementSound without throwing", async () => {
|
||||||
|
const { testAchievementSound } = await import("./achievement");
|
||||||
|
expect(() => testAchievementSound()).not.toThrow();
|
||||||
|
expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches errors from the sound player gracefully", async () => {
|
||||||
|
mockPlay.mockImplementation(() => {
|
||||||
|
throw new Error("Audio not available");
|
||||||
|
});
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const { testAchievementSound } = await import("./achievement");
|
||||||
|
expect(() => testAchievementSound()).not.toThrow();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Error playing achievement sound:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { setMockInvokeResult, emitMockEvent } from "../../../vitest.setup";
|
||||||
|
import {
|
||||||
|
achievementsStore,
|
||||||
|
unlockedAchievements,
|
||||||
|
lockedAchievements,
|
||||||
|
achievementsByRarity,
|
||||||
|
achievementProgress,
|
||||||
|
initAchievementsListener,
|
||||||
|
} from "./achievements";
|
||||||
|
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||||
|
import { playAchievementSound } from "$lib/sounds/achievement";
|
||||||
|
|
||||||
|
vi.mock("$lib/sounds/achievement", () => ({
|
||||||
|
playAchievementSound: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to build a minimal unlock event
|
||||||
|
function makeEvent(id: AchievementUnlockedEvent["achievement"]["id"]): AchievementUnlockedEvent {
|
||||||
|
return {
|
||||||
|
achievement: {
|
||||||
|
id,
|
||||||
|
name: "Test",
|
||||||
|
description: "Test achievement",
|
||||||
|
icon: "🏆",
|
||||||
|
unlocked_at: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("achievementsStore initial state", () => {
|
||||||
|
it("all achievements start as locked", () => {
|
||||||
|
const state = get(achievementsStore);
|
||||||
|
expect(state.achievements["FirstSteps"].unlocked).toBe(false);
|
||||||
|
expect(state.achievements["GrowingStrong"].unlocked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("totalUnlocked starts at 0", () => {
|
||||||
|
expect(get(achievementsStore).totalUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lastUnlocked starts as null", () => {
|
||||||
|
expect(get(achievementsStore).lastUnlocked).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derived stores initial state", () => {
|
||||||
|
it("unlockedAchievements is initially empty", () => {
|
||||||
|
expect(get(unlockedAchievements)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lockedAchievements contains all achievements initially", () => {
|
||||||
|
const locked = get(lockedAchievements);
|
||||||
|
const total = Object.keys(get(achievementsStore).achievements).length;
|
||||||
|
expect(locked.length).toBe(total);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("achievementsByRarity groups achievements into rarity buckets", () => {
|
||||||
|
const byRarity = get(achievementsByRarity);
|
||||||
|
expect(byRarity.common).toBeInstanceOf(Array);
|
||||||
|
expect(byRarity.rare).toBeInstanceOf(Array);
|
||||||
|
expect(byRarity.epic).toBeInstanceOf(Array);
|
||||||
|
expect(byRarity.legendary).toBeInstanceOf(Array);
|
||||||
|
expect(byRarity.common.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("achievementProgress shows zero unlocked initially", () => {
|
||||||
|
const progress = get(achievementProgress);
|
||||||
|
expect(progress.unlocked).toBe(0);
|
||||||
|
expect(progress.total).toBeGreaterThan(0);
|
||||||
|
expect(progress.percentage).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("achievementsStore.unlockAchievement", () => {
|
||||||
|
it("marks the achievement as unlocked and updates totalUnlocked", () => {
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("GrowingStrong"));
|
||||||
|
const state = get(achievementsStore);
|
||||||
|
expect(state.achievements["GrowingStrong"].unlocked).toBe(true);
|
||||||
|
expect(state.totalUnlocked).toBe(1);
|
||||||
|
expect(state.lastUnlocked?.id).toBe("GrowingStrong");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets unlockedAt from the event's unlocked_at timestamp", () => {
|
||||||
|
achievementsStore.unlockAchievement({
|
||||||
|
achievement: {
|
||||||
|
id: "BlossomingCoder",
|
||||||
|
name: "Blossoming Coder",
|
||||||
|
description: "100k tokens",
|
||||||
|
icon: "🌸",
|
||||||
|
unlocked_at: "2026-01-15T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const state = get(achievementsStore);
|
||||||
|
expect(state.achievements["BlossomingCoder"].unlockedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the achievement is already unlocked", () => {
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
|
||||||
|
const firstTotal = get(achievementsStore).totalUnlocked;
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
|
||||||
|
expect(get(achievementsStore).totalUnlocked).toBe(firstTotal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls playAchievementSound when playSound is true (default)", () => {
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("TokenBillionaire"));
|
||||||
|
expect(playAchievementSound).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call playAchievementSound when playSound is false", () => {
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("TokenTreasure"), false);
|
||||||
|
expect(playAchievementSound).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs an error when playAchievementSound throws", () => {
|
||||||
|
vi.mocked(playAchievementSound).mockImplementationOnce(() => {
|
||||||
|
throw new Error("Sound failed");
|
||||||
|
});
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("HelloWorld"));
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to play achievement sound:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derived stores after unlocks", () => {
|
||||||
|
it("unlockedAchievements includes previously unlocked achievements", () => {
|
||||||
|
const unlocked = get(unlockedAchievements);
|
||||||
|
expect(unlocked.some((a) => a.id === "GrowingStrong")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lockedAchievements excludes previously unlocked achievements", () => {
|
||||||
|
const locked = get(lockedAchievements);
|
||||||
|
expect(locked.some((a) => a.id === "GrowingStrong")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("achievementProgress reflects the current unlocked count", () => {
|
||||||
|
const progress = get(achievementProgress);
|
||||||
|
expect(progress.unlocked).toBeGreaterThan(0);
|
||||||
|
expect(progress.percentage).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("achievementsStore.updateProgress", () => {
|
||||||
|
it("updates the progress value for an achievement", () => {
|
||||||
|
achievementsStore.updateProgress("FirstMessage", 50);
|
||||||
|
expect(get(achievementsStore).achievements["FirstMessage"].progress).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("achievementsStore.reset", () => {
|
||||||
|
it("resets totalUnlocked to 0 and lastUnlocked to null", () => {
|
||||||
|
achievementsStore.reset();
|
||||||
|
const state = get(achievementsStore);
|
||||||
|
expect(state.totalUnlocked).toBe(0);
|
||||||
|
expect(state.lastUnlocked).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initAchievementsListener", () => {
|
||||||
|
it("unlocks an achievement when the achievement:unlocked event fires", async () => {
|
||||||
|
await initAchievementsListener();
|
||||||
|
emitMockEvent("achievement:unlocked", makeEvent("FirstSteps"));
|
||||||
|
expect(get(achievementsStore).achievements["FirstSteps"].unlocked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads saved achievements from the backend without playing sounds", async () => {
|
||||||
|
setMockInvokeResult("load_saved_achievements", [makeEvent("ConversationStarter")]);
|
||||||
|
await initAchievementsListener();
|
||||||
|
expect(get(achievementsStore).achievements["ConversationStarter"].unlocked).toBe(true);
|
||||||
|
expect(playAchievementSound).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs an error when loading saved achievements fails", async () => {
|
||||||
|
setMockInvokeResult("load_saved_achievements", new Error("Storage unavailable"));
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
await initAchievementsListener();
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to load saved achievements:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,15 @@ import { describe, it, expect, beforeEach } from "vitest";
|
|||||||
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
|
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import type { AgentInfo } from "$lib/types/agents";
|
import type { AgentInfo } from "$lib/types/agents";
|
||||||
|
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
describe("agents store", () => {
|
describe("agents store", () => {
|
||||||
const conversationId = "test-conversation-1";
|
const conversationId = "test-conversation-1";
|
||||||
const otherConversationId = "test-conversation-2";
|
const otherConversationId = "test-conversation-2";
|
||||||
|
|
||||||
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
|
type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">;
|
||||||
|
|
||||||
|
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
|
||||||
toolUseId: "toolu_test123",
|
toolUseId: "toolu_test123",
|
||||||
description: "Test agent",
|
description: "Test agent",
|
||||||
subagentType: "Explore",
|
subagentType: "Explore",
|
||||||
@@ -37,7 +40,29 @@ describe("agents store", () => {
|
|||||||
|
|
||||||
const agents = get(getAgentsForConversation(conversationId));
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
expect(agents).toHaveLength(1);
|
expect(agents).toHaveLength(1);
|
||||||
expect(agents[0]).toEqual(agent);
|
expect(agents[0]).toMatchObject(agent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns a character name and avatar to added agents", () => {
|
||||||
|
const agent = createMockAgent();
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
const validNames = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
expect(validNames).toContain(agents[0].characterName);
|
||||||
|
expect(agents[0].characterAvatar).toMatch(/^https:\/\//u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids duplicate character names across agents when possible", () => {
|
||||||
|
// Add 6 agents - each should ideally get a unique character
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
agentStore.addAgent(conversationId, createMockAgent({ toolUseId: `tool${i.toString()}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
const names = agents.map((a) => a.characterName);
|
||||||
|
const uniqueNames = new Set(names);
|
||||||
|
expect(uniqueNames.size).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds multiple agents to the same conversation", () => {
|
it("adds multiple agents to the same conversation", () => {
|
||||||
@@ -49,8 +74,8 @@ describe("agents store", () => {
|
|||||||
|
|
||||||
const agents = get(getAgentsForConversation(conversationId));
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
expect(agents).toHaveLength(2);
|
expect(agents).toHaveLength(2);
|
||||||
expect(agents[0]).toEqual(agent1);
|
expect(agents[0]).toMatchObject(agent1);
|
||||||
expect(agents[1]).toEqual(agent2);
|
expect(agents[1]).toMatchObject(agent2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps agents in different conversations separate", () => {
|
it("keeps agents in different conversations separate", () => {
|
||||||
@@ -65,8 +90,8 @@ describe("agents store", () => {
|
|||||||
|
|
||||||
expect(agents1).toHaveLength(1);
|
expect(agents1).toHaveLength(1);
|
||||||
expect(agents2).toHaveLength(1);
|
expect(agents2).toHaveLength(1);
|
||||||
expect(agents1[0]).toEqual(agent1);
|
expect(agents1[0]).toMatchObject(agent1);
|
||||||
expect(agents2[0]).toEqual(agent2);
|
expect(agents2[0]).toMatchObject(agent2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,6 +177,32 @@ describe("agents store", () => {
|
|||||||
const agents = get(getAgentsForConversation(conversationId));
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
expect(agents[0].status).toBe("running"); // Status unchanged
|
expect(agents[0].status).toBe("running"); // Status unchanged
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stores lastAssistantMessage when provided", () => {
|
||||||
|
const agent = createMockAgent({ status: "running" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.endAgent(
|
||||||
|
conversationId,
|
||||||
|
agent.toolUseId,
|
||||||
|
Date.now(),
|
||||||
|
false,
|
||||||
|
"Task completed successfully."
|
||||||
|
);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].lastAssistantMessage).toBe("Task completed successfully.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves lastAssistantMessage undefined when not provided", () => {
|
||||||
|
const agent = createMockAgent({ status: "running" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].lastAssistantMessage).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("markAllErrored", () => {
|
describe("markAllErrored", () => {
|
||||||
@@ -256,7 +307,7 @@ describe("agents store", () => {
|
|||||||
|
|
||||||
expect(agents1).toHaveLength(0);
|
expect(agents1).toHaveLength(0);
|
||||||
expect(agents2).toHaveLength(1);
|
expect(agents2).toHaveLength(1);
|
||||||
expect(agents2[0]).toEqual(agent2);
|
expect(agents2[0]).toMatchObject(agent2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing if conversation doesn't exist", () => {
|
it("does nothing if conversation doesn't exist", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import type { AgentInfo } from "$lib/types/agents";
|
import type { AgentInfo } from "$lib/types/agents";
|
||||||
|
import { assignCharacter } from "$lib/utils/agentCharacters";
|
||||||
|
|
||||||
// Map of conversation ID -> agents in that conversation
|
// Map of conversation ID -> agents in that conversation
|
||||||
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
|
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
|
||||||
@@ -8,12 +9,17 @@ function createAgentStore() {
|
|||||||
return {
|
return {
|
||||||
subscribe: agentsByConversation.subscribe,
|
subscribe: agentsByConversation.subscribe,
|
||||||
|
|
||||||
addAgent(conversationId: string, agent: AgentInfo) {
|
addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) {
|
||||||
agentsByConversation.update((state) => {
|
agentsByConversation.update((state) => {
|
||||||
const existing = state[conversationId] || [];
|
const existing = state[conversationId] || [];
|
||||||
|
const activeNames = existing.map((a) => a.characterName);
|
||||||
|
const character = assignCharacter(activeNames);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
[conversationId]: [...existing, agent],
|
[conversationId]: [
|
||||||
|
...existing,
|
||||||
|
{ ...agent, characterName: character.name, characterAvatar: character.avatar },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -39,7 +45,13 @@ function createAgentStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) {
|
endAgent(
|
||||||
|
conversationId: string,
|
||||||
|
toolUseId: string,
|
||||||
|
endedAt: number,
|
||||||
|
isError: boolean,
|
||||||
|
lastAssistantMessage?: string
|
||||||
|
) {
|
||||||
agentsByConversation.update((state) => {
|
agentsByConversation.update((state) => {
|
||||||
const agents = state[conversationId];
|
const agents = state[conversationId];
|
||||||
if (!agents) return state;
|
if (!agents) return state;
|
||||||
@@ -56,6 +68,7 @@ function createAgentStore() {
|
|||||||
endedAt,
|
endedAt,
|
||||||
status: isError ? "errored" : "completed",
|
status: isError ? "errored" : "completed",
|
||||||
durationMs,
|
durationMs,
|
||||||
|
lastAssistantMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { characterState, characterInfo } from "./character";
|
||||||
|
|
||||||
|
describe("characterState store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
characterState.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("starts in idle state", () => {
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setState", () => {
|
||||||
|
it("sets the character state", () => {
|
||||||
|
characterState.setState("thinking");
|
||||||
|
expect(get(characterState)).toBe("thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can set any valid state", () => {
|
||||||
|
const states = [
|
||||||
|
"idle",
|
||||||
|
"thinking",
|
||||||
|
"typing",
|
||||||
|
"coding",
|
||||||
|
"searching",
|
||||||
|
"mcp",
|
||||||
|
"permission",
|
||||||
|
"success",
|
||||||
|
"error",
|
||||||
|
] as const;
|
||||||
|
for (const state of states) {
|
||||||
|
characterState.setState(state);
|
||||||
|
expect(get(characterState)).toBe(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels any active temporary state timer", () => {
|
||||||
|
characterState.setTemporaryState("success", 5000);
|
||||||
|
characterState.setState("thinking");
|
||||||
|
|
||||||
|
// Advance past the temporary state duration — should stay as thinking
|
||||||
|
vi.advanceTimersByTime(6000);
|
||||||
|
expect(get(characterState)).toBe("thinking");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setTemporaryState", () => {
|
||||||
|
it("sets the character state immediately", () => {
|
||||||
|
characterState.setTemporaryState("success", 2000);
|
||||||
|
expect(get(characterState)).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverts to idle after the specified duration", () => {
|
||||||
|
characterState.setTemporaryState("success", 2000);
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 2000ms as the default duration", () => {
|
||||||
|
characterState.setTemporaryState("error");
|
||||||
|
vi.advanceTimersByTime(1999);
|
||||||
|
expect(get(characterState)).toBe("error");
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a previous temporary state timer when a new one is set", () => {
|
||||||
|
characterState.setTemporaryState("success", 5000);
|
||||||
|
characterState.setTemporaryState("error", 1000);
|
||||||
|
|
||||||
|
// First timer would have fired at 5000ms but was cancelled
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reset", () => {
|
||||||
|
it("resets the state to idle", () => {
|
||||||
|
characterState.setState("thinking");
|
||||||
|
characterState.reset();
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels any pending temporary state timer", () => {
|
||||||
|
characterState.setTemporaryState("success", 5000);
|
||||||
|
characterState.reset();
|
||||||
|
|
||||||
|
// Should now be idle and should NOT revert again after timer fires
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("characterInfo derived store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
characterState.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the info object for the current state", () => {
|
||||||
|
const info = get(characterInfo);
|
||||||
|
expect(info).toBeDefined();
|
||||||
|
expect(typeof info.label).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates when the character state changes", () => {
|
||||||
|
characterState.setState("thinking");
|
||||||
|
const thinkingInfo = get(characterInfo);
|
||||||
|
|
||||||
|
characterState.setState("idle");
|
||||||
|
const idleInfo = get(characterInfo);
|
||||||
|
|
||||||
|
expect(thinkingInfo.label).not.toBe(idleInfo.label);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import {
|
||||||
|
claudeStore,
|
||||||
|
hasPermissionPending,
|
||||||
|
hasQuestionPending,
|
||||||
|
isClaudeProcessing,
|
||||||
|
} from "./claude";
|
||||||
|
import { conversationsStore } from "./conversations";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import type { PermissionRequest, UserQuestionEvent } from "$lib/types/messages";
|
||||||
|
|
||||||
|
describe("claudeStore (compatibility wrapper)", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
conversationsStore.revokeAllTools();
|
||||||
|
conversationsStore.clearPermission();
|
||||||
|
conversationsStore.clearQuestion();
|
||||||
|
conversationsStore.setConnectionStatus("disconnected");
|
||||||
|
characterState.setState("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getGrantedTools", () => {
|
||||||
|
it("returns an empty array when no tools are granted", () => {
|
||||||
|
expect(claudeStore.getGrantedTools()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns granted tools as an array", () => {
|
||||||
|
conversationsStore.grantTool("Read");
|
||||||
|
conversationsStore.grantTool("Write");
|
||||||
|
|
||||||
|
const tools = claudeStore.getGrantedTools();
|
||||||
|
|
||||||
|
expect(tools).toContain("Read");
|
||||||
|
expect(tools).toContain("Write");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reset", () => {
|
||||||
|
it("clears terminal lines and resets processing state", () => {
|
||||||
|
conversationsStore.setProcessing(true);
|
||||||
|
conversationsStore.grantTool("Edit");
|
||||||
|
|
||||||
|
claudeStore.reset();
|
||||||
|
|
||||||
|
expect(get(conversationsStore.isProcessing)).toBe(false);
|
||||||
|
expect(claudeStore.getGrantedTools()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasPermissionPending derived store", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
conversationsStore.clearPermission();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when there are no pending permissions", () => {
|
||||||
|
expect(get(hasPermissionPending)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when there is a pending permission request", () => {
|
||||||
|
const request: PermissionRequest = {
|
||||||
|
id: "perm-1",
|
||||||
|
tool: "Bash",
|
||||||
|
description: "Run a shell command",
|
||||||
|
input: { command: "ls" },
|
||||||
|
};
|
||||||
|
|
||||||
|
conversationsStore.requestPermission(request);
|
||||||
|
|
||||||
|
expect(get(hasPermissionPending)).toBe(true);
|
||||||
|
|
||||||
|
conversationsStore.clearPermission();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasQuestionPending derived store", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
conversationsStore.clearQuestion();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when there is no pending question", () => {
|
||||||
|
expect(get(hasQuestionPending)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when there is a pending question", () => {
|
||||||
|
const question: UserQuestionEvent = {
|
||||||
|
id: "q-1",
|
||||||
|
question: "Which approach do you prefer?",
|
||||||
|
options: [{ label: "A" }, { label: "B" }],
|
||||||
|
multi_select: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
conversationsStore.requestQuestion(question);
|
||||||
|
|
||||||
|
expect(get(hasQuestionPending)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isClaudeProcessing derived store", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
conversationsStore.setConnectionStatus("disconnected");
|
||||||
|
characterState.setState("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when disconnected regardless of character state", () => {
|
||||||
|
conversationsStore.setConnectionStatus("disconnected");
|
||||||
|
characterState.setState("thinking");
|
||||||
|
|
||||||
|
expect(get(isClaudeProcessing)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when connected but in idle state", () => {
|
||||||
|
conversationsStore.setConnectionStatus("connected");
|
||||||
|
characterState.setState("idle");
|
||||||
|
|
||||||
|
expect(get(isClaudeProcessing)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when connected and in thinking state", () => {
|
||||||
|
conversationsStore.setConnectionStatus("connected");
|
||||||
|
characterState.setState("thinking");
|
||||||
|
|
||||||
|
expect(get(isClaudeProcessing)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when connected and in coding state", () => {
|
||||||
|
conversationsStore.setConnectionStatus("connected");
|
||||||
|
characterState.setState("coding");
|
||||||
|
|
||||||
|
expect(get(isClaudeProcessing)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when connected and in searching state", () => {
|
||||||
|
conversationsStore.setConnectionStatus("connected");
|
||||||
|
characterState.setState("searching");
|
||||||
|
|
||||||
|
expect(get(isClaudeProcessing)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,675 @@
|
|||||||
|
/**
|
||||||
|
* Clipboard Store Tests
|
||||||
|
*
|
||||||
|
* Tests the pure helper functions and store actions from the clipboard store:
|
||||||
|
* - detectLanguage: identifies programming language from code content
|
||||||
|
* - formatTimestamp: converts an ISO timestamp to a relative time string
|
||||||
|
* - Store actions: loadEntries, captureClipboard, deleteEntry, togglePin, etc.
|
||||||
|
* - Derived stores: filteredEntries (language + search filtering), languages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
|
||||||
|
describe("detectLanguage", () => {
|
||||||
|
describe("TypeScript detection", () => {
|
||||||
|
it("detects import statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("import React from 'react';")).toBe("typescript");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects export statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("export function foo() {}")).toBe("typescript");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects const declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("const x = 1;")).toBe("typescript");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects interface declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("interface Foo {\n bar: string;\n}")).toBe(
|
||||||
|
"typescript"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects type aliases", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("type MyType = string | number;")).toBe("typescript");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Python detection", () => {
|
||||||
|
it("detects def statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("def foo():\n pass")).toBe("python");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects async def statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("async def bar():\n pass")).toBe("python");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects from imports", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("from os import path")).toBe("python");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects the __name__ guard", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rust detection", () => {
|
||||||
|
it("detects fn declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects impl blocks", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe(
|
||||||
|
"rust"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects struct declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects enum declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects mod declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("mod utils;")).toBe("rust");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects pub visibility", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("pub fn exported() {}")).toBe("rust");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Go detection", () => {
|
||||||
|
it("detects package declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("package main")).toBe("go");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects func declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("func main() {}")).toBe("go");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PHP detection", () => {
|
||||||
|
it("detects the PHP open tag", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("<?php\necho 'hello';")).toBe("php");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SQL detection", () => {
|
||||||
|
it("detects SELECT statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects INSERT statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe(
|
||||||
|
"sql"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects CREATE statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects SQL case-insensitively", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("select * from users")).toBe("sql");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML detection", () => {
|
||||||
|
it("detects DOCTYPE declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("<!DOCTYPE html>")).toBe("html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects html tags", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("<html><body></body></html>")).toBe("html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects div tags", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("<div class='foo'>bar</div>")).toBe("html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects span tags", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("<span>text</span>")).toBe("html");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JSON detection", () => {
|
||||||
|
it("detects JSON object syntax", () => {
|
||||||
|
expect(clipboardStore.detectLanguage('{"name": "test", "value": 42}')).toBe("json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects JSON with hyphenated keys", () => {
|
||||||
|
expect(clipboardStore.detectLanguage('{"my-key": "value"}')).toBe("json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("YAML detection", () => {
|
||||||
|
it("detects YAML document separator", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("C detection", () => {
|
||||||
|
it("detects #include directives", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects #define directives", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("#define MAX 100")).toBe("c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects #ifdef directives", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Java detection", () => {
|
||||||
|
it("detects public class declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects private static methods", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("private static void helper() {}")).toBe("java");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects protected interface declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("protected interface Bar {}")).toBe("java");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bash detection", () => {
|
||||||
|
it("detects shell variable assignments", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("$HOME=/usr/local")).toBe("bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects variable assignments with underscores", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("$MY_VAR=some_value")).toBe("bash");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown content", () => {
|
||||||
|
it("returns null for plain text", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("Hello, world!")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty string", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for mathematical expressions", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("1 + 1 = 2")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a markdown heading", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("# My Heading\nSome text")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTimestamp", () => {
|
||||||
|
const NOW = new Date("2026-03-03T12:00:00.000Z");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("'Just now' threshold (< 1 minute)", () => {
|
||||||
|
it("returns 'Just now' for a timestamp 30 seconds ago", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:59:30.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'Just now' for the current moment", () => {
|
||||||
|
const ts = NOW.toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'Just now' for a timestamp 59 seconds ago", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:59:01.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("'Xm ago' threshold (1–59 minutes)", () => {
|
||||||
|
it("returns '1m ago' at exactly 1 minute", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:59:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("1m ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '5m ago' for a timestamp 5 minutes ago", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:55:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("5m ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '59m ago' just before the 1-hour threshold", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:01:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("59m ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("'Xh ago' threshold (1–23 hours)", () => {
|
||||||
|
it("returns '1h ago' at exactly 1 hour", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("1h ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '2h ago' for a timestamp 2 hours ago", () => {
|
||||||
|
const ts = new Date("2026-03-03T10:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("2h ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '23h ago' just before the 1-day threshold", () => {
|
||||||
|
const ts = new Date("2026-03-02T13:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("23h ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("'Xd ago' threshold (1–6 days)", () => {
|
||||||
|
it("returns '1d ago' at exactly 1 day", () => {
|
||||||
|
const ts = new Date("2026-03-02T12:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("1d ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '3d ago' for a timestamp 3 days ago", () => {
|
||||||
|
const ts = new Date("2026-02-28T12:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("3d ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '6d ago' just before the 7-day threshold", () => {
|
||||||
|
const ts = new Date("2026-02-25T12:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("6d ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("locale date string (7+ days ago)", () => {
|
||||||
|
it("returns a locale date string for a 2-week-old timestamp", () => {
|
||||||
|
const ts = new Date("2026-02-17T12:00:00.000Z").toISOString();
|
||||||
|
const result = clipboardStore.formatTimestamp(ts);
|
||||||
|
// Should not be a relative time string
|
||||||
|
expect(result).not.toContain("m ago");
|
||||||
|
expect(result).not.toContain("h ago");
|
||||||
|
expect(result).not.toContain("d ago");
|
||||||
|
expect(result).not.toBe("Just now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a locale date string for a 1-month-old timestamp", () => {
|
||||||
|
const ts = new Date("2026-02-03T12:00:00.000Z").toISOString();
|
||||||
|
const result = clipboardStore.formatTimestamp(ts);
|
||||||
|
expect(result).not.toContain("ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clipboardStore - derived stores", () => {
|
||||||
|
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: "entry-1",
|
||||||
|
content: "const x = 1;",
|
||||||
|
language: "typescript",
|
||||||
|
source: "test.ts",
|
||||||
|
timestamp: "2026-03-03T12:00:00.000Z",
|
||||||
|
is_pinned: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clipboardStore.entries.set([]);
|
||||||
|
clipboardStore.searchQuery.set("");
|
||||||
|
clipboardStore.languageFilter.set(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filteredEntries - language filter", () => {
|
||||||
|
it("returns all entries when no language filter is set", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ id: "1", language: "typescript" }),
|
||||||
|
makeEntry({ id: "2", language: "python" }),
|
||||||
|
]);
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters entries by language", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ id: "1", language: "typescript" }),
|
||||||
|
makeEntry({ id: "2", language: "python" }),
|
||||||
|
makeEntry({ id: "3", language: "typescript" }),
|
||||||
|
]);
|
||||||
|
clipboardStore.languageFilter.set("typescript");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
expect(filtered.every((e) => e.language === "typescript")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when filter matches nothing", () => {
|
||||||
|
clipboardStore.entries.set([makeEntry({ language: "typescript" })]);
|
||||||
|
clipboardStore.languageFilter.set("rust");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filteredEntries - search query", () => {
|
||||||
|
it("filters by content (case-insensitive)", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ id: "1", content: "const HELLO = 1;" }),
|
||||||
|
makeEntry({ id: "2", content: "let world = 2;" }),
|
||||||
|
]);
|
||||||
|
clipboardStore.searchQuery.set("hello");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(1);
|
||||||
|
expect(filtered[0].id).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by language field", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ id: "1", language: "typescript", content: "unrelated" }),
|
||||||
|
makeEntry({ id: "2", language: "python", content: "also unrelated" }),
|
||||||
|
]);
|
||||||
|
clipboardStore.searchQuery.set("python");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(1);
|
||||||
|
expect(filtered[0].id).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by source field", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ id: "1", source: "main.rs", content: "fn main() {}" }),
|
||||||
|
makeEntry({ id: "2", source: "app.ts", content: "const x = 1;" }),
|
||||||
|
]);
|
||||||
|
clipboardStore.searchQuery.set("main.rs");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(1);
|
||||||
|
expect(filtered[0].id).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all entries when search query is empty", () => {
|
||||||
|
clipboardStore.entries.set([makeEntry({ id: "1" }), makeEntry({ id: "2" })]);
|
||||||
|
clipboardStore.searchQuery.set("");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("languages derived store", () => {
|
||||||
|
it("returns empty array when no entries", () => {
|
||||||
|
clipboardStore.entries.set([]);
|
||||||
|
expect(get(clipboardStore.languages)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unique sorted languages", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ language: "typescript" }),
|
||||||
|
makeEntry({ language: "python" }),
|
||||||
|
makeEntry({ language: "typescript" }),
|
||||||
|
makeEntry({ language: "rust" }),
|
||||||
|
]);
|
||||||
|
const langs = get(clipboardStore.languages);
|
||||||
|
expect(langs).toEqual(["python", "rust", "typescript"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes entries with null language", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ language: "typescript" }),
|
||||||
|
makeEntry({ language: null }),
|
||||||
|
]);
|
||||||
|
const langs = get(clipboardStore.languages);
|
||||||
|
expect(langs).toEqual(["typescript"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clipboardStore - setSearchQuery and setLanguageFilter", () => {
|
||||||
|
it("setSearchQuery updates the searchQuery store", () => {
|
||||||
|
clipboardStore.setSearchQuery("hello world");
|
||||||
|
expect(get(clipboardStore.searchQuery)).toBe("hello world");
|
||||||
|
clipboardStore.setSearchQuery("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setLanguageFilter updates the languageFilter store", () => {
|
||||||
|
clipboardStore.setLanguageFilter("python");
|
||||||
|
expect(get(clipboardStore.languageFilter)).toBe("python");
|
||||||
|
clipboardStore.setLanguageFilter(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setLanguageFilter can be reset to null", () => {
|
||||||
|
clipboardStore.setLanguageFilter("rust");
|
||||||
|
clipboardStore.setLanguageFilter(null);
|
||||||
|
expect(get(clipboardStore.languageFilter)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clipboardStore - store actions", () => {
|
||||||
|
const mockEntry = {
|
||||||
|
id: "entry-1",
|
||||||
|
content: "const x = 1;",
|
||||||
|
language: "typescript",
|
||||||
|
source: "test.ts",
|
||||||
|
timestamp: "2026-03-03T12:00:00.000Z",
|
||||||
|
is_pinned: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
clipboardStore.entries.set([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadEntries", () => {
|
||||||
|
it("loads entries from backend and updates the store", async () => {
|
||||||
|
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
||||||
|
await clipboardStore.loadEntries();
|
||||||
|
expect(get(clipboardStore.entries)).toEqual([mockEntry]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully without crashing", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("list_clipboard_entries", new Error("Backend unavailable"));
|
||||||
|
await clipboardStore.loadEntries();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to load clipboard entries:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoading to false after completion", async () => {
|
||||||
|
setMockInvokeResult("list_clipboard_entries", []);
|
||||||
|
await clipboardStore.loadEntries();
|
||||||
|
expect(get(clipboardStore.isLoading)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("captureClipboard", () => {
|
||||||
|
it("captures clipboard entry with provided language", async () => {
|
||||||
|
setMockInvokeResult("capture_clipboard", mockEntry);
|
||||||
|
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
||||||
|
const result = await clipboardStore.captureClipboard("const x = 1;", "typescript", "test.ts");
|
||||||
|
expect(result).toEqual(mockEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-detects language when none provided", async () => {
|
||||||
|
setMockInvokeResult("capture_clipboard", mockEntry);
|
||||||
|
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
||||||
|
const result = await clipboardStore.captureClipboard("const x = 1;");
|
||||||
|
expect(result).toEqual(mockEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on error", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("capture_clipboard", new Error("Failed"));
|
||||||
|
const result = await clipboardStore.captureClipboard("const x = 1;");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteEntry", () => {
|
||||||
|
it("removes entry from store on success", async () => {
|
||||||
|
clipboardStore.entries.set([mockEntry, { ...mockEntry, id: "entry-2" }]);
|
||||||
|
setMockInvokeResult("delete_clipboard_entry", undefined);
|
||||||
|
await clipboardStore.deleteEntry("entry-1");
|
||||||
|
expect(get(clipboardStore.entries)).toHaveLength(1);
|
||||||
|
expect(get(clipboardStore.entries)[0].id).toBe("entry-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("delete_clipboard_entry", new Error("Failed"));
|
||||||
|
await clipboardStore.deleteEntry("entry-1");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to delete clipboard entry:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("togglePin", () => {
|
||||||
|
it("updates entry pin status and sorts pinned first", async () => {
|
||||||
|
const unpinned1 = { ...mockEntry, id: "entry-1", is_pinned: false };
|
||||||
|
const unpinned2 = { ...mockEntry, id: "entry-2", is_pinned: false };
|
||||||
|
clipboardStore.entries.set([unpinned1, unpinned2]);
|
||||||
|
const pinned = { ...unpinned2, is_pinned: true };
|
||||||
|
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
|
||||||
|
await clipboardStore.togglePin("entry-2");
|
||||||
|
const entries = get(clipboardStore.entries);
|
||||||
|
expect(entries[0].id).toBe("entry-2");
|
||||||
|
expect(entries[0].is_pinned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("toggle_pin_clipboard_entry", new Error("Failed"));
|
||||||
|
await clipboardStore.togglePin("entry-1");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to toggle pin:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exercises the unpinned-before-pinned sort branch", async () => {
|
||||||
|
// Three entries so sort compares (entry-1 unpinned, entry-2 pinned) — hits line 144
|
||||||
|
const e1 = { ...mockEntry, id: "e1", is_pinned: false };
|
||||||
|
const e2 = { ...mockEntry, id: "e2", is_pinned: false };
|
||||||
|
const e3 = { ...mockEntry, id: "e3", is_pinned: false };
|
||||||
|
clipboardStore.entries.set([e1, e2, e3]);
|
||||||
|
const pinned = { ...e2, is_pinned: true };
|
||||||
|
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
|
||||||
|
await clipboardStore.togglePin("e2");
|
||||||
|
expect(get(clipboardStore.entries)[0].id).toBe("e2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts same-pin-status entries by timestamp descending", async () => {
|
||||||
|
// Toggle entry to pinned while two others remain unpinned — sort compares unpinned pair by timestamp
|
||||||
|
const older = {
|
||||||
|
...mockEntry,
|
||||||
|
id: "older",
|
||||||
|
is_pinned: false,
|
||||||
|
timestamp: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const newer = {
|
||||||
|
...mockEntry,
|
||||||
|
id: "newer",
|
||||||
|
is_pinned: false,
|
||||||
|
timestamp: "2026-01-02T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const pinned = {
|
||||||
|
...mockEntry,
|
||||||
|
id: "pinned",
|
||||||
|
is_pinned: false,
|
||||||
|
timestamp: "2026-01-03T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
clipboardStore.entries.set([older, newer, pinned]);
|
||||||
|
const pinnedResult = { ...pinned, is_pinned: true };
|
||||||
|
setMockInvokeResult("toggle_pin_clipboard_entry", pinnedResult);
|
||||||
|
await clipboardStore.togglePin("pinned");
|
||||||
|
const entries = get(clipboardStore.entries);
|
||||||
|
expect(entries[0].id).toBe("pinned");
|
||||||
|
expect(entries[1].id).toBe("newer");
|
||||||
|
expect(entries[2].id).toBe("older");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearHistory", () => {
|
||||||
|
it("removes unpinned entries and keeps pinned ones", async () => {
|
||||||
|
const pinned = { ...mockEntry, id: "pinned", is_pinned: true };
|
||||||
|
const unpinned = { ...mockEntry, id: "unpinned", is_pinned: false };
|
||||||
|
clipboardStore.entries.set([pinned, unpinned]);
|
||||||
|
setMockInvokeResult("clear_clipboard_history", undefined);
|
||||||
|
await clipboardStore.clearHistory();
|
||||||
|
const entries = get(clipboardStore.entries);
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0].id).toBe("pinned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("clear_clipboard_history", new Error("Failed"));
|
||||||
|
await clipboardStore.clearHistory();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to clear clipboard history:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateLanguage", () => {
|
||||||
|
it("updates entry language in the store", async () => {
|
||||||
|
clipboardStore.entries.set([mockEntry]);
|
||||||
|
const updated = { ...mockEntry, language: "javascript" };
|
||||||
|
setMockInvokeResult("update_clipboard_language", updated);
|
||||||
|
await clipboardStore.updateLanguage("entry-1", "javascript");
|
||||||
|
expect(get(clipboardStore.entries)[0].language).toBe("javascript");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves non-matching entries unchanged", async () => {
|
||||||
|
const other = { ...mockEntry, id: "other", language: "python" };
|
||||||
|
clipboardStore.entries.set([mockEntry, other]);
|
||||||
|
const updated = { ...mockEntry, language: "javascript" };
|
||||||
|
setMockInvokeResult("update_clipboard_language", updated);
|
||||||
|
await clipboardStore.updateLanguage("entry-1", "javascript");
|
||||||
|
const entries = get(clipboardStore.entries);
|
||||||
|
expect(entries[1].language).toBe("python");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("update_clipboard_language", new Error("Failed"));
|
||||||
|
await clipboardStore.updateLanguage("entry-1", "javascript");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to update language:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("copyToClipboard", () => {
|
||||||
|
it("returns true and copies text on success", async () => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
});
|
||||||
|
const result = await clipboardStore.copyToClipboard("hello world");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false and logs error on failure", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard denied")) },
|
||||||
|
});
|
||||||
|
const result = await clipboardStore.copyToClipboard("hello world");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to copy to clipboard:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
import {
|
import {
|
||||||
configStore,
|
configStore,
|
||||||
maskPaths,
|
maskPaths,
|
||||||
clampFontSize,
|
clampFontSize,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
|
applyCustomFont,
|
||||||
|
applyCustomUiFont,
|
||||||
applyTheme,
|
applyTheme,
|
||||||
applyCustomThemeColors,
|
applyCustomThemeColors,
|
||||||
clearCustomThemeColors,
|
clearCustomThemeColors,
|
||||||
|
isDarkTheme,
|
||||||
|
isStreamerMode,
|
||||||
|
isCompactMode,
|
||||||
|
shouldHidePaths,
|
||||||
|
showThinkingBlocks,
|
||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
@@ -15,12 +23,17 @@ import {
|
|||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
} from "./config";
|
} from "./config";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
// Mock Tauri APIs
|
// Mock Tauri APIs
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||||
|
readFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("config store", () => {
|
describe("config store", () => {
|
||||||
describe("font size constants", () => {
|
describe("font size constants", () => {
|
||||||
it("has correct MIN_FONT_SIZE", () => {
|
it("has correct MIN_FONT_SIZE", () => {
|
||||||
@@ -194,6 +207,16 @@ describe("config store", () => {
|
|||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
|
custom_font_path: null,
|
||||||
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -240,6 +263,16 @@ describe("config store", () => {
|
|||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
|
custom_font_path: null,
|
||||||
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
@@ -785,6 +818,16 @@ describe("config store", () => {
|
|||||||
budget_warning_threshold: 0.9,
|
budget_warning_threshold: 0.9,
|
||||||
discord_rpc_enabled: false,
|
discord_rpc_enabled: false,
|
||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
|
custom_font_path: null,
|
||||||
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockInvokeImpl = vi.mocked(invoke);
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
@@ -825,4 +868,607 @@ describe("config store", () => {
|
|||||||
expect(lastSaved.font_size).toBe(16);
|
expect(lastSaved.font_size).toBe(16);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("loadConfig error path", () => {
|
||||||
|
it("resets to default config and logs error when loadConfig fails", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
await configStore.updateConfig({ theme: "light" });
|
||||||
|
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
vi.mocked(invoke).mockRejectedValue(new Error("Backend unavailable"));
|
||||||
|
|
||||||
|
await configStore.loadConfig();
|
||||||
|
|
||||||
|
expect(configStore.getConfig().theme).toBe("dark");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load config:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore sidebar methods", () => {
|
||||||
|
it("openSidebar sets isSidebarOpen to true", () => {
|
||||||
|
configStore.closeSidebar();
|
||||||
|
configStore.openSidebar();
|
||||||
|
expect(get(configStore.isSidebarOpen)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closeSidebar sets isSidebarOpen to false", () => {
|
||||||
|
configStore.openSidebar();
|
||||||
|
configStore.closeSidebar();
|
||||||
|
expect(get(configStore.isSidebarOpen)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleSidebar switches from false to true", () => {
|
||||||
|
configStore.closeSidebar();
|
||||||
|
configStore.toggleSidebar();
|
||||||
|
expect(get(configStore.isSidebarOpen)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleSidebar switches from true to false", () => {
|
||||||
|
configStore.openSidebar();
|
||||||
|
configStore.toggleSidebar();
|
||||||
|
expect(get(configStore.isSidebarOpen)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore setTheme method", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setTheme updates the theme via invoke", async () => {
|
||||||
|
await configStore.setTheme("light");
|
||||||
|
expect(configStore.getConfig().theme).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setTheme with custom colors updates custom_theme_colors", async () => {
|
||||||
|
const colors: CustomThemeColors = {
|
||||||
|
bg_primary: "#001122",
|
||||||
|
bg_secondary: null,
|
||||||
|
bg_terminal: null,
|
||||||
|
accent_primary: null,
|
||||||
|
accent_secondary: null,
|
||||||
|
text_primary: null,
|
||||||
|
text_secondary: null,
|
||||||
|
border_color: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await configStore.setTheme("custom", colors);
|
||||||
|
|
||||||
|
expect(configStore.getConfig().theme).toBe("custom");
|
||||||
|
expect(configStore.getConfig().custom_theme_colors.bg_primary).toBe("#001122");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore setCustomThemeColors with custom theme active", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
await configStore.setTheme("custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
clearCustomThemeColors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom colors to DOM when current theme is custom", async () => {
|
||||||
|
const colors: CustomThemeColors = {
|
||||||
|
bg_primary: "#aabbcc",
|
||||||
|
bg_secondary: null,
|
||||||
|
bg_terminal: null,
|
||||||
|
accent_primary: null,
|
||||||
|
accent_secondary: null,
|
||||||
|
text_primary: null,
|
||||||
|
text_secondary: null,
|
||||||
|
border_color: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await configStore.setCustomThemeColors(colors);
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#aabbcc");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore font size methods", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
await configStore.updateConfig({ font_size: DEFAULT_FONT_SIZE });
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setFontSize updates to the given value", async () => {
|
||||||
|
await configStore.setFontSize(18);
|
||||||
|
expect(configStore.getConfig().font_size).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setFontSize clamps to minimum", async () => {
|
||||||
|
await configStore.setFontSize(1);
|
||||||
|
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setFontSize clamps to maximum", async () => {
|
||||||
|
await configStore.setFontSize(99);
|
||||||
|
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increaseFontSize increases font size by 2", async () => {
|
||||||
|
await configStore.increaseFontSize();
|
||||||
|
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increaseFontSize does not exceed maximum", async () => {
|
||||||
|
await configStore.setFontSize(MAX_FONT_SIZE);
|
||||||
|
await configStore.increaseFontSize();
|
||||||
|
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decreaseFontSize decreases font size by 2", async () => {
|
||||||
|
await configStore.decreaseFontSize();
|
||||||
|
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE - 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decreaseFontSize does not go below minimum", async () => {
|
||||||
|
await configStore.setFontSize(MIN_FONT_SIZE);
|
||||||
|
await configStore.decreaseFontSize();
|
||||||
|
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resetFontSize restores the default font size", async () => {
|
||||||
|
await configStore.setFontSize(20);
|
||||||
|
await configStore.resetFontSize();
|
||||||
|
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore removeAutoGrantedTool", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
await configStore.updateConfig({ auto_granted_tools: [] });
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an existing tool", async () => {
|
||||||
|
await configStore.addAutoGrantedTool("Bash");
|
||||||
|
await configStore.removeAutoGrantedTool("Bash");
|
||||||
|
expect(configStore.getConfig().auto_granted_tools).not.toContain("Bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the tool is not in the list", async () => {
|
||||||
|
await configStore.removeAutoGrantedTool("NonExistentTool");
|
||||||
|
expect(configStore.getConfig().auto_granted_tools).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore toggle methods", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
await configStore.updateConfig({ streamer_mode: false, compact_mode: false });
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleStreamerMode flips streamer_mode from false to true", async () => {
|
||||||
|
await configStore.toggleStreamerMode();
|
||||||
|
expect(configStore.getConfig().streamer_mode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleStreamerMode flips streamer_mode from true to false", async () => {
|
||||||
|
await configStore.updateConfig({ streamer_mode: true });
|
||||||
|
await configStore.toggleStreamerMode();
|
||||||
|
expect(configStore.getConfig().streamer_mode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleCompactMode flips compact_mode from false to true", async () => {
|
||||||
|
await configStore.toggleCompactMode();
|
||||||
|
expect(configStore.getConfig().compact_mode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleCompactMode flips compact_mode from true to false", async () => {
|
||||||
|
await configStore.updateConfig({ compact_mode: true });
|
||||||
|
await configStore.toggleCompactMode();
|
||||||
|
expect(configStore.getConfig().compact_mode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCompactMode enables compact mode", async () => {
|
||||||
|
await configStore.setCompactMode(true);
|
||||||
|
expect(configStore.getConfig().compact_mode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCompactMode disables compact mode", async () => {
|
||||||
|
await configStore.updateConfig({ compact_mode: true });
|
||||||
|
await configStore.setCompactMode(false);
|
||||||
|
expect(configStore.getConfig().compact_mode).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derived stores (live subscriptions)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isDarkTheme is true when theme is dark", async () => {
|
||||||
|
await configStore.updateConfig({ theme: "dark" });
|
||||||
|
expect(get(isDarkTheme)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isDarkTheme is false when theme is not dark", async () => {
|
||||||
|
await configStore.updateConfig({ theme: "light" });
|
||||||
|
expect(get(isDarkTheme)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isStreamerMode reflects streamer_mode config", async () => {
|
||||||
|
await configStore.updateConfig({ streamer_mode: true });
|
||||||
|
expect(get(isStreamerMode)).toBe(true);
|
||||||
|
await configStore.updateConfig({ streamer_mode: false });
|
||||||
|
expect(get(isStreamerMode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isCompactMode reflects compact_mode config", async () => {
|
||||||
|
await configStore.updateConfig({ compact_mode: true });
|
||||||
|
expect(get(isCompactMode)).toBe(true);
|
||||||
|
await configStore.updateConfig({ compact_mode: false });
|
||||||
|
expect(get(isCompactMode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shouldHidePaths is true when both streamer flags are enabled", async () => {
|
||||||
|
await configStore.updateConfig({ streamer_mode: true, streamer_hide_paths: true });
|
||||||
|
expect(get(shouldHidePaths)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shouldHidePaths is false when streamer_mode is disabled", async () => {
|
||||||
|
await configStore.updateConfig({ streamer_mode: false, streamer_hide_paths: true });
|
||||||
|
expect(get(shouldHidePaths)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showThinkingBlocks is true when show_thinking_blocks is enabled", async () => {
|
||||||
|
await configStore.updateConfig({ show_thinking_blocks: true });
|
||||||
|
expect(get(showThinkingBlocks)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showThinkingBlocks is false when show_thinking_blocks is disabled", async () => {
|
||||||
|
await configStore.updateConfig({ show_thinking_blocks: false });
|
||||||
|
expect(get(showThinkingBlocks)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyCustomFont", () => {
|
||||||
|
const readFileMock = vi.mocked(readFile);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Remove any style element left by previous tests
|
||||||
|
document.getElementById("hikari-custom-font")?.remove();
|
||||||
|
document.documentElement.style.removeProperty("--terminal-font-family");
|
||||||
|
readFileMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes CSS variable when both path and family are null", async () => {
|
||||||
|
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||||
|
await applyCustomFont(null, null);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes CSS variable when both path and family are empty strings", async () => {
|
||||||
|
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||||
|
await applyCustomFont("", "");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||||
|
expect(document.getElementById("hikari-custom-font")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @import for a CSS stylesheet URL", async () => {
|
||||||
|
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain(
|
||||||
|
"@import url('https://fonts.googleapis.com/css2?family=Fira+Code')"
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||||
|
"'Fira Code', monospace"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @font-face for a direct font file URL (.woff2)", async () => {
|
||||||
|
await applyCustomFont("https://example.com/fonts/myfont.woff2", "MyFont");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain("@font-face");
|
||||||
|
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.woff2')");
|
||||||
|
expect(style?.textContent).toContain("'MyFont'");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||||
|
"'MyFont', monospace"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @font-face for a direct font file URL (.ttf)", async () => {
|
||||||
|
await applyCustomFont("https://example.com/fonts/myfont.ttf", "MyTtfFont");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain("@font-face");
|
||||||
|
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.ttf')");
|
||||||
|
expect(style?.textContent).toContain("'MyTtfFont'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses HikariCustomFont as fallback family for direct font URLs when family is empty", async () => {
|
||||||
|
await applyCustomFont("https://example.com/fonts/myfont.woff2", "");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style?.textContent).toContain("'HikariCustomFont'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads local file and injects @font-face with data URL", async () => {
|
||||||
|
const fakeData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
||||||
|
readFileMock.mockResolvedValueOnce(fakeData);
|
||||||
|
|
||||||
|
await applyCustomFont("/home/naomi/.fonts/MyFont.ttf", "MyFont");
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/MyFont.ttf");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain("@font-face");
|
||||||
|
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
||||||
|
expect(style?.textContent).toContain("'MyFont'");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||||
|
"'MyFont', monospace"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses HikariCustomFont as fallback family for local files when family is empty", async () => {
|
||||||
|
const fakeData = new Uint8Array([1, 2, 3]);
|
||||||
|
readFileMock.mockResolvedValueOnce(fakeData);
|
||||||
|
|
||||||
|
await applyCustomFont("/home/naomi/.fonts/MyFont.woff2", "");
|
||||||
|
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style?.textContent).toContain("'HikariCustomFont'");
|
||||||
|
expect(style?.textContent).toContain("font/woff2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets CSS variable when only family is provided (no path)", async () => {
|
||||||
|
await applyCustomFont("", "SystemFont");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||||
|
"'SystemFont', monospace"
|
||||||
|
);
|
||||||
|
expect(document.getElementById("hikari-custom-font")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a previously injected style element", async () => {
|
||||||
|
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
|
||||||
|
expect(document.getElementById("hikari-custom-font")).not.toBeNull();
|
||||||
|
|
||||||
|
await applyCustomFont("https://fonts.googleapis.com/css2?family=Roboto+Mono", "Roboto Mono");
|
||||||
|
const styles = document.querySelectorAll("#hikari-custom-font");
|
||||||
|
expect(styles.length).toBe(1);
|
||||||
|
expect(styles[0].textContent).toContain("Roboto+Mono");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct MIME type for .otf local files", async () => {
|
||||||
|
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
|
||||||
|
await applyCustomFont("/fonts/MyFont.otf", "OtfFont");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style?.textContent).toContain("font/otf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to font/ttf MIME for unknown extension local files", async () => {
|
||||||
|
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
|
||||||
|
await applyCustomFont("/fonts/MyFont.xyz", "XyzFont");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style?.textContent).toContain("font/ttf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setCustomFont", () => {
|
||||||
|
const readFileMock = vi.mocked(readFile);
|
||||||
|
const invokeMock = vi.mocked(invoke);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.getElementById("hikari-custom-font")?.remove();
|
||||||
|
document.documentElement.style.removeProperty("--terminal-font-family");
|
||||||
|
readFileMock.mockReset();
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves config and applies the font", async () => {
|
||||||
|
await configStore.setCustomFont(null, null);
|
||||||
|
await configStore.setCustomFont(
|
||||||
|
"https://fonts.googleapis.com/css2?family=Fira+Code",
|
||||||
|
"Fira Code"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"save_config",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
custom_font_path: "https://fonts.googleapis.com/css2?family=Fira+Code",
|
||||||
|
custom_font_family: "Fira Code",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||||
|
"'Fira Code', monospace"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears font when called with nulls", async () => {
|
||||||
|
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||||
|
await configStore.setCustomFont(null, null);
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"save_config",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
custom_font_path: null,
|
||||||
|
custom_font_family: null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyCustomUiFont", () => {
|
||||||
|
const readFileMock = vi.mocked(readFile);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.getElementById("hikari-custom-ui-font")?.remove();
|
||||||
|
document.documentElement.style.removeProperty("--ui-font-family");
|
||||||
|
document.body.style.removeProperty("font-family");
|
||||||
|
readFileMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes CSS variable and body font-family when both path and family are null", async () => {
|
||||||
|
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||||
|
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||||
|
await applyCustomUiFont(null, null);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes CSS variable and body font-family when both path and family are empty strings", async () => {
|
||||||
|
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||||
|
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||||
|
await applyCustomUiFont("", "");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||||
|
expect(document.getElementById("hikari-custom-ui-font")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @import for a CSS stylesheet URL and applies font to body", async () => {
|
||||||
|
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain(
|
||||||
|
"@import url('https://fonts.googleapis.com/css2?family=Inter')"
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @font-face for a direct font file URL (.woff2) and applies font to body", async () => {
|
||||||
|
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "Inter");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain("@font-face");
|
||||||
|
expect(style?.textContent).toContain("url('https://example.com/fonts/Inter.woff2')");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses HikariCustomUiFont as fallback family and applies it to body", async () => {
|
||||||
|
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style?.textContent).toContain("'HikariCustomUiFont'");
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe(
|
||||||
|
'"HikariCustomUiFont", sans-serif'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads local file and embeds as data URL, applies font to body", async () => {
|
||||||
|
const fakeData = new Uint8Array([104, 101, 108, 108, 111]);
|
||||||
|
readFileMock.mockResolvedValueOnce(fakeData);
|
||||||
|
|
||||||
|
await applyCustomUiFont("/home/naomi/.fonts/Inter.ttf", "Inter");
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/Inter.ttf");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets CSS variable and body font-family when only family is provided (no path)", async () => {
|
||||||
|
await applyCustomUiFont("", "SystemUiFont");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'SystemUiFont', sans-serif"
|
||||||
|
);
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe(
|
||||||
|
'"SystemUiFont", sans-serif'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a previously injected style element", async () => {
|
||||||
|
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||||
|
expect(document.getElementById("hikari-custom-ui-font")).not.toBeNull();
|
||||||
|
|
||||||
|
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Roboto", "Roboto");
|
||||||
|
const styles = document.querySelectorAll("#hikari-custom-ui-font");
|
||||||
|
expect(styles.length).toBe(1);
|
||||||
|
expect(styles[0].textContent).toContain("Roboto");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setCustomUiFont", () => {
|
||||||
|
const readFileMock = vi.mocked(readFile);
|
||||||
|
const invokeMock = vi.mocked(invoke);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.getElementById("hikari-custom-ui-font")?.remove();
|
||||||
|
document.documentElement.style.removeProperty("--ui-font-family");
|
||||||
|
document.body.style.removeProperty("font-family");
|
||||||
|
readFileMock.mockReset();
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves config and applies the UI font", async () => {
|
||||||
|
await configStore.setCustomUiFont(null, null);
|
||||||
|
await configStore.setCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||||
|
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"save_config",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
custom_ui_font_path: "https://fonts.googleapis.com/css2?family=Inter",
|
||||||
|
custom_ui_font_family: "Inter",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears UI font when called with nulls", async () => {
|
||||||
|
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||||
|
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||||
|
await configStore.setCustomUiFont(null, null);
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"save_config",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||||
export type BudgetAction = "warn" | "block";
|
export type BudgetAction = "warn" | "block";
|
||||||
@@ -47,6 +48,23 @@ export interface HikariConfig {
|
|||||||
discord_rpc_enabled: boolean;
|
discord_rpc_enabled: boolean;
|
||||||
// Thinking blocks settings
|
// Thinking blocks settings
|
||||||
show_thinking_blocks: boolean;
|
show_thinking_blocks: boolean;
|
||||||
|
// Worktree isolation
|
||||||
|
use_worktree: boolean;
|
||||||
|
// Disable 1M context window
|
||||||
|
disable_1m_context: boolean;
|
||||||
|
// Max output tokens for Claude Code responses
|
||||||
|
max_output_tokens: number | null;
|
||||||
|
// Workspaces the user has explicitly trusted
|
||||||
|
trusted_workspaces: string[];
|
||||||
|
// Background image settings
|
||||||
|
background_image_path: string | null;
|
||||||
|
background_image_opacity: number;
|
||||||
|
// Custom terminal font settings
|
||||||
|
custom_font_path: string | null;
|
||||||
|
custom_font_family: string | null;
|
||||||
|
// Custom UI font settings
|
||||||
|
custom_ui_font_path: string | null;
|
||||||
|
custom_ui_font_family: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -87,6 +105,16 @@ const defaultConfig: HikariConfig = {
|
|||||||
budget_warning_threshold: 0.8,
|
budget_warning_threshold: 0.8,
|
||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
|
use_worktree: false,
|
||||||
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
|
trusted_workspaces: [],
|
||||||
|
background_image_path: null,
|
||||||
|
background_image_opacity: 0.3,
|
||||||
|
custom_font_path: null,
|
||||||
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
@@ -223,6 +251,16 @@ function createConfigStore() {
|
|||||||
setCompactMode: async (enabled: boolean) => {
|
setCompactMode: async (enabled: boolean) => {
|
||||||
await updateConfig({ compact_mode: enabled });
|
await updateConfig({ compact_mode: enabled });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCustomFont: async (path: string | null, family: string | null) => {
|
||||||
|
await updateConfig({ custom_font_path: path, custom_font_family: family });
|
||||||
|
await applyCustomFont(path, family);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCustomUiFont: async (path: string | null, family: string | null) => {
|
||||||
|
await updateConfig({ custom_ui_font_path: path, custom_ui_font_family: family });
|
||||||
|
await applyCustomUiFont(path, family);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +326,99 @@ export function clampFontSize(size: number): number {
|
|||||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]);
|
||||||
|
|
||||||
|
const FONT_MIME_MAP: Record<string, string> = {
|
||||||
|
woff: "font/woff",
|
||||||
|
woff2: "font/woff2",
|
||||||
|
ttf: "font/ttf",
|
||||||
|
otf: "font/otf",
|
||||||
|
eot: "application/vnd.ms-fontobject",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function applyFontFromSource(path: string, family: string, styleId: string): Promise<void> {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = styleId;
|
||||||
|
|
||||||
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
|
||||||
|
if (DIRECT_FONT_EXTENSIONS.has(ext)) {
|
||||||
|
style.textContent = `@font-face { font-family: '${family}'; src: url('${path}'); }`;
|
||||||
|
} else {
|
||||||
|
style.textContent = `@import url('${path}');`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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() ?? "ttf";
|
||||||
|
const mime = FONT_MIME_MAP[ext] ?? "font/ttf";
|
||||||
|
const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
|
||||||
|
style.textContent = `@font-face { font-family: '${family}'; src: url('${dataUrl}'); }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const styleId = "hikari-custom-font";
|
||||||
|
const cssVar = "--terminal-font-family";
|
||||||
|
const fallbackFamily = "HikariCustomFont";
|
||||||
|
|
||||||
|
document.getElementById(styleId)?.remove();
|
||||||
|
|
||||||
|
const trimmedPath = path?.trim() ?? "";
|
||||||
|
const trimmedFamily = family?.trim() ?? "";
|
||||||
|
|
||||||
|
if (!trimmedPath && !trimmedFamily) {
|
||||||
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedPath) {
|
||||||
|
await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedFamily) {
|
||||||
|
document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', monospace`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyCustomUiFont(path: string | null, family: string | null): Promise<void> {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const styleId = "hikari-custom-ui-font";
|
||||||
|
const cssVar = "--ui-font-family";
|
||||||
|
const fallbackFamily = "HikariCustomUiFont";
|
||||||
|
|
||||||
|
document.getElementById(styleId)?.remove();
|
||||||
|
|
||||||
|
const trimmedPath = path?.trim() ?? "";
|
||||||
|
const trimmedFamily = family?.trim() ?? "";
|
||||||
|
|
||||||
|
if (!trimmedPath && !trimmedFamily) {
|
||||||
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
|
document.body?.style.removeProperty("font-family");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveFamily = trimmedFamily || fallbackFamily;
|
||||||
|
|
||||||
|
if (trimmedPath) {
|
||||||
|
await applyFontFromSource(trimmedPath, effectiveFamily, styleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontValue = `'${effectiveFamily}', sans-serif`;
|
||||||
|
document.documentElement.style.setProperty(cssVar, fontValue);
|
||||||
|
document.body?.style.setProperty("font-family", fontValue);
|
||||||
|
}
|
||||||
|
|
||||||
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
||||||
|
|
||||||
export const configStore = createConfigStore();
|
export const configStore = 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,92 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAB_NAMES = [
|
||||||
|
// Cosmic & celestial
|
||||||
|
"Starfall",
|
||||||
|
"Moonbeam",
|
||||||
|
"Nebula",
|
||||||
|
"Aurora",
|
||||||
|
"Stardust",
|
||||||
|
"Solstice",
|
||||||
|
"Comet",
|
||||||
|
"Eclipse",
|
||||||
|
"Zenith",
|
||||||
|
"Celestia",
|
||||||
|
"Nova",
|
||||||
|
"Quasar",
|
||||||
|
"Lyra",
|
||||||
|
"Andromeda",
|
||||||
|
"Twilight",
|
||||||
|
// Magical & fantastical
|
||||||
|
"Camelot",
|
||||||
|
"Reverie",
|
||||||
|
"Arcane",
|
||||||
|
"Spellbound",
|
||||||
|
"Mirage",
|
||||||
|
"Oracle",
|
||||||
|
"Seraphim",
|
||||||
|
"Ethereal",
|
||||||
|
"Labyrinth",
|
||||||
|
"Enchantment",
|
||||||
|
// Nature & cosy
|
||||||
|
"Sakura",
|
||||||
|
"Ember",
|
||||||
|
"Cascade",
|
||||||
|
"Zephyr",
|
||||||
|
"Serendipity",
|
||||||
|
"Solace",
|
||||||
|
"Blossom",
|
||||||
|
"Whisper",
|
||||||
|
"Dewdrop",
|
||||||
|
"Sunbeam",
|
||||||
|
"Willow",
|
||||||
|
"Clover",
|
||||||
|
"Honeybee",
|
||||||
|
"Buttercup",
|
||||||
|
"Dandelion",
|
||||||
|
// Japanese/anime-inspired
|
||||||
|
"Tsukimi",
|
||||||
|
"Hanami",
|
||||||
|
"Yozora",
|
||||||
|
"Hoshi",
|
||||||
|
"Koharu",
|
||||||
|
"Akari",
|
||||||
|
"Midori",
|
||||||
|
// Adventure & epic
|
||||||
|
"Odyssey",
|
||||||
|
"Wanderer",
|
||||||
|
"Horizon",
|
||||||
|
"Voyage",
|
||||||
|
"Pathfinder",
|
||||||
|
"Frontier",
|
||||||
|
// Whimsical & sweet
|
||||||
|
"Bubblegum",
|
||||||
|
"Marshmallow",
|
||||||
|
"Daydream",
|
||||||
|
"Whimsy",
|
||||||
|
"Jellybean",
|
||||||
|
"Sprinkle",
|
||||||
|
"Cupcake",
|
||||||
|
// Dreamy & poetic
|
||||||
|
"Revenant",
|
||||||
|
"Elysium",
|
||||||
|
"Halcyon",
|
||||||
|
"Ephemera",
|
||||||
|
"Serenade",
|
||||||
|
"Lullaby",
|
||||||
|
"Nocturne",
|
||||||
|
"Rhapsody",
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickRandomTabName(): string {
|
||||||
|
return TAB_NAMES[Math.floor(Math.random() * TAB_NAMES.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConversationsStore() {
|
function createConversationsStore() {
|
||||||
@@ -59,7 +145,7 @@ function createConversationsStore() {
|
|||||||
const id = generateConversationId();
|
const id = generateConversationId();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: name || `Conversation ${conversationCounter}`,
|
name: name ?? pickRandomTabName(),
|
||||||
terminalLines: [],
|
terminalLines: [],
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
connectionStatus: "disconnected",
|
connectionStatus: "disconnected",
|
||||||
@@ -75,6 +161,10 @@ function createConversationsStore() {
|
|||||||
attachments: [],
|
attachments: [],
|
||||||
summary: null,
|
summary: null,
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
|
taskStartTime: null,
|
||||||
|
successSoundFired: false,
|
||||||
|
taskStartSoundFired: false,
|
||||||
|
draftText: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +173,7 @@ function createConversationsStore() {
|
|||||||
function ensureInitialized() {
|
function ensureInitialized() {
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
initialized = true;
|
initialized = true;
|
||||||
const initialConversation = createNewConversation("Main");
|
const initialConversation = createNewConversation();
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
convs.set(initialConversation.id, initialConversation);
|
convs.set(initialConversation.id, initialConversation);
|
||||||
return convs;
|
return convs;
|
||||||
@@ -196,7 +286,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 +309,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 +454,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 +912,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,292 @@
|
|||||||
|
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 {
|
||||||
|
formatCost,
|
||||||
|
formatAlertType,
|
||||||
|
getAlertMessage,
|
||||||
|
costTrackingStore,
|
||||||
|
formattedCosts,
|
||||||
|
type AlertType,
|
||||||
|
type CostAlert,
|
||||||
|
} from "./costTracking";
|
||||||
|
|
||||||
|
vi.mock("$lib/notifications/notificationManager", () => ({
|
||||||
|
notificationManager: {
|
||||||
|
notifyCostAlert: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("formatCost", () => {
|
||||||
|
it("formats amounts below $0.01 to 4 decimal places", () => {
|
||||||
|
expect(formatCost(0)).toBe("$0.0000");
|
||||||
|
expect(formatCost(0.001)).toBe("$0.0010");
|
||||||
|
expect(formatCost(0.0099)).toBe("$0.0099");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats amounts between $0.01 and $1 to 3 decimal places", () => {
|
||||||
|
expect(formatCost(0.01)).toBe("$0.010");
|
||||||
|
expect(formatCost(0.123)).toBe("$0.123");
|
||||||
|
expect(formatCost(0.999)).toBe("$0.999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats amounts $1 and above to 2 decimal places", () => {
|
||||||
|
expect(formatCost(1)).toBe("$1.00");
|
||||||
|
expect(formatCost(1.5)).toBe("$1.50");
|
||||||
|
expect(formatCost(100.99)).toBe("$100.99");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatAlertType", () => {
|
||||||
|
it("formats Daily as Today", () => {
|
||||||
|
expect(formatAlertType("Daily")).toBe("Today");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats Weekly as This Week", () => {
|
||||||
|
expect(formatAlertType("Weekly")).toBe("This Week");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats Monthly as This Month", () => {
|
||||||
|
expect(formatAlertType("Monthly")).toBe("This Month");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles all AlertType values", () => {
|
||||||
|
const types: AlertType[] = ["Daily", "Weekly", "Monthly"];
|
||||||
|
const results = types.map(formatAlertType);
|
||||||
|
expect(results).toEqual(["Today", "This Week", "This Month"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAlertMessage", () => {
|
||||||
|
it("generates a message for a Daily alert", () => {
|
||||||
|
const alert: CostAlert = {
|
||||||
|
alert_type: "Daily",
|
||||||
|
threshold: 1.0,
|
||||||
|
current_cost: 1.5,
|
||||||
|
};
|
||||||
|
const message = getAlertMessage(alert);
|
||||||
|
expect(message).toContain("Today");
|
||||||
|
expect(message).toContain("$1.50");
|
||||||
|
expect(message).toContain("$1.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a message for a Weekly alert", () => {
|
||||||
|
const alert: CostAlert = {
|
||||||
|
alert_type: "Weekly",
|
||||||
|
threshold: 5.0,
|
||||||
|
current_cost: 6.0,
|
||||||
|
};
|
||||||
|
const message = getAlertMessage(alert);
|
||||||
|
expect(message).toContain("This Week");
|
||||||
|
expect(message).toContain("$6.00");
|
||||||
|
expect(message).toContain("$5.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a message for a Monthly alert", () => {
|
||||||
|
const alert: CostAlert = {
|
||||||
|
alert_type: "Monthly",
|
||||||
|
threshold: 20.0,
|
||||||
|
current_cost: 25.0,
|
||||||
|
};
|
||||||
|
const message = getAlertMessage(alert);
|
||||||
|
expect(message).toContain("This Month");
|
||||||
|
expect(message).toContain("$25.00");
|
||||||
|
expect(message).toContain("$20.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes threshold and current cost in the message", () => {
|
||||||
|
const alert: CostAlert = {
|
||||||
|
alert_type: "Daily",
|
||||||
|
threshold: 0.005,
|
||||||
|
current_cost: 0.007,
|
||||||
|
};
|
||||||
|
const message = getAlertMessage(alert);
|
||||||
|
expect(message).toContain("$0.0070");
|
||||||
|
expect(message).toContain("$0.0050");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("costTrackingStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
costTrackingStore.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refresh", () => {
|
||||||
|
it("loads cost data from the backend", async () => {
|
||||||
|
setMockInvokeResult("get_today_cost", 1.5);
|
||||||
|
setMockInvokeResult("get_week_cost", 5.25);
|
||||||
|
setMockInvokeResult("get_month_cost", 20.0);
|
||||||
|
setMockInvokeResult("get_cost_alerts", []);
|
||||||
|
|
||||||
|
await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
const state = get(costTrackingStore);
|
||||||
|
expect(state.todayCost).toBe(1.5);
|
||||||
|
expect(state.weekCost).toBe(5.25);
|
||||||
|
expect(state.monthCost).toBe(20.0);
|
||||||
|
expect(state.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers notifications for any returned alerts", async () => {
|
||||||
|
const { notificationManager } = await import("$lib/notifications/notificationManager");
|
||||||
|
const alert: CostAlert = { alert_type: "Daily", threshold: 1.0, current_cost: 1.5 };
|
||||||
|
setMockInvokeResult("get_today_cost", 1.5);
|
||||||
|
setMockInvokeResult("get_week_cost", 0);
|
||||||
|
setMockInvokeResult("get_month_cost", 0);
|
||||||
|
setMockInvokeResult("get_cost_alerts", [alert]);
|
||||||
|
|
||||||
|
await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
expect(notificationManager.notifyCostAlert).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Today")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns alerts from refresh", async () => {
|
||||||
|
const alert: CostAlert = { alert_type: "Weekly", threshold: 5.0, current_cost: 6.0 };
|
||||||
|
setMockInvokeResult("get_today_cost", 0);
|
||||||
|
setMockInvokeResult("get_week_cost", 6.0);
|
||||||
|
setMockInvokeResult("get_month_cost", 0);
|
||||||
|
setMockInvokeResult("get_cost_alerts", [alert]);
|
||||||
|
|
||||||
|
const result = await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
expect(result).toEqual([alert]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles refresh errors gracefully and returns empty array", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("get_today_cost", new Error("Backend error"));
|
||||||
|
|
||||||
|
const result = await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Failed to refresh cost tracking:", expect.any(Error));
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSummary", () => {
|
||||||
|
it("fetches and stores a cost summary", async () => {
|
||||||
|
const mockSummary = {
|
||||||
|
period_days: 7,
|
||||||
|
total_input_tokens: 1000,
|
||||||
|
total_output_tokens: 500,
|
||||||
|
total_cost: 0.05,
|
||||||
|
total_messages: 20,
|
||||||
|
total_sessions: 3,
|
||||||
|
average_daily_cost: 0.007,
|
||||||
|
daily_breakdown: [],
|
||||||
|
};
|
||||||
|
setMockInvokeResult("get_cost_summary", mockSummary);
|
||||||
|
|
||||||
|
const result = await costTrackingStore.getSummary(7);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSummary);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("get_cost_summary", { days: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null and logs error on failure", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("get_cost_summary", new Error("Summary unavailable"));
|
||||||
|
|
||||||
|
const result = await costTrackingStore.getSummary(30);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Failed to get cost summary:", expect.any(Error));
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setAlertThresholds", () => {
|
||||||
|
it("persists new thresholds to the backend", async () => {
|
||||||
|
const thresholds = { daily: 2.0, weekly: 10.0, monthly: 40.0 };
|
||||||
|
|
||||||
|
await costTrackingStore.setAlertThresholds(thresholds);
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("set_cost_alert_thresholds", {
|
||||||
|
daily: 2.0,
|
||||||
|
weekly: 10.0,
|
||||||
|
monthly: 40.0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs an error when the backend call fails", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("set_cost_alert_thresholds", new Error("Failed"));
|
||||||
|
|
||||||
|
await costTrackingStore.setAlertThresholds({ daily: 1.0, weekly: null, monthly: null });
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Failed to set alert thresholds:", expect.any(Error));
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportCsv", () => {
|
||||||
|
it("returns the CSV string from the backend", async () => {
|
||||||
|
setMockInvokeResult("export_cost_csv", "date,cost\n2026-01-01,1.50");
|
||||||
|
|
||||||
|
const result = await costTrackingStore.exportCsv(7);
|
||||||
|
|
||||||
|
expect(result).toBe("date,cost\n2026-01-01,1.50");
|
||||||
|
expect(invoke).toHaveBeenCalledWith("export_cost_csv", { days: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null and logs error on failure", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("export_cost_csv", new Error("Export failed"));
|
||||||
|
|
||||||
|
const result = await costTrackingStore.exportCsv(30);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Failed to export CSV:", expect.any(Error));
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reset", () => {
|
||||||
|
it("resets costs back to zero", async () => {
|
||||||
|
setMockInvokeResult("get_today_cost", 5.0);
|
||||||
|
setMockInvokeResult("get_week_cost", 15.0);
|
||||||
|
setMockInvokeResult("get_month_cost", 50.0);
|
||||||
|
setMockInvokeResult("get_cost_alerts", []);
|
||||||
|
await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
costTrackingStore.reset();
|
||||||
|
|
||||||
|
const state = get(costTrackingStore);
|
||||||
|
expect(state.todayCost).toBe(0);
|
||||||
|
expect(state.weekCost).toBe(0);
|
||||||
|
expect(state.monthCost).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formattedCosts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
costTrackingStore.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats the initial zero costs correctly", () => {
|
||||||
|
const costs = get(formattedCosts);
|
||||||
|
expect(costs.today).toBe("$0.0000");
|
||||||
|
expect(costs.week).toBe("$0.0000");
|
||||||
|
expect(costs.month).toBe("$0.0000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects updated costs after a refresh", async () => {
|
||||||
|
setMockInvokeResult("get_today_cost", 1.5);
|
||||||
|
setMockInvokeResult("get_week_cost", 5.0);
|
||||||
|
setMockInvokeResult("get_month_cost", 20.0);
|
||||||
|
setMockInvokeResult("get_cost_alerts", []);
|
||||||
|
await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
const costs = get(formattedCosts);
|
||||||
|
expect(costs.today).toBe("$1.50");
|
||||||
|
expect(costs.week).toBe("$5.00");
|
||||||
|
expect(costs.month).toBe("$20.00");
|
||||||
|
expect(costs.todayRaw).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { emitMockEvent } from "../../../vitest.setup";
|
||||||
|
import { debugConsoleStore, filteredLogs } from "./debugConsole";
|
||||||
|
|
||||||
|
describe("debugConsoleStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
debugConsoleStore.clear();
|
||||||
|
debugConsoleStore.close();
|
||||||
|
debugConsoleStore.setFilterLevel("all");
|
||||||
|
debugConsoleStore.setAutoScroll(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
debugConsoleStore.restoreConsole();
|
||||||
|
debugConsoleStore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes with correct default state", () => {
|
||||||
|
const state = get(debugConsoleStore);
|
||||||
|
expect(state.logs).toEqual([]);
|
||||||
|
expect(state.isOpen).toBe(false);
|
||||||
|
expect(state.maxLogs).toBe(1000);
|
||||||
|
expect(state.filterLevel).toBe("all");
|
||||||
|
expect(state.autoScroll).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggle", () => {
|
||||||
|
it("opens when currently closed", () => {
|
||||||
|
debugConsoleStore.toggle();
|
||||||
|
expect(get(debugConsoleStore).isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when currently open", () => {
|
||||||
|
debugConsoleStore.open();
|
||||||
|
debugConsoleStore.toggle();
|
||||||
|
expect(get(debugConsoleStore).isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("open", () => {
|
||||||
|
it("sets isOpen to true", () => {
|
||||||
|
debugConsoleStore.open();
|
||||||
|
expect(get(debugConsoleStore).isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("close", () => {
|
||||||
|
it("sets isOpen to false", () => {
|
||||||
|
debugConsoleStore.open();
|
||||||
|
debugConsoleStore.close();
|
||||||
|
expect(get(debugConsoleStore).isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clear", () => {
|
||||||
|
it("removes all log entries", async () => {
|
||||||
|
await debugConsoleStore.setupBackendLogsListener();
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "test entry" });
|
||||||
|
expect(get(debugConsoleStore).logs.length).toBe(1);
|
||||||
|
debugConsoleStore.clear();
|
||||||
|
expect(get(debugConsoleStore).logs).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setFilterLevel", () => {
|
||||||
|
it("updates filterLevel to the specified level", () => {
|
||||||
|
debugConsoleStore.setFilterLevel("error");
|
||||||
|
expect(get(debugConsoleStore).filterLevel).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can reset filterLevel back to all", () => {
|
||||||
|
debugConsoleStore.setFilterLevel("warn");
|
||||||
|
debugConsoleStore.setFilterLevel("all");
|
||||||
|
expect(get(debugConsoleStore).filterLevel).toBe("all");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setAutoScroll", () => {
|
||||||
|
it("disables autoScroll", () => {
|
||||||
|
debugConsoleStore.setAutoScroll(false);
|
||||||
|
expect(get(debugConsoleStore).autoScroll).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-enables autoScroll", () => {
|
||||||
|
debugConsoleStore.setAutoScroll(false);
|
||||||
|
debugConsoleStore.setAutoScroll(true);
|
||||||
|
expect(get(debugConsoleStore).autoScroll).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setupConsoleCapture", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
debugConsoleStore.restoreConsole();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures console.log calls as info-level frontend logs", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.log("hello world");
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "hello world");
|
||||||
|
expect(captured).toBeDefined();
|
||||||
|
expect(captured?.level).toBe("info");
|
||||||
|
expect(captured?.source).toBe("frontend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures console.info calls as info-level logs", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.info("info message");
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "info message");
|
||||||
|
expect(captured?.level).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures console.warn calls as warn-level logs", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.warn("warning message");
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "warning message");
|
||||||
|
expect(captured?.level).toBe("warn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures console.error calls as error-level logs", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.error("error message");
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "error message");
|
||||||
|
expect(captured?.level).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures console.debug calls as debug-level logs", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.debug("debug message");
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "debug message");
|
||||||
|
expect(captured?.level).toBe("debug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins multiple console arguments with spaces", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.log("hello", "world", 42);
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "hello world 42");
|
||||||
|
expect(captured).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures unhandled window error events", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
const errorEvent = new ErrorEvent("error", {
|
||||||
|
message: "Test unhandled error",
|
||||||
|
filename: "test.js",
|
||||||
|
lineno: 10,
|
||||||
|
colno: 5,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(errorEvent);
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find(
|
||||||
|
(l) => l.level === "error" && l.message.includes("[Unhandled Error]")
|
||||||
|
);
|
||||||
|
expect(captured).toBeDefined();
|
||||||
|
expect(captured?.message).toContain("Test unhandled error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures unhandled promise rejection events", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
const rejectionEvent = Object.assign(new Event("unhandledrejection"), {
|
||||||
|
reason: "test rejection reason",
|
||||||
|
});
|
||||||
|
window.dispatchEvent(rejectionEvent);
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find(
|
||||||
|
(l) => l.level === "error" && l.message.includes("[Unhandled Promise Rejection]")
|
||||||
|
);
|
||||||
|
expect(captured).toBeDefined();
|
||||||
|
expect(captured?.message).toContain("test rejection reason");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("restoreConsole", () => {
|
||||||
|
it("stops capturing console output after restore", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
debugConsoleStore.restoreConsole();
|
||||||
|
const countBefore = get(debugConsoleStore).logs.length;
|
||||||
|
console.log("this should not be captured");
|
||||||
|
expect(get(debugConsoleStore).logs.length).toBe(countBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setupBackendLogsListener", () => {
|
||||||
|
it("captures backend logs emitted via debug:log event", async () => {
|
||||||
|
await debugConsoleStore.setupBackendLogsListener();
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "backend message" });
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
expect(logs.length).toBe(1);
|
||||||
|
expect(logs[0].level).toBe("info");
|
||||||
|
expect(logs[0].message).toBe("backend message");
|
||||||
|
expect(logs[0].source).toBe("backend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles different log levels from backend", async () => {
|
||||||
|
await debugConsoleStore.setupBackendLogsListener();
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "backend error" });
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
expect(logs[0].level).toBe("error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circular buffer", () => {
|
||||||
|
it("drops oldest log when exceeding 1000-entry limit", async () => {
|
||||||
|
await debugConsoleStore.setupBackendLogsListener();
|
||||||
|
for (let i = 0; i < 1001; i++) {
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: `log ${i}` });
|
||||||
|
}
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
expect(logs.length).toBe(1000);
|
||||||
|
expect(logs[0].message).toBe("log 1");
|
||||||
|
expect(logs[999].message).toBe("log 1000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filteredLogs derived store", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
debugConsoleStore.clear();
|
||||||
|
debugConsoleStore.setFilterLevel("all");
|
||||||
|
await debugConsoleStore.setupBackendLogsListener();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
debugConsoleStore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all logs when filterLevel is all", () => {
|
||||||
|
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||||
|
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||||
|
expect(get(filteredLogs).length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only error logs when filterLevel is error", () => {
|
||||||
|
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||||
|
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||||
|
debugConsoleStore.setFilterLevel("error");
|
||||||
|
const logs = get(filteredLogs);
|
||||||
|
expect(logs.length).toBe(1);
|
||||||
|
expect(logs[0].level).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warn and error logs when filterLevel is warn", () => {
|
||||||
|
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||||
|
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||||
|
debugConsoleStore.setFilterLevel("warn");
|
||||||
|
const logs = get(filteredLogs);
|
||||||
|
expect(logs.length).toBe(2);
|
||||||
|
expect(logs.every((l) => l.level === "warn" || l.level === "error")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes debug logs when filterLevel is info", () => {
|
||||||
|
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||||
|
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||||
|
debugConsoleStore.setFilterLevel("info");
|
||||||
|
const logs = get(filteredLogs);
|
||||||
|
expect(logs.length).toBe(3);
|
||||||
|
expect(logs.some((l) => l.level === "debug")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all log levels when filterLevel is debug", () => {
|
||||||
|
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||||
|
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||||
|
debugConsoleStore.setFilterLevel("debug");
|
||||||
|
expect(get(filteredLogs).length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to raw string when toLocaleString throws", () => {
|
||||||
|
const spy = vi.spyOn(Date.prototype, "toLocaleString").mockImplementation(() => {
|
||||||
|
throw new Error("Locale not supported");
|
||||||
|
});
|
||||||
|
const ts = "2026-01-15T14:30:00.000Z";
|
||||||
|
const result = draftsStore.formatTimestamp(ts);
|
||||||
|
expect(result).toBe(ts);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { editorStore } from "$lib/stores/editor";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
|
||||||
|
const FILE_CONTENT = "// test file content";
|
||||||
|
|
||||||
|
// Reset tabs between tests
|
||||||
|
afterEach(async () => {
|
||||||
|
const tabs = get(editorStore.tabs);
|
||||||
|
for (const tab of tabs) {
|
||||||
|
editorStore.closeTab(tab.id);
|
||||||
|
}
|
||||||
|
editorStore.hideEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - openFile (tests getLanguageFromPath)", () => {
|
||||||
|
const testCases: [string, string][] = [
|
||||||
|
["file.ts", "typescript"],
|
||||||
|
["file.tsx", "typescript"],
|
||||||
|
["file.js", "javascript"],
|
||||||
|
["file.jsx", "javascript"],
|
||||||
|
["file.py", "python"],
|
||||||
|
["file.rs", "rust"],
|
||||||
|
["file.go", "go"],
|
||||||
|
["file.java", "java"],
|
||||||
|
["file.c", "c"],
|
||||||
|
["file.cpp", "cpp"],
|
||||||
|
["file.h", "c"],
|
||||||
|
["file.hpp", "cpp"],
|
||||||
|
["file.cs", "csharp"],
|
||||||
|
["file.rb", "ruby"],
|
||||||
|
["file.php", "php"],
|
||||||
|
["file.swift", "swift"],
|
||||||
|
["file.kt", "kotlin"],
|
||||||
|
["file.scala", "scala"],
|
||||||
|
["file.html", "html"],
|
||||||
|
["file.htm", "html"],
|
||||||
|
["file.css", "css"],
|
||||||
|
["file.scss", "scss"],
|
||||||
|
["file.sass", "sass"],
|
||||||
|
["file.less", "less"],
|
||||||
|
["file.json", "json"],
|
||||||
|
["file.xml", "xml"],
|
||||||
|
["file.yaml", "yaml"],
|
||||||
|
["file.yml", "yaml"],
|
||||||
|
["file.toml", "toml"],
|
||||||
|
["file.md", "markdown"],
|
||||||
|
["file.markdown", "markdown"],
|
||||||
|
["file.sql", "sql"],
|
||||||
|
["file.sh", "shell"],
|
||||||
|
["file.bash", "shell"],
|
||||||
|
["file.zsh", "shell"],
|
||||||
|
["file.ps1", "powershell"],
|
||||||
|
["file.svelte", "svelte"],
|
||||||
|
["file.vue", "vue"],
|
||||||
|
["file.graphql", "graphql"],
|
||||||
|
["file.gql", "graphql"],
|
||||||
|
["file.lua", "lua"],
|
||||||
|
["file.r", "r"],
|
||||||
|
["file.dart", "dart"],
|
||||||
|
["file.elm", "elm"],
|
||||||
|
["file.ex", "elixir"],
|
||||||
|
["file.exs", "elixir"],
|
||||||
|
["file.erl", "erlang"],
|
||||||
|
["file.hs", "haskell"],
|
||||||
|
["file.clj", "clojure"],
|
||||||
|
["file.lisp", "lisp"],
|
||||||
|
["file.ml", "ocaml"],
|
||||||
|
["file.fs", "fsharp"],
|
||||||
|
["file.zig", "zig"],
|
||||||
|
["file.nim", "nim"],
|
||||||
|
["file.v", "v"],
|
||||||
|
["file.wasm", "wasm"],
|
||||||
|
["file.wat", "wasm"],
|
||||||
|
["dockerfile", "plaintext"],
|
||||||
|
["file.unknown", "plaintext"],
|
||||||
|
["file", "plaintext"],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(testCases)("maps %s to language %s", async (filename, expectedLanguage) => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile(`/path/to/${filename}`);
|
||||||
|
const activeTab = get(editorStore.activeTab);
|
||||||
|
expect(activeTab?.language).toBe(expectedLanguage);
|
||||||
|
if (activeTab) editorStore.closeTab(activeTab.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a new tab and makes it active", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
const tabs = get(editorStore.tabs);
|
||||||
|
const activeTab = get(editorStore.activeTab);
|
||||||
|
expect(tabs).toHaveLength(1);
|
||||||
|
expect(activeTab?.fileName).toBe("file.ts");
|
||||||
|
expect(activeTab?.content).toBe(FILE_CONTENT);
|
||||||
|
expect(activeTab?.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to existing tab instead of opening a duplicate", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
const firstTabId = get(editorStore.activeTabId);
|
||||||
|
|
||||||
|
// Open another file to change active tab
|
||||||
|
await editorStore.openFile("/path/to/other.ts");
|
||||||
|
const secondTabId = get(editorStore.activeTabId);
|
||||||
|
expect(secondTabId).not.toBe(firstTabId);
|
||||||
|
|
||||||
|
// Re-open first file — should switch to existing tab
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
expect(get(editorStore.activeTabId)).toBe(firstTabId);
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles open file errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||||
|
await editorStore.openFile("/path/to/missing.ts");
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(0);
|
||||||
|
expect(get(editorStore.saveError)).toContain("Failed to open file");
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts filename from path correctly", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/deep/nested/path/component.svelte");
|
||||||
|
expect(get(editorStore.activeTab)?.fileName).toBe("component.svelte");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - saveFile", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
// Make it dirty first
|
||||||
|
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the active tab successfully", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
await editorStore.saveFile();
|
||||||
|
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
||||||
|
expect(get(editorStore.activeTab)?.originalContent).toBe("modified content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a specific tab by ID", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
const tabId = get(editorStore.activeTabId)!;
|
||||||
|
await editorStore.saveFile(tabId);
|
||||||
|
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when no tab ID matches", async () => {
|
||||||
|
await editorStore.saveFile("non-existent-tab");
|
||||||
|
// No error = pass
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - saveFile error", () => {
|
||||||
|
it("sets saveError when write fails", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file-err.ts");
|
||||||
|
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("write_file_content", new Error("Permission denied"));
|
||||||
|
await editorStore.saveFile().catch(() => {});
|
||||||
|
expect(get(editorStore.saveError)).toContain("Failed to save file");
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - updateTabContent", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates content and marks tab as dirty", () => {
|
||||||
|
const tabId = get(editorStore.activeTabId)!;
|
||||||
|
editorStore.updateTabContent(tabId, "new content");
|
||||||
|
const tab = get(editorStore.activeTab);
|
||||||
|
expect(tab?.content).toBe("new content");
|
||||||
|
expect(tab?.isDirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks tab as clean when content matches original", () => {
|
||||||
|
const tabId = get(editorStore.activeTabId)!;
|
||||||
|
editorStore.updateTabContent(tabId, "modified");
|
||||||
|
expect(get(editorStore.activeTab)?.isDirty).toBe(true);
|
||||||
|
editorStore.updateTabContent(tabId, FILE_CONTENT);
|
||||||
|
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasDirtyTabs is true when any tab is dirty", () => {
|
||||||
|
const tabId = get(editorStore.activeTabId)!;
|
||||||
|
expect(get(editorStore.hasDirtyTabs)).toBe(false);
|
||||||
|
editorStore.updateTabContent(tabId, "dirty content");
|
||||||
|
expect(get(editorStore.hasDirtyTabs)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - closeTab", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file1.ts");
|
||||||
|
await editorStore.openFile("/path/to/file2.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the specified tab", () => {
|
||||||
|
const tabs = get(editorStore.tabs);
|
||||||
|
editorStore.closeTab(tabs[0].id);
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(1);
|
||||||
|
expect(get(editorStore.tabs)[0].filePath).toBe("/path/to/file2.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets active tab to remaining tab when active tab is closed", () => {
|
||||||
|
const activeTabId = get(editorStore.activeTabId)!;
|
||||||
|
editorStore.closeTab(activeTabId);
|
||||||
|
expect(get(editorStore.activeTabId)).not.toBe(activeTabId);
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - setActiveTab", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file1.ts");
|
||||||
|
await editorStore.openFile("/path/to/file2.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the active tab to the specified ID", () => {
|
||||||
|
const tabs = get(editorStore.tabs);
|
||||||
|
editorStore.setActiveTab(tabs[0].id);
|
||||||
|
expect(get(editorStore.activeTabId)).toBe(tabs[0].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - visibility controls", () => {
|
||||||
|
it("showEditor sets isEditorVisible to true", () => {
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||||
|
editorStore.showEditor();
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hideEditor sets isEditorVisible to false", () => {
|
||||||
|
editorStore.showEditor();
|
||||||
|
editorStore.hideEditor();
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleEditor toggles isEditorVisible", () => {
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||||
|
editorStore.toggleEditor();
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(true);
|
||||||
|
editorStore.toggleEditor();
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - toggleFileBrowser", () => {
|
||||||
|
it("toggles the file browser visibility", () => {
|
||||||
|
const initial = get(editorStore.isFileBrowserOpen);
|
||||||
|
editorStore.toggleFileBrowser();
|
||||||
|
expect(get(editorStore.isFileBrowserOpen)).toBe(!initial);
|
||||||
|
editorStore.toggleFileBrowser();
|
||||||
|
expect(get(editorStore.isFileBrowserOpen)).toBe(initial);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - file system operations", () => {
|
||||||
|
it("createFile invokes backend and refreshes directory", async () => {
|
||||||
|
setMockInvokeResult("create_file", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.createFile("/path/to", "new-file.ts");
|
||||||
|
// No error = success
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createFile handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("create_file", new Error("Permission denied"));
|
||||||
|
await editorStore.createFile("/path/to", "new-file.ts");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create file:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createDirectory invokes backend and refreshes", async () => {
|
||||||
|
setMockInvokeResult("create_directory", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.createDirectory("/path/to", "new-dir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createDirectory handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("create_directory", new Error("Failed"));
|
||||||
|
await editorStore.createDirectory("/path/to", "new-dir");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create directory:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteFile invokes backend and refreshes", async () => {
|
||||||
|
setMockInvokeResult("delete_file", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.deleteFile("/path/to/file.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteFile handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("delete_file", new Error("Failed"));
|
||||||
|
await editorStore.deleteFile("/path/to/file.ts");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete file:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteDirectory invokes backend and refreshes", async () => {
|
||||||
|
setMockInvokeResult("delete_directory", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.deleteDirectory("/path/to/dir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteDirectory handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("delete_directory", new Error("Failed"));
|
||||||
|
await editorStore.deleteDirectory("/path/to/dir");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete directory:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renamePath invokes backend and refreshes", async () => {
|
||||||
|
setMockInvokeResult("rename_path", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renamePath handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("rename_path", new Error("Failed"));
|
||||||
|
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to rename:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - initializeFileTree", () => {
|
||||||
|
it("loads directory entries and updates file tree", async () => {
|
||||||
|
const entries = [
|
||||||
|
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", entries);
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
expect(get(editorStore.fileTree)).toEqual(entries);
|
||||||
|
expect(get(editorStore.currentDirectory)).toBe("/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("list_directory", new Error("Failed"));
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
expect(get(editorStore.isLoadingTree)).toBe(false);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - toggleDirectory", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
path: "/path/dir",
|
||||||
|
name: "dir",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", entries);
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when entry is not a directory", async () => {
|
||||||
|
const nonDirEntry = {
|
||||||
|
path: "/path/file.ts",
|
||||||
|
name: "file.ts",
|
||||||
|
isDirectory: false,
|
||||||
|
};
|
||||||
|
const treeBefore = get(editorStore.fileTree);
|
||||||
|
await editorStore.toggleDirectory(nonDirEntry);
|
||||||
|
expect(get(editorStore.fileTree)).toEqual(treeBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands a collapsed directory and loads children asynchronously", async () => {
|
||||||
|
const children = [
|
||||||
|
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", children);
|
||||||
|
|
||||||
|
const dirEntry = get(editorStore.fileTree)[0];
|
||||||
|
await editorStore.toggleDirectory(dirEntry);
|
||||||
|
|
||||||
|
const updatedEntry = get(editorStore.fileTree)[0];
|
||||||
|
expect(updatedEntry.isExpanded).toBe(true);
|
||||||
|
expect(updatedEntry.children).toEqual(children);
|
||||||
|
expect(updatedEntry.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses an expanded directory", async () => {
|
||||||
|
const children = [
|
||||||
|
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", children);
|
||||||
|
|
||||||
|
// Expand first
|
||||||
|
const dirEntry = get(editorStore.fileTree)[0];
|
||||||
|
await editorStore.toggleDirectory(dirEntry);
|
||||||
|
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
|
||||||
|
|
||||||
|
// Now collapse
|
||||||
|
const expandedEntry = get(editorStore.fileTree)[0];
|
||||||
|
await editorStore.toggleDirectory(expandedEntry);
|
||||||
|
expect(get(editorStore.fileTree)[0].isExpanded).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands a directory that already has children without reloading", async () => {
|
||||||
|
// Re-init with a directory that already has children
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
path: "/path/dir",
|
||||||
|
name: "dir",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/path/dir/child.ts",
|
||||||
|
name: "child.ts",
|
||||||
|
isDirectory: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", entries);
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
|
||||||
|
// Toggle should expand without loading (children already present)
|
||||||
|
const dirEntry = get(editorStore.fileTree)[0];
|
||||||
|
await editorStore.toggleDirectory(dirEntry);
|
||||||
|
|
||||||
|
const updatedEntry = get(editorStore.fileTree)[0];
|
||||||
|
expect(updatedEntry.isExpanded).toBe(true);
|
||||||
|
expect(updatedEntry.children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles tree with multiple entries when toggling — exercises non-matching leaf fallback", async () => {
|
||||||
|
// Tree has dir + a sibling file; toggling dir exercises the `return e` branch for the file
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
path: "/path/dir",
|
||||||
|
name: "dir",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", entries);
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
|
||||||
|
const children: never[] = [];
|
||||||
|
setMockInvokeResult("list_directory", children);
|
||||||
|
const dirEntry = get(editorStore.fileTree)[0];
|
||||||
|
await editorStore.toggleDirectory(dirEntry);
|
||||||
|
|
||||||
|
// The sibling file should remain unchanged
|
||||||
|
expect(get(editorStore.fileTree)[1].path).toBe("/path/file.ts");
|
||||||
|
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles nested directory toggle — exercises recursive updateTree branches", async () => {
|
||||||
|
// Tree: outer_dir → [inner_dir (no children), sibling_file]
|
||||||
|
// Toggling inner_dir covers the recursive path through outer's children
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
path: "/path/outer",
|
||||||
|
name: "outer",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/path/outer/inner",
|
||||||
|
name: "inner",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/path/outer/sibling.ts",
|
||||||
|
name: "sibling.ts",
|
||||||
|
isDirectory: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", entries);
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
|
||||||
|
const innerChildren = [
|
||||||
|
{
|
||||||
|
path: "/path/outer/inner/deep.ts",
|
||||||
|
name: "deep.ts",
|
||||||
|
isDirectory: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", innerChildren);
|
||||||
|
|
||||||
|
const innerEntry = get(editorStore.fileTree)[0].children![0];
|
||||||
|
await editorStore.toggleDirectory(innerEntry);
|
||||||
|
|
||||||
|
const outer = get(editorStore.fileTree)[0];
|
||||||
|
expect(outer.children![0].isExpanded).toBe(true);
|
||||||
|
expect(outer.children![0].children).toEqual(innerChildren);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - deleteFile closes open tab", () => {
|
||||||
|
it("closes the tab when the deleted file is open", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(1);
|
||||||
|
|
||||||
|
setMockInvokeResult("delete_file", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.deleteFile("/path/to/file.ts");
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - deleteDirectory closes open tabs", () => {
|
||||||
|
it("closes all tabs inside the deleted directory", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/dir/file1.ts");
|
||||||
|
await editorStore.openFile("/path/to/dir/nested/file2.ts");
|
||||||
|
await editorStore.openFile("/path/to/other.ts");
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(3);
|
||||||
|
|
||||||
|
setMockInvokeResult("delete_directory", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.deleteDirectory("/path/to/dir");
|
||||||
|
|
||||||
|
const remainingTabs = get(editorStore.tabs);
|
||||||
|
expect(remainingTabs).toHaveLength(1);
|
||||||
|
expect(remainingTabs[0].filePath).toBe("/path/to/other.ts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - renamePath updates open tabs", () => {
|
||||||
|
it("updates filePath and fileName when the renamed file is open in a tab", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/old-name.ts");
|
||||||
|
|
||||||
|
setMockInvokeResult("rename_path", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||||
|
|
||||||
|
const tab = get(editorStore.activeTab);
|
||||||
|
expect(tab?.filePath).toBe("/path/to/new-name.ts");
|
||||||
|
expect(tab?.fileName).toBe("new-name.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates filePaths for tabs inside a renamed directory", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/old-dir/file.ts");
|
||||||
|
|
||||||
|
setMockInvokeResult("rename_path", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.renamePath("/path/to/old-dir", "new-dir");
|
||||||
|
|
||||||
|
const tab = get(editorStore.activeTab);
|
||||||
|
expect(tab?.filePath).toBe("/path/to/new-dir/file.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unrelated tabs unchanged when renaming — exercises return-t fallback", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/unrelated.ts");
|
||||||
|
await editorStore.openFile("/path/to/old-name.ts");
|
||||||
|
|
||||||
|
setMockInvokeResult("rename_path", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||||
|
|
||||||
|
const tabs = get(editorStore.tabs);
|
||||||
|
const unrelated = tabs.find((t) => t.filePath === "/path/to/unrelated.ts");
|
||||||
|
expect(unrelated).toBeDefined();
|
||||||
|
expect(unrelated?.fileName).toBe("unrelated.ts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - refreshDirectory", () => {
|
||||||
|
it("reloads the entire tree when refreshing the root directory", async () => {
|
||||||
|
const initialEntries = [
|
||||||
|
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", initialEntries);
|
||||||
|
await editorStore.initializeFileTree("/root");
|
||||||
|
expect(get(editorStore.fileTree)).toHaveLength(1);
|
||||||
|
|
||||||
|
const updatedEntries = [
|
||||||
|
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||||
|
{ path: "/root/new.ts", name: "new.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", updatedEntries);
|
||||||
|
await editorStore.refreshDirectory("/root");
|
||||||
|
expect(get(editorStore.fileTree)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a subdirectory entry in the tree", async () => {
|
||||||
|
const initialEntries = [
|
||||||
|
{
|
||||||
|
path: "/root/subdir",
|
||||||
|
name: "subdir",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: true,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", initialEntries);
|
||||||
|
await editorStore.initializeFileTree("/root");
|
||||||
|
|
||||||
|
const subChildren = [
|
||||||
|
{ path: "/root/subdir/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", subChildren);
|
||||||
|
await editorStore.refreshDirectory("/root/subdir");
|
||||||
|
|
||||||
|
const tree = get(editorStore.fileTree);
|
||||||
|
expect(tree[0].children).toEqual(subChildren);
|
||||||
|
expect(tree[0].isExpanded).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
setShouldRestoreHistory,
|
||||||
|
setSavedHistory,
|
||||||
|
getShouldRestoreHistory,
|
||||||
|
getSavedHistory,
|
||||||
|
clearHistoryRestore,
|
||||||
|
} from "./historyRestore";
|
||||||
|
|
||||||
|
describe("historyRestore module", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearHistoryRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("shouldRestoreHistory is false by default", () => {
|
||||||
|
expect(getShouldRestoreHistory()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("savedHistory is null by default", () => {
|
||||||
|
expect(getSavedHistory()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setShouldRestoreHistory", () => {
|
||||||
|
it("sets shouldRestoreHistory to true", () => {
|
||||||
|
setShouldRestoreHistory(true);
|
||||||
|
expect(getShouldRestoreHistory()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets shouldRestoreHistory to false", () => {
|
||||||
|
setShouldRestoreHistory(true);
|
||||||
|
setShouldRestoreHistory(false);
|
||||||
|
expect(getShouldRestoreHistory()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setSavedHistory", () => {
|
||||||
|
it("sets the saved history string", () => {
|
||||||
|
setSavedHistory("some history content");
|
||||||
|
expect(getSavedHistory()).toBe("some history content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the saved history to null", () => {
|
||||||
|
setSavedHistory("some history content");
|
||||||
|
setSavedHistory(null);
|
||||||
|
expect(getSavedHistory()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearHistoryRestore", () => {
|
||||||
|
it("resets shouldRestoreHistory to false", () => {
|
||||||
|
setShouldRestoreHistory(true);
|
||||||
|
clearHistoryRestore();
|
||||||
|
expect(getShouldRestoreHistory()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets savedHistory to null", () => {
|
||||||
|
setSavedHistory("some content");
|
||||||
|
clearHistoryRestore();
|
||||||
|
expect(getSavedHistory()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears both values at once", () => {
|
||||||
|
setShouldRestoreHistory(true);
|
||||||
|
setSavedHistory("history");
|
||||||
|
clearHistoryRestore();
|
||||||
|
expect(getShouldRestoreHistory()).toBe(false);
|
||||||
|
expect(getSavedHistory()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { messageMode, getCurrentMode } from "./messageMode";
|
||||||
|
|
||||||
|
describe("messageMode store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
messageMode.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("defaults to chat mode", () => {
|
||||||
|
expect(get(messageMode)).toBe("chat");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set", () => {
|
||||||
|
it("sets the mode to the given value", () => {
|
||||||
|
messageMode.set("plan");
|
||||||
|
expect(get(messageMode)).toBe("plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can set any arbitrary mode string", () => {
|
||||||
|
messageMode.set("auto");
|
||||||
|
expect(get(messageMode)).toBe("auto");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reset", () => {
|
||||||
|
it("resets mode back to chat", () => {
|
||||||
|
messageMode.set("plan");
|
||||||
|
messageMode.reset();
|
||||||
|
expect(get(messageMode)).toBe("chat");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCurrentMode", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
messageMode.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns chat when in default state", () => {
|
||||||
|
expect(getCurrentMode()).toBe("chat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the currently set mode", () => {
|
||||||
|
messageMode.set("plan");
|
||||||
|
expect(getCurrentMode()).toBe("plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns chat after a reset", () => {
|
||||||
|
messageMode.set("auto");
|
||||||
|
messageMode.reset();
|
||||||
|
expect(getCurrentMode()).toBe("chat");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
const mockSetEnabled = vi.fn();
|
||||||
|
const mockSetGlobalVolume = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("$lib/notifications", () => ({
|
||||||
|
soundPlayer: {
|
||||||
|
setEnabled: mockSetEnabled,
|
||||||
|
setGlobalVolume: mockSetGlobalVolume,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// We need to control the config store's emitted values
|
||||||
|
const configWritable = writable({
|
||||||
|
notifications_enabled: true,
|
||||||
|
notification_volume: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./config", () => ({
|
||||||
|
configStore: {
|
||||||
|
config: { subscribe: configWritable.subscribe },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("notifications sync store", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
mockSetEnabled.mockReset();
|
||||||
|
mockSetGlobalVolume.mockReset();
|
||||||
|
configWritable.set({ notifications_enabled: true, notification_volume: 0.7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Re-import to clean up any lingering subscriptions
|
||||||
|
const { cleanupNotificationSync } = await import("./notifications");
|
||||||
|
cleanupNotificationSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initNotificationSync", () => {
|
||||||
|
it("syncs soundPlayer enabled state from config on init", async () => {
|
||||||
|
const { initNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
expect(mockSetEnabled).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs soundPlayer volume from config on init", async () => {
|
||||||
|
const { initNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates soundPlayer when config changes", async () => {
|
||||||
|
const { initNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
|
||||||
|
mockSetEnabled.mockReset();
|
||||||
|
mockSetGlobalVolume.mockReset();
|
||||||
|
|
||||||
|
configWritable.set({ notifications_enabled: false, notification_volume: 0.3 });
|
||||||
|
|
||||||
|
expect(mockSetEnabled).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not register a duplicate subscription when called twice", async () => {
|
||||||
|
const { initNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
initNotificationSync();
|
||||||
|
|
||||||
|
// Both calls should only produce one subscription (one initial sync)
|
||||||
|
expect(mockSetEnabled).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanupNotificationSync", () => {
|
||||||
|
it("stops reacting to config changes after cleanup", async () => {
|
||||||
|
const { initNotificationSync, cleanupNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
cleanupNotificationSync();
|
||||||
|
|
||||||
|
mockSetEnabled.mockReset();
|
||||||
|
configWritable.set({ notifications_enabled: false, notification_volume: 0.5 });
|
||||||
|
|
||||||
|
expect(mockSetEnabled).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is safe to call when not initialised", async () => {
|
||||||
|
const { cleanupNotificationSync } = await import("./notifications");
|
||||||
|
expect(() => cleanupNotificationSync()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows re-initialisation after cleanup", async () => {
|
||||||
|
const { initNotificationSync, cleanupNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
cleanupNotificationSync();
|
||||||
|
|
||||||
|
mockSetEnabled.mockReset();
|
||||||
|
initNotificationSync();
|
||||||
|
|
||||||
|
expect(mockSetEnabled).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { searchState, isSearchActive, searchQuery } from "./search";
|
||||||
|
|
||||||
|
describe("searchState store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchState.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("starts with empty query", () => {
|
||||||
|
const state = get(searchState);
|
||||||
|
expect(state.query).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts inactive", () => {
|
||||||
|
const state = get(searchState);
|
||||||
|
expect(state.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with zero match count", () => {
|
||||||
|
const state = get(searchState);
|
||||||
|
expect(state.matchCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with zero current match index", () => {
|
||||||
|
const state = get(searchState);
|
||||||
|
expect(state.currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setQuery", () => {
|
||||||
|
it("sets the query string", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
expect(get(searchState).query).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates the store when query is non-empty", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
expect(get(searchState).isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deactivates the store when query is empty", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
searchState.setQuery("");
|
||||||
|
expect(get(searchState).isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets currentMatchIndex to 0 on each query change", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.setQuery("world");
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setMatchCount", () => {
|
||||||
|
it("updates the match count", () => {
|
||||||
|
searchState.setMatchCount(7);
|
||||||
|
expect(get(searchState).matchCount).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reset currentMatchIndex", () => {
|
||||||
|
searchState.setQuery("test");
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.setMatchCount(10);
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nextMatch", () => {
|
||||||
|
it("advances to the next match index", () => {
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.nextMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around to 0 after the last match", () => {
|
||||||
|
searchState.setMatchCount(3);
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.nextMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays at 0 when match count is 0", () => {
|
||||||
|
searchState.setMatchCount(0);
|
||||||
|
searchState.nextMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("previousMatch", () => {
|
||||||
|
it("goes to the previous match index", () => {
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.previousMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around to last match when at index 0", () => {
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.previousMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays at 0 when match count is 0", () => {
|
||||||
|
searchState.setMatchCount(0);
|
||||||
|
searchState.previousMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clear", () => {
|
||||||
|
it("resets query to empty string", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(searchState).query).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets isActive to false", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(searchState).isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets matchCount to 0", () => {
|
||||||
|
searchState.setMatchCount(10);
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(searchState).matchCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets currentMatchIndex to 0", () => {
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSearchActive derived store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchState.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false initially", () => {
|
||||||
|
expect(get(isSearchActive)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when query is set", () => {
|
||||||
|
searchState.setQuery("test");
|
||||||
|
expect(get(isSearchActive)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false after clearing", () => {
|
||||||
|
searchState.setQuery("test");
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(isSearchActive)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searchQuery derived store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchState.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty string initially", () => {
|
||||||
|
expect(get(searchQuery)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects the current query", () => {
|
||||||
|
searchState.setQuery("my search");
|
||||||
|
expect(get(searchQuery)).toBe("my search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty after clearing", () => {
|
||||||
|
searchState.setQuery("test");
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(searchQuery)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { sessionsStore } from "$lib/stores/sessions";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
import type { SavedSession } from "$lib/stores/sessions";
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
|
save: vi.fn(),
|
||||||
|
open: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||||
|
writeTextFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
readTextFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||||
|
openPath: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeSavedSession = (overrides: Partial<SavedSession> = {}): SavedSession => ({
|
||||||
|
id: "session-1",
|
||||||
|
name: "Test Session",
|
||||||
|
created_at: "2026-03-03T10:00:00.000Z",
|
||||||
|
last_activity_at: "2026-03-03T11:00:00.000Z",
|
||||||
|
working_directory: "/home/naomi/code",
|
||||||
|
message_count: 2,
|
||||||
|
preview: "Hello world",
|
||||||
|
messages: [
|
||||||
|
{ id: "msg-1", type: "user", content: "Hello world", timestamp: "2026-03-03T10:00:00.000Z" },
|
||||||
|
{ id: "msg-2", type: "assistant", content: "Hi there!", timestamp: "2026-03-03T10:01:00.000Z" },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeConversation = () => ({
|
||||||
|
id: "conv-1",
|
||||||
|
name: "Test Conversation",
|
||||||
|
workingDirectory: "/home/naomi/code",
|
||||||
|
createdAt: new Date("2026-03-03T10:00:00.000Z"),
|
||||||
|
lastActivityAt: new Date("2026-03-03T11:00:00.000Z"),
|
||||||
|
terminalLines: [
|
||||||
|
{
|
||||||
|
id: "line-1",
|
||||||
|
type: "user",
|
||||||
|
content: "Hello",
|
||||||
|
timestamp: new Date("2026-03-03T10:00:00.000Z"),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "line-2",
|
||||||
|
type: "assistant",
|
||||||
|
content: "Hi",
|
||||||
|
timestamp: new Date("2026-03-03T10:01:00.000Z"),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - loadSessions", () => {
|
||||||
|
it("loads sessions from backend and updates the store", async () => {
|
||||||
|
const sessionList = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
|
||||||
|
setMockInvokeResult("list_sessions", sessionList);
|
||||||
|
await sessionsStore.loadSessions();
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual(sessionList);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("list_sessions", new Error("Backend error"));
|
||||||
|
await sessionsStore.loadSessions();
|
||||||
|
expect(spy).toHaveBeenCalledWith("Failed to load sessions:", expect.any(Error));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoading to false after completion", async () => {
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
await sessionsStore.loadSessions();
|
||||||
|
expect(get(sessionsStore.isLoading)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - loadSession", () => {
|
||||||
|
it("returns session data on success", async () => {
|
||||||
|
const session = makeSavedSession();
|
||||||
|
setMockInvokeResult("load_session", session);
|
||||||
|
const result = await sessionsStore.loadSession("session-1");
|
||||||
|
expect(result).toEqual(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", new Error("Not found"));
|
||||||
|
const result = await sessionsStore.loadSession("session-1");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - deleteSession", () => {
|
||||||
|
it("deletes session and reloads the session list", async () => {
|
||||||
|
setMockInvokeResult("delete_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
await sessionsStore.deleteSession("session-1");
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("delete_session", new Error("Failed"));
|
||||||
|
await sessionsStore.deleteSession("session-1");
|
||||||
|
expect(spy).toHaveBeenCalledWith("Failed to delete session:", expect.any(Error));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - searchSessions", () => {
|
||||||
|
it("calls loadSessions when query is empty", async () => {
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
await sessionsStore.searchSessions("");
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls loadSessions when query is whitespace-only", async () => {
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
await sessionsStore.searchSessions(" ");
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches with the given query", async () => {
|
||||||
|
const results = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
|
||||||
|
setMockInvokeResult("search_sessions", results);
|
||||||
|
await sessionsStore.searchSessions("test");
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual(results);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates searchQuery store", async () => {
|
||||||
|
setMockInvokeResult("search_sessions", []);
|
||||||
|
await sessionsStore.searchSessions("hello");
|
||||||
|
expect(get(sessionsStore.searchQuery)).toBe("hello");
|
||||||
|
await sessionsStore.searchSessions("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles search errors gracefully", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("search_sessions", new Error("Search failed"));
|
||||||
|
await sessionsStore.searchSessions("error-query");
|
||||||
|
expect(spy).toHaveBeenCalledWith("Failed to search sessions:", expect.any(Error));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - clearAllSessions", () => {
|
||||||
|
it("clears all sessions", async () => {
|
||||||
|
setMockInvokeResult("clear_all_sessions", undefined);
|
||||||
|
await sessionsStore.clearAllSessions();
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("clear_all_sessions", new Error("Failed"));
|
||||||
|
await sessionsStore.clearAllSessions();
|
||||||
|
expect(spy).toHaveBeenCalledWith("Failed to clear sessions:", expect.any(Error));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - saveConversation", () => {
|
||||||
|
it("saves conversation to backend", async () => {
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
await sessionsStore.saveConversation(makeConversation() as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles save errors gracefully", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("save_session", new Error("Save failed"));
|
||||||
|
await sessionsStore.saveConversation(makeConversation() as never);
|
||||||
|
expect(spy).toHaveBeenCalledWith("Failed to save session:", expect.any(Error));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty conversation", async () => {
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
const conv = { ...makeConversation(), terminalLines: [] };
|
||||||
|
await sessionsStore.saveConversation(conv as never);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules an auto-save after the debounce delay", async () => {
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
sessionsStore.scheduleAutoSave(makeConversation() as never);
|
||||||
|
await vi.advanceTimersByTimeAsync(2001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-save empty conversations", async () => {
|
||||||
|
const conv = { ...makeConversation(), terminalLines: [] };
|
||||||
|
sessionsStore.scheduleAutoSave(conv as never);
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a pending auto-save", () => {
|
||||||
|
const conv = makeConversation();
|
||||||
|
sessionsStore.scheduleAutoSave(conv as never);
|
||||||
|
sessionsStore.cancelAutoSave(conv.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles cancel when no auto-save is pending", () => {
|
||||||
|
sessionsStore.cancelAutoSave("non-existent-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears an existing pending auto-save when rescheduled for the same conversation", async () => {
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
const conv = makeConversation();
|
||||||
|
sessionsStore.scheduleAutoSave(conv as never);
|
||||||
|
// Schedule again before timer fires — hits clearTimeout branch (line 487)
|
||||||
|
sessionsStore.scheduleAutoSave(conv as never);
|
||||||
|
await vi.advanceTimersByTimeAsync(2001);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - exportSessionAsJson", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on successful export", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.json");
|
||||||
|
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when session not found", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", null);
|
||||||
|
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user cancels save dialog", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue(null);
|
||||||
|
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||||
|
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when writeTextFile throws", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.json");
|
||||||
|
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||||
|
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - exportSessionAsMarkdown", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates markdown with all message types", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const session = makeSavedSession({
|
||||||
|
messages: [
|
||||||
|
{ id: "1", type: "user", content: "User message", timestamp: "2026-03-03T10:00:00Z" },
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "assistant",
|
||||||
|
content: "Assistant reply",
|
||||||
|
timestamp: "2026-03-03T10:01:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
type: "tool_use",
|
||||||
|
content: "Tool input",
|
||||||
|
timestamp: "2026-03-03T10:02:00Z",
|
||||||
|
tool_name: "bash",
|
||||||
|
},
|
||||||
|
{ id: "4", type: "tool_result", content: "Tool output", timestamp: "2026-03-03T10:03:00Z" },
|
||||||
|
{ id: "5", type: "system", content: "System event", timestamp: "2026-03-03T10:04:00Z" },
|
||||||
|
{ id: "6", type: "error", content: "Error message", timestamp: "2026-03-03T10:05:00Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
setMockInvokeResult("load_session", session);
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.md");
|
||||||
|
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(true);
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("User message");
|
||||||
|
expect(content).toContain("Assistant reply");
|
||||||
|
expect(content).toContain("Tool: bash");
|
||||||
|
expect(content).toContain("Tool input");
|
||||||
|
expect(content).toContain("Tool output");
|
||||||
|
expect(content).toContain("System event");
|
||||||
|
expect(content).toContain("Error message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when session not found", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", null);
|
||||||
|
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user cancels dialog", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue(null);
|
||||||
|
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||||
|
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when writeTextFile throws", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.md");
|
||||||
|
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||||
|
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - exportSessionAsHtml (tests escapeHtml + generateHtmlExport)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates HTML and returns true on success", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(true);
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("<!DOCTYPE html>");
|
||||||
|
expect(content).toContain("Test Session");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML characters in session name", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession({ name: "<Test & 'Session'>" }));
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
await sessionsStore.exportSessionAsHtml("session-1");
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("<Test & 'Session'>");
|
||||||
|
expect(content).not.toContain("<Test & 'Session'>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML characters in message content", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult(
|
||||||
|
"load_session",
|
||||||
|
makeSavedSession({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "user",
|
||||||
|
content: '<script>alert("xss")</script>',
|
||||||
|
timestamp: "2026-03-03T10:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
await sessionsStore.exportSessionAsHtml("session-1");
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("<script>alert("xss")</script>");
|
||||||
|
expect(content).not.toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML in tool names", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult(
|
||||||
|
"load_session",
|
||||||
|
makeSavedSession({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "tool_use",
|
||||||
|
content: "input",
|
||||||
|
timestamp: "2026-03-03T10:00:00Z",
|
||||||
|
tool_name: "<dangerous>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
await sessionsStore.exportSessionAsHtml("session-1");
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("<dangerous>");
|
||||||
|
expect(content).not.toContain("<dangerous>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes metadata when includeMetadata is false", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
await sessionsStore.exportSessionAsHtml("session-1", false);
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).not.toContain('class="metadata"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when session not found", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", null);
|
||||||
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user cancels dialog", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue(null);
|
||||||
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||||
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when writeTextFile throws", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||||
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - exportSessionAsPdf (tests generatePrintableHtml)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates printable HTML and opens it in the browser", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { openPath } = await import("@tauri-apps/plugin-opener");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
||||||
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(true);
|
||||||
|
expect(openPath).toHaveBeenCalledWith("/output/session-print.html");
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("<!DOCTYPE html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes metadata when includeMetadata is false", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
||||||
|
await sessionsStore.exportSessionAsPdf("session-1", false);
|
||||||
|
expect(vi.mocked(writeTextFile).mock.calls[0][1]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when session not found", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", null);
|
||||||
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user cancels dialog", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue(null);
|
||||||
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||||
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when writeTextFile throws", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
||||||
|
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||||
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - importSession", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports session from JSON file and returns true", async () => {
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
vi.mocked(open).mockResolvedValue("/input/session.json");
|
||||||
|
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ session: makeSavedSession() }));
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
expect(await sessionsStore.importSession()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user cancels file dialog", async () => {
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
vi.mocked(open).mockResolvedValue(null);
|
||||||
|
expect(await sessionsStore.importSession()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when file has invalid format", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
vi.mocked(open).mockResolvedValue("/input/bad.json");
|
||||||
|
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ not_a_session: true }));
|
||||||
|
expect(await sessionsStore.importSession()).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
vi.mocked(open).mockRejectedValue(new Error("Dialog failed"));
|
||||||
|
expect(await sessionsStore.importSession()).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import {
|
import {
|
||||||
stats,
|
stats,
|
||||||
formattedStats,
|
formattedStats,
|
||||||
@@ -9,8 +11,13 @@ import {
|
|||||||
estimateMessageCost,
|
estimateMessageCost,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
MODEL_PRICING,
|
MODEL_PRICING,
|
||||||
|
checkBudget,
|
||||||
|
getBudgetStatusMessage,
|
||||||
|
getRemainingTokenBudget,
|
||||||
|
getRemainingCostBudget,
|
||||||
|
initStatsListener,
|
||||||
} from "./stats";
|
} from "./stats";
|
||||||
import type { UsageStats, ToolTokenStats } from "./stats";
|
import type { UsageStats, ToolTokenStats, BudgetStatus } from "./stats";
|
||||||
|
|
||||||
// Helper function to create ToolTokenStats for tests
|
// Helper function to create ToolTokenStats for tests
|
||||||
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
|
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
|
||||||
@@ -30,6 +37,12 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./costTracking", () => ({
|
||||||
|
costTrackingStore: {
|
||||||
|
refresh: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe("stats store", () => {
|
describe("stats store", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset stats to default before each test
|
// Reset stats to default before each test
|
||||||
@@ -600,4 +613,282 @@ describe("stats store", () => {
|
|||||||
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
|
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("checkBudget", () => {
|
||||||
|
const baseStats: UsageStats = {
|
||||||
|
total_input_tokens: 0,
|
||||||
|
total_output_tokens: 0,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
session_input_tokens: 500,
|
||||||
|
session_output_tokens: 500,
|
||||||
|
session_cost_usd: 0.5,
|
||||||
|
model: null,
|
||||||
|
messages_exchanged: 0,
|
||||||
|
session_messages_exchanged: 0,
|
||||||
|
code_blocks_generated: 0,
|
||||||
|
session_code_blocks_generated: 0,
|
||||||
|
files_edited: 0,
|
||||||
|
session_files_edited: 0,
|
||||||
|
files_created: 0,
|
||||||
|
session_files_created: 0,
|
||||||
|
tools_usage: {},
|
||||||
|
session_tools_usage: {},
|
||||||
|
session_duration_seconds: 0,
|
||||||
|
context_tokens_used: 0,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 0,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns ok when budget is disabled", () => {
|
||||||
|
const result = checkBudget(baseStats, false, 100, 1.0, 0.8);
|
||||||
|
expect(result).toEqual({ type: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok when under all budgets", () => {
|
||||||
|
const result = checkBudget(baseStats, true, 10000, 10.0, 0.8);
|
||||||
|
expect(result).toEqual({ type: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns exceeded when token budget is reached", () => {
|
||||||
|
const result = checkBudget(baseStats, true, 1000, null, 0.8);
|
||||||
|
expect(result).toEqual({ type: "exceeded", budget_type: "token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning when token usage is above threshold", () => {
|
||||||
|
// session tokens = 1000, budget = 1100, threshold = 0.8 → 1000/1100 ≈ 0.909 > 0.8
|
||||||
|
const result = checkBudget(baseStats, true, 1100, null, 0.8);
|
||||||
|
expect(result.type).toBe("warning");
|
||||||
|
if (result.type === "warning") {
|
||||||
|
expect(result.budget_type).toBe("token");
|
||||||
|
expect(result.percent_used).toBeGreaterThan(80);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns exceeded when cost budget is reached", () => {
|
||||||
|
const result = checkBudget(baseStats, true, null, 0.5, 0.8);
|
||||||
|
expect(result).toEqual({ type: "exceeded", budget_type: "cost" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning when cost usage is above threshold", () => {
|
||||||
|
// session cost = 0.5, budget = 0.55, threshold = 0.8 → 0.5/0.55 ≈ 0.909 > 0.8
|
||||||
|
const result = checkBudget(baseStats, true, null, 0.55, 0.8);
|
||||||
|
expect(result.type).toBe("warning");
|
||||||
|
if (result.type === "warning") {
|
||||||
|
expect(result.budget_type).toBe("cost");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks token budget before cost budget", () => {
|
||||||
|
// Both budgets exceeded, but token should be reported first
|
||||||
|
const result = checkBudget(baseStats, true, 500, 0.1, 0.8);
|
||||||
|
expect(result).toEqual({ type: "exceeded", budget_type: "token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok when no budgets are set", () => {
|
||||||
|
const result = checkBudget(baseStats, true, null, null, 0.8);
|
||||||
|
expect(result).toEqual({ type: "ok" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBudgetStatusMessage", () => {
|
||||||
|
it("returns null for ok status", () => {
|
||||||
|
const status: BudgetStatus = { type: "ok" };
|
||||||
|
expect(getBudgetStatusMessage(status)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns exceeded message for token budget", () => {
|
||||||
|
const status: BudgetStatus = { type: "exceeded", budget_type: "token" };
|
||||||
|
const message = getBudgetStatusMessage(status);
|
||||||
|
expect(message).toContain("token");
|
||||||
|
expect(message).toContain("exceeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns exceeded message for cost budget", () => {
|
||||||
|
const status: BudgetStatus = { type: "exceeded", budget_type: "cost" };
|
||||||
|
const message = getBudgetStatusMessage(status);
|
||||||
|
expect(message).toContain("cost");
|
||||||
|
expect(message).toContain("exceeded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning message with percentage for token budget", () => {
|
||||||
|
const status: BudgetStatus = { type: "warning", budget_type: "token", percent_used: 85.5 };
|
||||||
|
const message = getBudgetStatusMessage(status);
|
||||||
|
expect(message).toContain("token");
|
||||||
|
expect(message).toContain("86%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warning message with percentage for cost budget", () => {
|
||||||
|
const status: BudgetStatus = { type: "warning", budget_type: "cost", percent_used: 90 };
|
||||||
|
const message = getBudgetStatusMessage(status);
|
||||||
|
expect(message).toContain("cost");
|
||||||
|
expect(message).toContain("90%");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRemainingTokenBudget", () => {
|
||||||
|
const baseStats: UsageStats = {
|
||||||
|
total_input_tokens: 0,
|
||||||
|
total_output_tokens: 0,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
session_input_tokens: 300,
|
||||||
|
session_output_tokens: 200,
|
||||||
|
session_cost_usd: 0,
|
||||||
|
model: null,
|
||||||
|
messages_exchanged: 0,
|
||||||
|
session_messages_exchanged: 0,
|
||||||
|
code_blocks_generated: 0,
|
||||||
|
session_code_blocks_generated: 0,
|
||||||
|
files_edited: 0,
|
||||||
|
session_files_edited: 0,
|
||||||
|
files_created: 0,
|
||||||
|
session_files_created: 0,
|
||||||
|
tools_usage: {},
|
||||||
|
session_tools_usage: {},
|
||||||
|
session_duration_seconds: 0,
|
||||||
|
context_tokens_used: 0,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 0,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns null when no token budget is set", () => {
|
||||||
|
expect(getRemainingTokenBudget(baseStats, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the remaining tokens when under budget", () => {
|
||||||
|
// session tokens = 500, budget = 1000 → remaining = 500
|
||||||
|
expect(getRemainingTokenBudget(baseStats, 1000)).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when at or over budget", () => {
|
||||||
|
expect(getRemainingTokenBudget(baseStats, 500)).toBe(0);
|
||||||
|
expect(getRemainingTokenBudget(baseStats, 400)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRemainingCostBudget", () => {
|
||||||
|
const baseStats: UsageStats = {
|
||||||
|
total_input_tokens: 0,
|
||||||
|
total_output_tokens: 0,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
session_input_tokens: 0,
|
||||||
|
session_output_tokens: 0,
|
||||||
|
session_cost_usd: 0.3,
|
||||||
|
model: null,
|
||||||
|
messages_exchanged: 0,
|
||||||
|
session_messages_exchanged: 0,
|
||||||
|
code_blocks_generated: 0,
|
||||||
|
session_code_blocks_generated: 0,
|
||||||
|
files_edited: 0,
|
||||||
|
session_files_edited: 0,
|
||||||
|
files_created: 0,
|
||||||
|
session_files_created: 0,
|
||||||
|
tools_usage: {},
|
||||||
|
session_tools_usage: {},
|
||||||
|
session_duration_seconds: 0,
|
||||||
|
context_tokens_used: 0,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 0,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns null when no cost budget is set", () => {
|
||||||
|
expect(getRemainingCostBudget(baseStats, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the remaining cost when under budget", () => {
|
||||||
|
// session cost = 0.3, budget = 1.0 → remaining = 0.7
|
||||||
|
expect(getRemainingCostBudget(baseStats, 1.0)).toBeCloseTo(0.7, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when at or over budget", () => {
|
||||||
|
expect(getRemainingCostBudget(baseStats, 0.3)).toBe(0);
|
||||||
|
expect(getRemainingCostBudget(baseStats, 0.2)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initStatsListener", () => {
|
||||||
|
const emptyStats: UsageStats = {
|
||||||
|
total_input_tokens: 0,
|
||||||
|
total_output_tokens: 0,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
session_input_tokens: 0,
|
||||||
|
session_output_tokens: 0,
|
||||||
|
session_cost_usd: 0,
|
||||||
|
model: null,
|
||||||
|
messages_exchanged: 0,
|
||||||
|
session_messages_exchanged: 0,
|
||||||
|
code_blocks_generated: 0,
|
||||||
|
session_code_blocks_generated: 0,
|
||||||
|
files_edited: 0,
|
||||||
|
session_files_edited: 0,
|
||||||
|
files_created: 0,
|
||||||
|
session_files_created: 0,
|
||||||
|
tools_usage: {},
|
||||||
|
session_tools_usage: {},
|
||||||
|
session_duration_seconds: 0,
|
||||||
|
context_tokens_used: 0,
|
||||||
|
context_window_limit: 200000,
|
||||||
|
context_utilisation_percent: 0,
|
||||||
|
potential_cache_hits: 0,
|
||||||
|
potential_cache_savings_tokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers a listener for the claude:stats event", async () => {
|
||||||
|
vi.mocked(listen).mockResolvedValue(vi.fn());
|
||||||
|
vi.mocked(invoke).mockResolvedValue(emptyStats);
|
||||||
|
|
||||||
|
await initStatsListener();
|
||||||
|
|
||||||
|
expect(listen).toHaveBeenCalledWith("claude:stats", expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the stats store when the listener callback fires", async () => {
|
||||||
|
let capturedCallback: ((event: unknown) => void) | undefined;
|
||||||
|
vi.mocked(listen).mockImplementation(async (_event, callback) => {
|
||||||
|
capturedCallback = callback as (event: unknown) => void;
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
|
vi.mocked(invoke).mockResolvedValue(emptyStats);
|
||||||
|
|
||||||
|
await initStatsListener();
|
||||||
|
|
||||||
|
const newStats = { ...emptyStats, total_input_tokens: 9999 };
|
||||||
|
capturedCallback!({ payload: { stats: newStats } });
|
||||||
|
|
||||||
|
expect(get(stats).total_input_tokens).toBe(9999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads persisted stats from the backend on initialisation", async () => {
|
||||||
|
const persistedStats = { ...emptyStats, total_input_tokens: 5000 };
|
||||||
|
vi.mocked(listen).mockResolvedValue(vi.fn());
|
||||||
|
vi.mocked(invoke).mockResolvedValue(persistedStats);
|
||||||
|
|
||||||
|
await initStatsListener();
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("get_persisted_stats");
|
||||||
|
expect(get(stats).total_input_tokens).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a failed invoke gracefully and logs the error", async () => {
|
||||||
|
const error = new Error("Failed to get stats");
|
||||||
|
vi.mocked(listen).mockResolvedValue(vi.fn());
|
||||||
|
vi.mocked(invoke).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await initStatsListener();
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load initial stats:", error);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type BudgetType = "token" | "cost";
|
|||||||
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
||||||
// Current generation (Claude 4.6)
|
// Current generation (Claude 4.6)
|
||||||
"claude-opus-4-6": { input: 5.0, output: 25.0 },
|
"claude-opus-4-6": { input: 5.0, output: 25.0 },
|
||||||
|
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
|
||||||
// Previous generation (Claude 4.5)
|
// Previous generation (Claude 4.5)
|
||||||
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
|
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
|
||||||
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
|
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { emitMockEvent } from "../../../vitest.setup";
|
||||||
|
import { todos, initializeTodoListener, cleanupTodoListener } from "./todos";
|
||||||
|
import type { TodoItem } from "./todos";
|
||||||
|
|
||||||
|
describe("todos store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanupTodoListener();
|
||||||
|
todos.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("starts with an empty list", () => {
|
||||||
|
expect(get(todos)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set and clear", () => {
|
||||||
|
it("can set todos directly", () => {
|
||||||
|
const items: TodoItem[] = [
|
||||||
|
{ content: "Task 1", status: "pending", activeForm: "Doing task 1" },
|
||||||
|
{ content: "Task 2", status: "in_progress", activeForm: "Doing task 2" },
|
||||||
|
];
|
||||||
|
todos.set(items);
|
||||||
|
expect(get(todos)).toEqual(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear resets to empty array", () => {
|
||||||
|
todos.set([{ content: "Task", status: "completed", activeForm: "Doing task" }]);
|
||||||
|
todos.clear();
|
||||||
|
expect(get(todos)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
it("can update todos with a callback", () => {
|
||||||
|
const initial: TodoItem[] = [
|
||||||
|
{ content: "Task 1", status: "pending", activeForm: "Doing task 1" },
|
||||||
|
];
|
||||||
|
todos.set(initial);
|
||||||
|
todos.update((current) => [
|
||||||
|
...current,
|
||||||
|
{ content: "Task 2", status: "completed", activeForm: "Doing task 2" },
|
||||||
|
]);
|
||||||
|
expect(get(todos)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initializeTodoListener", () => {
|
||||||
|
it("listens for claude:todo-update events", async () => {
|
||||||
|
await initializeTodoListener();
|
||||||
|
|
||||||
|
const newTodos: TodoItem[] = [
|
||||||
|
{ content: "Backend task", status: "in_progress", activeForm: "Running backend task" },
|
||||||
|
];
|
||||||
|
emitMockEvent("claude:todo-update", { todos: newTodos });
|
||||||
|
|
||||||
|
expect(get(todos)).toEqual(newTodos);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not register a duplicate listener when called twice", async () => {
|
||||||
|
await initializeTodoListener();
|
||||||
|
await initializeTodoListener();
|
||||||
|
|
||||||
|
const newTodos: TodoItem[] = [
|
||||||
|
{ content: "Task", status: "pending", activeForm: "Doing task" },
|
||||||
|
];
|
||||||
|
emitMockEvent("claude:todo-update", { todos: newTodos });
|
||||||
|
|
||||||
|
// Should only have been set once (not doubled)
|
||||||
|
expect(get(todos)).toEqual(newTodos);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanupTodoListener", () => {
|
||||||
|
it("stops listening for events after cleanup", async () => {
|
||||||
|
await initializeTodoListener();
|
||||||
|
cleanupTodoListener();
|
||||||
|
|
||||||
|
emitMockEvent("claude:todo-update", {
|
||||||
|
todos: [{ content: "Task", status: "pending", activeForm: "Doing task" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not have been updated since we cleaned up
|
||||||
|
expect(get(todos)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is safe to call when not initialised", () => {
|
||||||
|
expect(() => cleanupTodoListener()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+142
-9
@@ -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;
|
||||||
@@ -29,6 +30,7 @@ interface StateChangePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connectedConversations = new Set<string>();
|
const connectedConversations = new Set<string>();
|
||||||
|
const greetingPendingConversations = new Set<string>();
|
||||||
let unlisteners: Array<() => void> = [];
|
let unlisteners: Array<() => void> = [];
|
||||||
let skipNextGreeting = false;
|
let skipNextGreeting = false;
|
||||||
|
|
||||||
@@ -55,17 +57,17 @@ function generateGreetingPrompt(): string {
|
|||||||
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
|
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendGreeting(conversationId: string) {
|
async function sendGreeting(conversationId: string): Promise<boolean> {
|
||||||
// Check if we should skip this greeting
|
// Check if we should skip this greeting
|
||||||
if (skipNextGreeting) {
|
if (skipNextGreeting) {
|
||||||
skipNextGreeting = false; // Reset the flag
|
skipNextGreeting = false; // Reset the flag
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = configStore.getConfig();
|
const config = configStore.getConfig();
|
||||||
|
|
||||||
if (!config.greeting_enabled) {
|
if (!config.greeting_enabled) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt();
|
const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt();
|
||||||
@@ -81,10 +83,12 @@ async function sendGreeting(conversationId: string) {
|
|||||||
conversationId,
|
conversationId,
|
||||||
message: greetingPrompt,
|
message: greetingPrompt,
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send greeting:", error);
|
console.error("Failed to send greeting:", error);
|
||||||
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
|
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
|
||||||
characterState.setTemporaryState("error", 3000);
|
characterState.setTemporaryState("error", 3000);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +122,7 @@ interface WorkingDirectoryPayload {
|
|||||||
|
|
||||||
export async function cleanupConversationTracking(conversationId: string) {
|
export async function cleanupConversationTracking(conversationId: string) {
|
||||||
connectedConversations.delete(conversationId);
|
connectedConversations.delete(conversationId);
|
||||||
|
greetingPendingConversations.delete(conversationId);
|
||||||
|
|
||||||
// Clean up any temp files associated with this conversation
|
// Clean up any temp files associated with this conversation
|
||||||
try {
|
try {
|
||||||
@@ -173,7 +178,24 @@ export async function initializeTauriListeners() {
|
|||||||
if (!connectedConversations.has(targetConversationId)) {
|
if (!connectedConversations.has(targetConversationId)) {
|
||||||
connectedConversations.add(targetConversationId);
|
connectedConversations.add(targetConversationId);
|
||||||
resetSessionStats(); // Reset session stats on new connection
|
resetSessionStats(); // Reset session stats on new connection
|
||||||
await sendGreeting(targetConversationId);
|
|
||||||
|
// Immediately hold the tab at yellow while we wait for the greeting response.
|
||||||
|
// This avoids a brief green flash before the greeting is even sent.
|
||||||
|
greetingPendingConversations.add(targetConversationId);
|
||||||
|
claudeStore.setConnectionStatusForConversation(
|
||||||
|
targetConversationId,
|
||||||
|
"connecting" as ConnectionStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
const greetingSent = await sendGreeting(targetConversationId);
|
||||||
|
if (!greetingSent) {
|
||||||
|
// Greeting was disabled or failed — flip straight to connected.
|
||||||
|
greetingPendingConversations.delete(targetConversationId);
|
||||||
|
claudeStore.setConnectionStatusForConversation(
|
||||||
|
targetConversationId,
|
||||||
|
"connected" as ConnectionStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (status === "disconnected") {
|
} else if (status === "disconnected") {
|
||||||
@@ -191,6 +213,7 @@ export async function initializeTauriListeners() {
|
|||||||
// Only remove from connected set if we're not about to reconnect
|
// Only remove from connected set if we're not about to reconnect
|
||||||
if (!skipNextGreeting && targetConversationId) {
|
if (!skipNextGreeting && targetConversationId) {
|
||||||
connectedConversations.delete(targetConversationId);
|
connectedConversations.delete(targetConversationId);
|
||||||
|
greetingPendingConversations.delete(targetConversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't add system message if we're about to reconnect
|
// Don't add system message if we're about to reconnect
|
||||||
@@ -198,13 +221,21 @@ 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)
|
||||||
todos.clear();
|
todos.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the tab's connection status on real disconnects
|
||||||
|
if (!skipNextGreeting && targetConversationId) {
|
||||||
|
claudeStore.setConnectionStatusForConversation(
|
||||||
|
targetConversationId,
|
||||||
|
"disconnected" as ConnectionStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Update character state for this conversation
|
// Update character state for this conversation
|
||||||
if (targetConversationId) {
|
if (targetConversationId) {
|
||||||
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
|
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
|
||||||
@@ -214,6 +245,7 @@ export async function initializeTauriListeners() {
|
|||||||
|
|
||||||
if (targetConversationId) {
|
if (targetConversationId) {
|
||||||
connectedConversations.delete(targetConversationId);
|
connectedConversations.delete(targetConversationId);
|
||||||
|
greetingPendingConversations.delete(targetConversationId);
|
||||||
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
|
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,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);
|
||||||
@@ -275,11 +368,34 @@ export async function initializeTauriListeners() {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Flip to connected when first assistant message arrives after greeting
|
||||||
|
if (
|
||||||
|
conversation_id &&
|
||||||
|
line_type === "assistant" &&
|
||||||
|
greetingPendingConversations.has(conversation_id)
|
||||||
|
) {
|
||||||
|
greetingPendingConversations.delete(conversation_id);
|
||||||
|
claudeStore.setConnectionStatusForConversation(
|
||||||
|
conversation_id,
|
||||||
|
"connected" as ConnectionStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Always store the output to the correct conversation
|
// Always store the output to the correct conversation
|
||||||
if (conversation_id) {
|
if (conversation_id) {
|
||||||
claudeStore.addLineToConversation(
|
claudeStore.addLineToConversation(
|
||||||
conversation_id,
|
conversation_id,
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
line_type as
|
||||||
|
| "user"
|
||||||
|
| "assistant"
|
||||||
|
| "system"
|
||||||
|
| "tool"
|
||||||
|
| "error"
|
||||||
|
| "thinking"
|
||||||
|
| "rate-limit"
|
||||||
|
| "compact-prompt"
|
||||||
|
| "worktree"
|
||||||
|
| "config-change",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData,
|
costData,
|
||||||
@@ -288,7 +404,17 @@ export async function initializeTauriListeners() {
|
|||||||
} else {
|
} else {
|
||||||
// Fallback to active conversation if no conversation_id provided
|
// Fallback to active conversation if no conversation_id provided
|
||||||
claudeStore.addLine(
|
claudeStore.addLine(
|
||||||
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking",
|
line_type as
|
||||||
|
| "user"
|
||||||
|
| "assistant"
|
||||||
|
| "system"
|
||||||
|
| "tool"
|
||||||
|
| "error"
|
||||||
|
| "thinking"
|
||||||
|
| "rate-limit"
|
||||||
|
| "compact-prompt"
|
||||||
|
| "worktree"
|
||||||
|
| "config-change",
|
||||||
content,
|
content,
|
||||||
tool_name || undefined,
|
tool_name || undefined,
|
||||||
costData,
|
costData,
|
||||||
@@ -410,10 +536,17 @@ export async function initializeTauriListeners() {
|
|||||||
unlisteners.push(agentUpdateUnlisten);
|
unlisteners.push(agentUpdateUnlisten);
|
||||||
|
|
||||||
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
|
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
|
||||||
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload;
|
const { tool_use_id, ended_at, is_error, conversation_id, last_assistant_message } =
|
||||||
|
event.payload;
|
||||||
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
|
||||||
if (targetConversationId) {
|
if (targetConversationId) {
|
||||||
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error);
|
agentStore.endAgent(
|
||||||
|
targetConversationId,
|
||||||
|
tool_use_id,
|
||||||
|
ended_at,
|
||||||
|
is_error,
|
||||||
|
last_assistant_message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
unlisteners.push(agentEndUnlisten);
|
unlisteners.push(agentEndUnlisten);
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface AgentInfo {
|
|||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
parentToolUseId?: string;
|
parentToolUseId?: string;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
characterName: string;
|
||||||
|
characterAvatar: string;
|
||||||
|
lastAssistantMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentStartPayload {
|
export interface AgentStartPayload {
|
||||||
@@ -29,4 +32,5 @@ export interface AgentEndPayload {
|
|||||||
conversation_id?: string;
|
conversation_id?: string;
|
||||||
duration_ms?: number;
|
duration_ms?: number;
|
||||||
num_turns?: number;
|
num_turns?: number;
|
||||||
|
last_assistant_message?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* MessageMode Type Tests
|
||||||
|
*
|
||||||
|
* Tests the helper functions exported from messageMode.ts:
|
||||||
|
* - getMessageMode: looks up a MessageMode by id
|
||||||
|
* - formatMessageWithMode: prepends a mode prefix to a message
|
||||||
|
*
|
||||||
|
* What this module does:
|
||||||
|
* - Defines the six conversation modes (chat, architect, code, debug, ask, review)
|
||||||
|
* - Provides a lookup function to find a mode by its id
|
||||||
|
* - Provides a formatting function that prepends the mode prefix to messages
|
||||||
|
*
|
||||||
|
* Manual testing checklist:
|
||||||
|
* - [ ] Mode selector shows correct names and icons for all six modes
|
||||||
|
* - [ ] Selecting a mode prefixes the next message with the correct prefix
|
||||||
|
* - [ ] Switching back to Chat sends messages without any prefix
|
||||||
|
* - [ ] Sending a message that already has the prefix does not double-prefix it
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { MESSAGE_MODES, getMessageMode, formatMessageWithMode } from "./messageMode";
|
||||||
|
|
||||||
|
describe("MESSAGE_MODES", () => {
|
||||||
|
it("contains all six expected modes", () => {
|
||||||
|
const ids = MESSAGE_MODES.map((m) => m.id);
|
||||||
|
expect(ids).toEqual(["chat", "architect", "code", "debug", "ask", "review"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each mode has an id, name, description, and icon", () => {
|
||||||
|
for (const mode of MESSAGE_MODES) {
|
||||||
|
expect(mode.id).toBeTruthy();
|
||||||
|
expect(mode.name).toBeTruthy();
|
||||||
|
expect(mode.description).toBeTruthy();
|
||||||
|
expect(mode.icon).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMessageMode", () => {
|
||||||
|
it("returns the chat mode with no prefix", () => {
|
||||||
|
const mode = getMessageMode("chat");
|
||||||
|
expect(mode).toBeDefined();
|
||||||
|
expect(mode?.id).toBe("chat");
|
||||||
|
expect(mode?.prefix).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the architect mode with its prefix", () => {
|
||||||
|
const mode = getMessageMode("architect");
|
||||||
|
expect(mode?.id).toBe("architect");
|
||||||
|
expect(mode?.prefix).toBe("[Architect Mode] ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the code mode with its prefix", () => {
|
||||||
|
const mode = getMessageMode("code");
|
||||||
|
expect(mode?.id).toBe("code");
|
||||||
|
expect(mode?.prefix).toBe("[Code Mode] ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the debug mode with its prefix", () => {
|
||||||
|
const mode = getMessageMode("debug");
|
||||||
|
expect(mode?.id).toBe("debug");
|
||||||
|
expect(mode?.prefix).toBe("[Debug Mode] ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the ask mode with its prefix", () => {
|
||||||
|
const mode = getMessageMode("ask");
|
||||||
|
expect(mode?.id).toBe("ask");
|
||||||
|
expect(mode?.prefix).toBe("[Ask Mode] ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the review mode with its prefix", () => {
|
||||||
|
const mode = getMessageMode("review");
|
||||||
|
expect(mode?.id).toBe("review");
|
||||||
|
expect(mode?.prefix).toBe("[Review Mode] ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an unknown mode id", () => {
|
||||||
|
expect(getMessageMode("unknown")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an empty string", () => {
|
||||||
|
expect(getMessageMode("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatMessageWithMode", () => {
|
||||||
|
it("prepends the prefix for code mode", () => {
|
||||||
|
expect(formatMessageWithMode("hello", "code")).toBe("[Code Mode] hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepends the prefix for architect mode", () => {
|
||||||
|
expect(formatMessageWithMode("design this", "architect")).toBe("[Architect Mode] design this");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepends the prefix for debug mode", () => {
|
||||||
|
expect(formatMessageWithMode("fix this bug", "debug")).toBe("[Debug Mode] fix this bug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepends the prefix for ask mode", () => {
|
||||||
|
expect(formatMessageWithMode("what is this?", "ask")).toBe("[Ask Mode] what is this?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepends the prefix for review mode", () => {
|
||||||
|
expect(formatMessageWithMode("review my code", "review")).toBe("[Review Mode] review my code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the message unchanged in chat mode (no prefix)", () => {
|
||||||
|
expect(formatMessageWithMode("hello", "chat")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the message unchanged for unknown mode ids", () => {
|
||||||
|
expect(formatMessageWithMode("hello", "unknown")).toBe("hello");
|
||||||
|
expect(formatMessageWithMode("hello", "")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-prefix a message already starting with the mode prefix", () => {
|
||||||
|
expect(formatMessageWithMode("[Code Mode] already prefixed", "code")).toBe(
|
||||||
|
"[Code Mode] already prefixed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the prefix to an empty string", () => {
|
||||||
|
expect(formatMessageWithMode("", "code")).toBe("[Code Mode] ");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
export interface TerminalLine {
|
export interface TerminalLine {
|
||||||
id: string;
|
id: string;
|
||||||
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking";
|
type:
|
||||||
|
| "user"
|
||||||
|
| "assistant"
|
||||||
|
| "system"
|
||||||
|
| "tool"
|
||||||
|
| "error"
|
||||||
|
| "thinking"
|
||||||
|
| "rate-limit"
|
||||||
|
| "compact-prompt"
|
||||||
|
| "worktree"
|
||||||
|
| "config-change";
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
@@ -164,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;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CHARACTER_POOL, assignCharacter } from "./agentCharacters";
|
||||||
|
|
||||||
|
describe("agentCharacters", () => {
|
||||||
|
describe("CHARACTER_POOL", () => {
|
||||||
|
it("contains exactly 6 characters", () => {
|
||||||
|
expect(CHARACTER_POOL).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each character has a name, avatar, title, and description", () => {
|
||||||
|
for (const character of CHARACTER_POOL) {
|
||||||
|
expect(character.name).toBeTruthy();
|
||||||
|
expect(character.avatar).toBeTruthy();
|
||||||
|
expect(character.avatar).toMatch(/^https:\/\//u);
|
||||||
|
expect(character.title).toBeTruthy();
|
||||||
|
expect(character.description).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all names are unique", () => {
|
||||||
|
const names = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
const uniqueNames = new Set(names);
|
||||||
|
expect(uniqueNames.size).toBe(CHARACTER_POOL.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assignCharacter", () => {
|
||||||
|
it("returns a character from the pool", () => {
|
||||||
|
const character = assignCharacter([]);
|
||||||
|
const names = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
expect(names).toContain(character.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids names already in use when possible", () => {
|
||||||
|
const takenNames = ["Amari", "Keiko", "Minori", "Reina", "Tatsumi"];
|
||||||
|
// Run many times to confirm we never get a taken name
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const character = assignCharacter(takenNames);
|
||||||
|
expect(takenNames).not.toContain(character.name);
|
||||||
|
expect(character.name).toBe("Yumiko");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks from the full pool when all 6 names are taken", () => {
|
||||||
|
const allNames = CHARACTER_POOL.map((c) => c.name);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
// Run enough times that we'd statistically see variety
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const character = assignCharacter(allNames);
|
||||||
|
seen.add(character.name);
|
||||||
|
}
|
||||||
|
// Should still pick valid characters
|
||||||
|
for (const name of seen) {
|
||||||
|
expect(allNames).toContain(name);
|
||||||
|
}
|
||||||
|
// With 100 runs and 6 characters, we should see at least 2 distinct names
|
||||||
|
expect(seen.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a character with name, avatar, title, and description", () => {
|
||||||
|
const character = assignCharacter([]);
|
||||||
|
expect(character.name).toBeTruthy();
|
||||||
|
expect(character.avatar).toBeTruthy();
|
||||||
|
expect(character.title).toBeTruthy();
|
||||||
|
expect(character.description).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works when the active list is empty", () => {
|
||||||
|
const character = assignCharacter([]);
|
||||||
|
expect(character).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export interface AgentCharacter {
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHARACTER_POOL: readonly AgentCharacter[] = [
|
||||||
|
{
|
||||||
|
name: "Amari",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/amari.png",
|
||||||
|
title: "Executive Assistant",
|
||||||
|
description:
|
||||||
|
"Fey-blooded PA and healer of the team. She always knows when you need a break — and makes sure you take one.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Keiko",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/keiko.png",
|
||||||
|
title: "Chief Security Officer",
|
||||||
|
description:
|
||||||
|
"Bodyguard and shadow of the family. Conceals blades beneath evening gowns; always watching from the dark.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Minori",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/minori.png",
|
||||||
|
title: "Chief Compliance Officer",
|
||||||
|
description:
|
||||||
|
"An ancient Automaton built to guard the Great Library. Perfect memory, perfect logic, perfect dedication.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Reina",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/reina.png",
|
||||||
|
title: "Chief Legal Officer",
|
||||||
|
description:
|
||||||
|
"Demon of the Crossroads turned corporate lawyer. Her binding contracts have held for millennia.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tatsumi",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/tatsumi.png",
|
||||||
|
title: "Chief Design Officer",
|
||||||
|
description:
|
||||||
|
"A Siren who traded the ocean for a stylus. Uses her glamour to make every interface welcoming and beautiful.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Yumiko",
|
||||||
|
avatar: "https://cdn.nhcarrigan.com/yumiko.png",
|
||||||
|
title: "Chief Technology Officer",
|
||||||
|
description:
|
||||||
|
"Technomancer and machine whisperer. She communes with machine spirits and keeps the digital world running.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a character for a new subagent.
|
||||||
|
* Avoids names already assigned to active agents unless all six are taken.
|
||||||
|
*/
|
||||||
|
export function assignCharacter(activeNames: readonly string[]): AgentCharacter {
|
||||||
|
const available = CHARACTER_POOL.filter((c) => !activeNames.includes(c.name));
|
||||||
|
const pool = available.length > 0 ? available : [...CHARACTER_POOL];
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
@@ -266,6 +266,30 @@ describe("stateMapper", () => {
|
|||||||
expect(mapMessageToState(message)).toBeNull();
|
expect(mapMessageToState(message)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns null for content_block_start with unrecognised content block type", () => {
|
||||||
|
const message = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: {
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "input_json_delta" },
|
||||||
|
},
|
||||||
|
} as unknown as ClaudeStreamMessage;
|
||||||
|
expect(mapMessageToState(message)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for content_block_delta with unrecognised delta type", () => {
|
||||||
|
const message = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: {
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "input_json_delta", partial_json: "{}" },
|
||||||
|
},
|
||||||
|
} as unknown as ClaudeStreamMessage;
|
||||||
|
expect(mapMessageToState(message)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null for result with unknown subtype", () => {
|
it("returns null for result with unknown subtype", () => {
|
||||||
const message = {
|
const message = {
|
||||||
type: "result",
|
type: "result",
|
||||||
|
|||||||
+73
-7
@@ -11,7 +11,15 @@
|
|||||||
updateDiscordRpc,
|
updateDiscordRpc,
|
||||||
setSkipNextGreeting,
|
setSkipNextGreeting,
|
||||||
} from "$lib/tauri";
|
} from "$lib/tauri";
|
||||||
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
import {
|
||||||
|
configStore,
|
||||||
|
applyTheme,
|
||||||
|
applyFontSize,
|
||||||
|
applyCustomFont,
|
||||||
|
applyCustomUiFont,
|
||||||
|
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 +45,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 +344,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);
|
||||||
}
|
}
|
||||||
@@ -414,6 +461,8 @@
|
|||||||
const config = configStore.getConfig();
|
const config = configStore.getConfig();
|
||||||
applyTheme(config.theme, config.custom_theme_colors);
|
applyTheme(config.theme, config.custom_theme_colors);
|
||||||
applyFontSize(config.font_size);
|
applyFontSize(config.font_size);
|
||||||
|
await applyCustomFont(config.custom_font_path, config.custom_font_family);
|
||||||
|
await applyCustomUiFont(config.custom_ui_font_path, config.custom_ui_font_family);
|
||||||
|
|
||||||
// Apply always-on-top setting
|
// Apply always-on-top setting
|
||||||
if (config.always_on_top) {
|
if (config.always_on_top) {
|
||||||
@@ -473,16 +522,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 +551,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>
|
||||||
@@ -538,13 +602,15 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.app-container {
|
.app-container {
|
||||||
font-family:
|
font-family: var(
|
||||||
|
--ui-font-family,
|
||||||
"Inter",
|
"Inter",
|
||||||
-apple-system,
|
-apple-system,
|
||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
"Segoe UI",
|
"Segoe UI",
|
||||||
Roboto,
|
Roboto,
|
||||||
sans-serif;
|
sans-serif
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-panel {
|
.character-panel {
|
||||||
|
|||||||
@@ -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`
|
||||||
//
|
//
|
||||||
|
|||||||
+6
-1
@@ -17,7 +17,10 @@ vi.mock("@tauri-apps/api/core", () => ({
|
|||||||
if (command in mockInvokeResults) {
|
if (command in mockInvokeResults) {
|
||||||
const result = mockInvokeResults[command];
|
const result = mockInvokeResults[command];
|
||||||
if (result instanceof Error) {
|
if (result instanceof Error) {
|
||||||
return Promise.reject(result);
|
const err = result;
|
||||||
|
return Promise.resolve().then(() => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Promise.resolve(result);
|
return Promise.resolve(result);
|
||||||
}
|
}
|
||||||
@@ -73,6 +76,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