From fa906684c203406500ead3c69233fc8793c33900 Mon Sep 17 00:00:00 2001
From: Hikari
Date: Tue, 3 Mar 2026 20:21:58 -0800
Subject: [PATCH] feat: multiple UI improvements, font settings, and memory
file display names (#175)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- **fix**: `show_thinking_blocks` setting now persists across sessions — it was defined on the TypeScript side but missing from the Rust `HikariConfig` struct, so serde silently dropped it on every save/load
- **feat**: Tool calls are now rendered as collapsible blocks matching the Extended Thinking block aesthetic, replacing the old inline dropdown approach
- **feat**: Add configurable max output tokens setting
- **feat**: Use random creative names for conversation tabs
- **test**: Significantly expanded frontend unit test coverage
- **docs**: Require tests for all changes in CLAUDE.md
- **feat**: Allow users to specify a custom terminal font (Closes #176)
- **feat**: Display friendly names for memory files derived from the first heading (Closes #177)
- **feat**: Add custom UI font support for the app chrome (buttons, labels, tabs)
- **fix**: Apply custom UI font to the full app interface — `.app-container` was hardcoded, blocking inheritance from `body`; also renamed "Custom Font" to "Custom Terminal Font" for clarity
✨ This PR was created with help from Hikari~ 🌸
Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/175
Co-authored-by: Hikari
Co-committed-by: Hikari
---
CLAUDE.md | 19 +-
src-tauri/src/commands.rs | 138 +++-
src-tauri/src/config.rs | 40 ++
src-tauri/src/wsl_bridge.rs | 10 +
src/app.css | 6 +-
src/lib/commands/slashCommands.test.ts | 450 +++++++++++-
src/lib/commands/slashCommands.ts | 2 +
.../AchievementNotification.test.ts | 153 ++++
src/lib/components/CliVersion.test.ts | 134 ++++
src/lib/components/ConfigSidebar.svelte | 160 +++++
src/lib/components/ConversationTabs.test.ts | 111 +++
src/lib/components/HighlightedText.test.ts | 195 +++++
src/lib/components/InputBar.svelte | 2 +-
src/lib/components/MemoryBrowserPanel.svelte | 42 +-
src/lib/components/MemoryBrowserPanel.test.ts | 98 +++
src/lib/components/StatusBar.svelte | 7 +
src/lib/components/StatusBar.test.ts | 89 +++
src/lib/components/Terminal.svelte | 74 +-
src/lib/components/ToolCallBlock.svelte | 141 ++++
.../notifications/notificationManager.test.ts | 242 +++++++
src/lib/notifications/notifications.test.ts | 47 ++
src/lib/notifications/rules.test.ts | 150 ++++
.../notifications/terminalNotifier.test.ts | 64 ++
.../notifications/testNotifications.test.ts | 58 ++
.../wslNotificationHelper.test.ts | 40 ++
src/lib/sounds/achievement.test.ts | 48 ++
src/lib/stores/achievements.test.ts | 185 +++++
src/lib/stores/character.test.ts | 124 ++++
src/lib/stores/claude.test.ts | 139 ++++
src/lib/stores/clipboard.test.ts | 675 ++++++++++++++++++
src/lib/stores/config.test.ts | 633 +++++++++++++++-
src/lib/stores/config.ts | 117 +++
src/lib/stores/conversations.ts | 86 ++-
src/lib/stores/costTracking.test.ts | 292 ++++++++
src/lib/stores/debugConsole.test.ts | 282 ++++++++
src/lib/stores/drafts.test.ts | 10 +
src/lib/stores/editor.test.ts | 639 +++++++++++++++++
src/lib/stores/historyRestore.test.ts | 72 ++
src/lib/stores/messageMode.test.ts | 56 ++
src/lib/stores/notifications.test.ts | 104 +++
src/lib/stores/search.test.ts | 187 +++++
src/lib/stores/sessions.test.ts | 569 +++++++++++++++
src/lib/stores/stats.test.ts | 295 +++++++-
src/lib/stores/todos.test.ts | 93 +++
src/lib/types/messageMode.test.ts | 125 ++++
src/lib/utils/stateMapper.test.ts | 24 +
src/routes/+page.svelte | 17 +-
vitest.setup.ts | 5 +-
48 files changed, 7148 insertions(+), 101 deletions(-)
create mode 100644 src/lib/components/AchievementNotification.test.ts
create mode 100644 src/lib/components/CliVersion.test.ts
create mode 100644 src/lib/components/ConversationTabs.test.ts
create mode 100644 src/lib/components/HighlightedText.test.ts
create mode 100644 src/lib/components/MemoryBrowserPanel.test.ts
create mode 100644 src/lib/components/StatusBar.test.ts
create mode 100644 src/lib/components/ToolCallBlock.svelte
create mode 100644 src/lib/notifications/notificationManager.test.ts
create mode 100644 src/lib/notifications/rules.test.ts
create mode 100644 src/lib/notifications/terminalNotifier.test.ts
create mode 100644 src/lib/notifications/testNotifications.test.ts
create mode 100644 src/lib/notifications/wslNotificationHelper.test.ts
create mode 100644 src/lib/sounds/achievement.test.ts
create mode 100644 src/lib/stores/achievements.test.ts
create mode 100644 src/lib/stores/character.test.ts
create mode 100644 src/lib/stores/claude.test.ts
create mode 100644 src/lib/stores/clipboard.test.ts
create mode 100644 src/lib/stores/costTracking.test.ts
create mode 100644 src/lib/stores/debugConsole.test.ts
create mode 100644 src/lib/stores/editor.test.ts
create mode 100644 src/lib/stores/historyRestore.test.ts
create mode 100644 src/lib/stores/messageMode.test.ts
create mode 100644 src/lib/stores/notifications.test.ts
create mode 100644 src/lib/stores/search.test.ts
create mode 100644 src/lib/stores/sessions.test.ts
create mode 100644 src/lib/stores/todos.test.ts
create mode 100644 src/lib/types/messageMode.test.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index c6459de..c56c41b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,19 +20,26 @@ When working with issues, pull requests, or other repository operations for this
When asked to commit changes for this project:
- **Always commit as Hikari** using: `--author="Hikari "`
-- **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
- **Always ask for confirmation** before committing
+- **Always ask for confirmation** before pushing
Example commit command:
```bash
-git commit --author="Hikari " --no-gpg-sign -m "your commit message"
+git commit --author="Hikari " --gpg-sign=5380E4EE7307C808 -m "your commit message"
+```
+
+Example push command:
+
+```bash
+git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git
```
## 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
- **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)
2. **During implementation**: Write tests alongside the code
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
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
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index efc58c3..acddc96 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -862,6 +862,26 @@ pub async fn read_file_content(path: String) -> Result {
.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 {
+ 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)
#[allow(dead_code)]
async fn read_file_via_wsl(path: &str) -> Result {
@@ -1353,9 +1373,29 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
Ok(())
}
+#[derive(serde::Serialize)]
+pub struct MemoryFileInfo {
+ pub path: String,
+ pub heading: Option,
+}
+
#[derive(serde::Serialize)]
pub struct MemoryFilesResponse {
- pub files: Vec,
+ pub files: Vec,
+}
+
+/// Extract the first `# Heading` from a string of file content.
+fn extract_first_heading(content: &str) -> Option {
+ 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]
@@ -1398,12 +1438,19 @@ async fn list_memory_files_via_wsl() -> Result {
}
let stdout = String::from_utf8_lossy(&output.stdout);
- let files: Vec = stdout
+ let paths: Vec = stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.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 })
}
@@ -1425,10 +1472,13 @@ async fn list_memory_files_native() -> Result {
return Ok(MemoryFilesResponse { files: Vec::new() });
}
- let mut memory_files = Vec::new();
+ let mut memory_paths = Vec::new();
// Recursively find all memory directories
- fn find_memory_files(dir: &std::path::Path, files: &mut Vec) -> std::io::Result<()> {
+ fn find_memory_files(
+ dir: &std::path::Path,
+ files: &mut Vec,
+ ) -> std::io::Result<()> {
if !dir.is_dir() {
return Ok(());
}
@@ -1461,16 +1511,25 @@ async fn list_memory_files_native() -> Result {
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));
}
// Sort files alphabetically
- memory_files.sort();
+ memory_paths.sort();
- Ok(MemoryFilesResponse {
- files: memory_files,
- })
+ // Read first heading from each file
+ 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]
@@ -2902,4 +2961,65 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
assert_eq!(servers[0].name, "asana");
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())
+ );
+ }
}
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index b9af21a..cfb4f7f 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -31,6 +31,9 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub disable_1m_context: bool,
+
+ #[serde(default)]
+ pub max_output_tokens: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -126,6 +129,9 @@ pub struct HikariConfig {
#[serde(default)]
pub disable_1m_context: bool,
+ #[serde(default)]
+ pub max_output_tokens: Option,
+
#[serde(default)]
pub trusted_workspaces: Vec,
@@ -135,6 +141,23 @@ pub struct HikariConfig {
#[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,
+
+ #[serde(default)]
+ pub custom_font_family: Option,
+
+ // Custom UI font settings
+ #[serde(default)]
+ pub custom_ui_font_path: Option,
+
+ #[serde(default)]
+ pub custom_ui_font_family: Option,
}
impl Default for HikariConfig {
@@ -169,9 +192,15 @@ impl Default for HikariConfig {
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,
}
}
}
@@ -286,6 +315,11 @@ mod tests {
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]
@@ -320,9 +354,15 @@ mod tests {
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();
diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs
index 5df9852..c2da718 100644
--- a/src-tauri/src/wsl_bridge.rs
+++ b/src-tauri/src/wsl_bridge.rs
@@ -296,6 +296,11 @@ impl WslBridge {
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
} else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
@@ -343,6 +348,11 @@ impl WslBridge {
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 --output-format stream-json --input-format stream-json --verbose",
);
diff --git a/src/app.css b/src/app.css
index 13b8324..88bf299 100644
--- a/src/app.css
+++ b/src/app.css
@@ -154,11 +154,7 @@ body {
padding: 0;
height: 100%;
overflow: hidden;
- font-family:
- "Segoe UI",
- system-ui,
- -apple-system,
- sans-serif;
+ font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
background: var(--bg-primary);
color: var(--text-primary);
}
diff --git a/src/lib/commands/slashCommands.test.ts b/src/lib/commands/slashCommands.test.ts
index 74c7de5..af08a7b 100644
--- a/src/lib/commands/slashCommands.test.ts
+++ b/src/lib/commands/slashCommands.test.ts
@@ -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 {
slashCommands,
parseSlashCommand,
@@ -40,6 +45,28 @@ vi.mock("$lib/stores/character", () => ({
vi.mock("$lib/tauri", () => ({
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", () => ({
@@ -415,4 +442,425 @@ describe("slashCommands", () => {
expect(result).toBeUndefined();
});
});
+
+ describe("command execute functions", () => {
+ let getMock: ReturnType;
+ let invokeMock: ReturnType;
+
+ 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(),
+ };
+ 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(["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)
+ );
+ });
+ });
+ });
});
diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts
index 8d252c8..3e97bb2 100644
--- a/src/lib/commands/slashCommands.ts
+++ b/src/lib/commands/slashCommands.ts
@@ -63,6 +63,7 @@ async function changeDirectory(path: string): Promise {
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,
},
});
@@ -139,6 +140,7 @@ async function startNewConversation(): Promise {
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,
},
});
diff --git a/src/lib/components/AchievementNotification.test.ts b/src/lib/components/AchievementNotification.test.ts
new file mode 100644
index 0000000..ed971f2
--- /dev/null
+++ b/src/lib/components/AchievementNotification.test.ts
@@ -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");
+ });
+ });
+});
diff --git a/src/lib/components/CliVersion.test.ts b/src/lib/components/CliVersion.test.ts
new file mode 100644
index 0000000..a16516f
--- /dev/null
+++ b/src/lib/components/CliVersion.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte
index dd26102..1328fbb 100644
--- a/src/lib/components/ConfigSidebar.svelte
+++ b/src/lib/components/ConfigSidebar.svelte
@@ -5,6 +5,8 @@
type Theme,
type CustomThemeColors,
applyFontSize,
+ applyCustomFont,
+ applyCustomUiFont,
applyCustomThemeColors,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
@@ -56,12 +58,23 @@
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 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;
@@ -87,6 +100,10 @@
configStore.config.subscribe((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) => {
@@ -533,6 +550,25 @@
context window
+
+
+
+
+
+
+ Sets CLAUDE_CODE_MAX_OUTPUT_TOKENS — increase if responses are
+ being cut off mid-reply
+
+
@@ -917,6 +953,130 @@
+
+
+
Custom Terminal Font
+
+
+
+
+
+
+
+ {#if customFontStatus}
+
{customFontStatus}
+ {/if}
+
+
+ Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is
+ required to apply the font.
+
+
+
+
+
+
Custom UI Font
+
+
+
+
+
+
+
+ {#if customUiFontStatus}
+
{customUiFontStatus}
+ {/if}
+
+
+ Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs,
+ direct font file URLs, or local file paths.
+
+
+