Compare commits

...

7 Commits

Author SHA1 Message Date
hikari 7e45c685d3 test: add clipboard and notification rules tests
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m34s
CI / Lint & Test (pull_request) Successful in 19m30s
CI / Build Linux (pull_request) Successful in 20m6s
CI / Build Windows (cross-compile) (pull_request) Successful in 30m44s
Adds 55 tests for clipboard store pure functions (detectLanguage for 12
languages + formatTimestamp with fake timers) and 13 tests for the
notification rules state machine (reconnect transition logic and no-op
handlers). Total test count now 698 across 30 files.
2026-03-03 12:52:08 -08:00
hikari 514e137590 test: add component logic tests and clean up eslint directives
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 56s
CI / Lint & Test (pull_request) Successful in 16m12s
CI / Build Linux (pull_request) Successful in 20m14s
CI / Build Windows (cross-compile) (pull_request) Successful in 32m19s
Adds mirror-function tests for five Svelte components (HighlightedText,
CliVersion, AchievementNotification, StatusBar, ConversationTabs) and
removes stale eslint-disable comments from existing store test files.
2026-03-03 12:21:47 -08:00
hikari fd3122e080 test: expand frontend unit test coverage to 30%
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 57s
CI / Lint & Test (pull_request) Failing after 5m49s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped
2026-03-03 12:05:31 -08:00
hikari 66c65a6ab8 feat: use random creative names for conversation tabs
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 58s
CI / Lint & Test (pull_request) Successful in 16m15s
CI / Build Linux (pull_request) Successful in 20m3s
CI / Build Windows (cross-compile) (pull_request) Successful in 29m56s
2026-03-03 10:56:44 -08:00
hikari 19e28b7ec7 feat: add configurable max output tokens setting
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m1s
CI / Lint & Test (pull_request) Successful in 16m15s
CI / Build Linux (pull_request) Successful in 20m15s
CI / Build Windows (cross-compile) (pull_request) Successful in 30m13s
2026-03-03 09:47:15 -08:00
hikari 08f7ca2d55 feat: restyle tool calls as collapsible blocks matching thinking block aesthetic
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 57s
CI / Lint & Test (pull_request) Successful in 16m25s
CI / Build Linux (pull_request) Successful in 21m32s
CI / Build Windows (cross-compile) (pull_request) Successful in 31m29s
2026-03-02 15:08:19 -08:00
hikari c5d1df351c fix: persist show_thinking_blocks setting across sessions 2026-03-02 13:36:23 -08:00
27 changed files with 2476 additions and 64 deletions
+9 -2
View File
@@ -20,14 +20,21 @@ 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 <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
- **Always ask for confirmation** before committing
- **Always ask for confirmation** before pushing
Example commit command:
```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
+14
View File
@@ -31,6 +31,9 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub disable_1m_context: bool,
#[serde(default)]
pub max_output_tokens: Option<u64>,
}
#[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<u64>,
#[serde(default)]
pub trusted_workspaces: Vec<String>,
@@ -135,6 +141,9 @@ pub struct HikariConfig {
#[serde(default = "default_background_image_opacity")]
pub background_image_opacity: f32,
#[serde(default)]
pub show_thinking_blocks: bool,
}
impl Default for HikariConfig {
@@ -169,9 +178,11 @@ 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,
}
}
}
@@ -286,6 +297,7 @@ mod tests {
assert!(!config.use_worktree);
assert!(!config.disable_1m_context);
assert!(config.trusted_workspaces.is_empty());
assert!(!config.show_thinking_blocks);
}
#[test]
@@ -320,9 +332,11 @@ 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,
};
let json = serde_json::to_string(&config).unwrap();
+10
View File
@@ -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",
);
+2
View File
@@ -63,6 +63,7 @@ async function changeDirectory(path: string): Promise<void> {
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<void> {
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");
});
});
});
+134
View File
@@ -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);
});
});
});
+20
View File
@@ -56,6 +56,7 @@
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,
@@ -533,6 +534,25 @@
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>
<!-- Greeting Section -->
+111
View File
@@ -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);
});
});
+195
View File
@@ -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 },
]);
});
});
});
+3
View File
@@ -107,6 +107,7 @@
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,
@@ -185,6 +186,7 @@
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,
},
});
@@ -344,6 +346,7 @@
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,
},
});
+89
View File
@@ -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");
});
});
+13 -59
View File
@@ -7,6 +7,7 @@
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
import ThinkingBlock from "./ThinkingBlock.svelte";
import ToolCallBlock from "./ToolCallBlock.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
import { clipboardStore } from "$lib/stores/clipboard";
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
@@ -208,22 +209,6 @@
if (!currentConversationId) return;
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
}
// Collapsible tool lines
const TOOL_COLLAPSE_THRESHOLD = 60;
let expandedToolLines: Record<string, boolean> = {};
function isToolContentLong(content: string): boolean {
return content.length > TOOL_COLLAPSE_THRESHOLD;
}
function truncateToolContent(content: string): string {
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
}
function toggleToolLine(id: string) {
expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] };
}
</script>
<div
@@ -258,6 +243,18 @@
{#if showThinking}
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
{/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}
<div
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
@@ -296,9 +293,6 @@
{#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if}
{#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "compact-prompt"}
<button class="compact-action-btn" onclick={handleCompact}>
⚡ Compact Conversation
@@ -330,22 +324,6 @@
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button>
</div>
{:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))}
<span class="tool-collapsible">
<HighlightedText
content={expandedToolLines[line.id]
? maskPaths(line.content, hidePaths)
: truncateToolContent(maskPaths(line.content, hidePaths))}
searchQuery={currentSearchQuery}
/>
<button
class="tool-toggle-btn"
onclick={() => toggleToolLine(line.id)}
title={expandedToolLines[line.id] ? "Collapse" : "Expand to see full content"}
>
{expandedToolLines[line.id] ? "▲" : "▼"}
</button>
</span>
{:else}
<HighlightedText
content={maskPaths(line.content, hidePaths)}
@@ -501,28 +479,4 @@
.terminal-line {
position: relative;
}
.tool-collapsible {
display: inline-flex;
align-items: baseline;
gap: 0.4em;
}
.tool-toggle-btn {
background: none;
border: none;
color: var(--text-tertiary, #6b7280);
cursor: pointer;
font-size: 0.7em;
padding: 0;
line-height: 1;
opacity: 0.7;
transition: opacity 0.15s ease;
font-family: inherit;
}
.tool-toggle-btn:hover {
opacity: 1;
color: var(--terminal-tool, #c084fc);
}
</style>
+141
View File
@@ -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>
+150
View File
@@ -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();
});
});
+48
View File
@@ -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();
});
});
});
+124
View File
@@ -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);
});
});
+359
View File
@@ -0,0 +1,359 @@
/**
* Clipboard Store Tests
*
* Tests the pure helper functions from the clipboard store:
* - detectLanguage: identifies programming language from code content
* - formatTimestamp: converts an ISO timestamp to a relative time string
*
* What this store does:
* - Maintains a history of clipboard entries (code snippets)
* - Auto-detects the language of captured content
* - Supports filtering by language and search query
* - Pinned entries persist across clear operations
*
* Manual testing checklist:
* - [ ] Clipboard entries appear when code is copied during a session
* - [ ] Language is detected and labelled correctly
* - [ ] Pinned entries survive "Clear history"
* - [ ] Language filter dropdown shows only languages present in history
* - [ ] Search query filters by content, language, and source
*/
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mirror: detectLanguage from clipboard.ts
function detectLanguage(content: string): string | null {
const patterns: [RegExp, string][] = [
[/^(import|export|const|let|var|function|class|interface|type)\s/m, "typescript"],
[/^(def|class|import|from|if __name__|async def)\s/m, "python"],
[/^(fn|let|mut|impl|struct|enum|use|mod|pub)\s/m, "rust"],
[/^(package|import|func|type|var|const)\s/m, "go"],
[/<\?php/m, "php"],
[/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/im, "sql"],
[/^<!DOCTYPE|^<html|^<div|^<span/im, "html"],
[/^\s*\{[\s\S]*"[\w-]+":/m, "json"],
[/^---\s*\n/m, "yaml"],
[/^\s*#\s*(include|define|ifdef)/m, "c"],
[/^(public|private|protected)\s+(class|interface|static)/m, "java"],
[/^\$[\w_]+\s*=/m, "bash"],
];
for (const [pattern, lang] of patterns) {
if (pattern.test(content)) {
return lang;
}
}
return null;
}
// Mirror: formatTimestamp from clipboard.ts
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
// ---
describe("detectLanguage", () => {
describe("TypeScript detection", () => {
it("detects import statements", () => {
expect(detectLanguage("import React from 'react';")).toBe("typescript");
});
it("detects export statements", () => {
expect(detectLanguage("export function foo() {}")).toBe("typescript");
});
it("detects const declarations", () => {
expect(detectLanguage("const x = 1;")).toBe("typescript");
});
it("detects interface declarations", () => {
expect(detectLanguage("interface Foo {\n bar: string;\n}")).toBe("typescript");
});
it("detects type aliases", () => {
expect(detectLanguage("type MyType = string | number;")).toBe("typescript");
});
});
describe("Python detection", () => {
it("detects def statements", () => {
expect(detectLanguage("def foo():\n pass")).toBe("python");
});
it("detects async def statements", () => {
expect(detectLanguage("async def bar():\n pass")).toBe("python");
});
it("detects from imports", () => {
expect(detectLanguage("from os import path")).toBe("python");
});
it("detects the __name__ guard", () => {
expect(detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
});
});
describe("Rust detection", () => {
it("detects fn declarations", () => {
expect(detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
});
it("detects impl blocks", () => {
expect(detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe("rust");
});
it("detects struct declarations", () => {
expect(detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
});
it("detects enum declarations", () => {
expect(detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
});
it("detects mod declarations", () => {
expect(detectLanguage("mod utils;")).toBe("rust");
});
it("detects pub visibility", () => {
expect(detectLanguage("pub fn exported() {}")).toBe("rust");
});
});
describe("Go detection", () => {
it("detects package declarations", () => {
expect(detectLanguage("package main")).toBe("go");
});
it("detects func declarations", () => {
expect(detectLanguage("func main() {}")).toBe("go");
});
});
describe("PHP detection", () => {
it("detects the PHP open tag", () => {
expect(detectLanguage("<?php\necho 'hello';")).toBe("php");
});
});
describe("SQL detection", () => {
it("detects SELECT statements", () => {
expect(detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
});
it("detects INSERT statements", () => {
expect(detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe("sql");
});
it("detects CREATE statements", () => {
expect(detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
});
it("detects SQL case-insensitively", () => {
expect(detectLanguage("select * from users")).toBe("sql");
});
});
describe("HTML detection", () => {
it("detects DOCTYPE declarations", () => {
expect(detectLanguage("<!DOCTYPE html>")).toBe("html");
});
it("detects html tags", () => {
expect(detectLanguage("<html><body></body></html>")).toBe("html");
});
it("detects div tags", () => {
expect(detectLanguage("<div class='foo'>bar</div>")).toBe("html");
});
it("detects span tags", () => {
expect(detectLanguage("<span>text</span>")).toBe("html");
});
});
describe("JSON detection", () => {
it("detects JSON object syntax", () => {
expect(detectLanguage('{"name": "test", "value": 42}')).toBe("json");
});
it("detects JSON with hyphenated keys", () => {
expect(detectLanguage('{"my-key": "value"}')).toBe("json");
});
});
describe("YAML detection", () => {
it("detects YAML document separator", () => {
expect(detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
});
});
describe("C detection", () => {
it("detects #include directives", () => {
expect(detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
});
it("detects #define directives", () => {
expect(detectLanguage("#define MAX 100")).toBe("c");
});
it("detects #ifdef directives", () => {
expect(detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
});
});
describe("Java detection", () => {
it("detects public class declarations", () => {
expect(detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
});
it("detects private static methods", () => {
expect(detectLanguage("private static void helper() {}")).toBe("java");
});
it("detects protected interface declarations", () => {
expect(detectLanguage("protected interface Bar {}")).toBe("java");
});
});
describe("Bash detection", () => {
it("detects shell variable assignments", () => {
expect(detectLanguage("$HOME=/usr/local")).toBe("bash");
});
it("detects variable assignments with underscores", () => {
expect(detectLanguage("$MY_VAR=some_value")).toBe("bash");
});
});
describe("unknown content", () => {
it("returns null for plain text", () => {
expect(detectLanguage("Hello, world!")).toBeNull();
});
it("returns null for empty string", () => {
expect(detectLanguage("")).toBeNull();
});
it("returns null for mathematical expressions", () => {
expect(detectLanguage("1 + 1 = 2")).toBeNull();
});
it("returns null for a markdown heading", () => {
expect(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(formatTimestamp(ts)).toBe("Just now");
});
it("returns 'Just now' for the current moment", () => {
const ts = NOW.toISOString();
expect(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(formatTimestamp(ts)).toBe("Just now");
});
});
describe("'Xm ago' threshold (159 minutes)", () => {
it("returns '1m ago' at exactly 1 minute", () => {
const ts = new Date("2026-03-03T11:59:00.000Z").toISOString();
expect(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(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(formatTimestamp(ts)).toBe("59m ago");
});
});
describe("'Xh ago' threshold (123 hours)", () => {
it("returns '1h ago' at exactly 1 hour", () => {
const ts = new Date("2026-03-03T11:00:00.000Z").toISOString();
expect(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(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(formatTimestamp(ts)).toBe("23h ago");
});
});
describe("'Xd ago' threshold (16 days)", () => {
it("returns '1d ago' at exactly 1 day", () => {
const ts = new Date("2026-03-02T12:00:00.000Z").toISOString();
expect(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(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(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 = 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 = formatTimestamp(ts);
expect(result).not.toContain("ago");
});
});
});
+3
View File
@@ -196,6 +196,7 @@ describe("config store", () => {
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,
@@ -247,6 +248,7 @@ describe("config store", () => {
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,
@@ -797,6 +799,7 @@ describe("config store", () => {
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,
+3
View File
@@ -51,6 +51,8 @@ export interface HikariConfig {
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
@@ -98,6 +100,7 @@ const defaultConfig: HikariConfig = {
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,
+84 -2
View File
@@ -43,6 +43,88 @@ export interface Conversation {
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() {
const conversations = writable<Map<string, Conversation>>(new Map());
const activeConversationId = writable<string | null>(null);
@@ -63,7 +145,7 @@ function createConversationsStore() {
const id = generateConversationId();
return {
id,
name: name || `Conversation ${conversationCounter}`,
name: name ?? pickRandomTabName(),
terminalLines: [],
sessionId: null,
connectionStatus: "disconnected",
@@ -91,7 +173,7 @@ function createConversationsStore() {
function ensureInitialized() {
if (!initialized) {
initialized = true;
const initialConversation = createNewConversation("Main");
const initialConversation = createNewConversation();
conversations.update((convs) => {
convs.set(initialConversation.id, initialConversation);
return convs;
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import {
formatCost,
formatAlertType,
getAlertMessage,
type AlertType,
type CostAlert,
} from "./costTracking";
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");
});
});
+72
View File
@@ -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();
});
});
});
+56
View File
@@ -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");
});
});
+104
View File
@@ -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);
});
});
});
+187
View File
@@ -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("");
});
});
+202 -1
View File
@@ -9,8 +9,12 @@ import {
estimateMessageCost,
formatTokenCount,
MODEL_PRICING,
checkBudget,
getBudgetStatusMessage,
getRemainingTokenBudget,
getRemainingCostBudget,
} from "./stats";
import type { UsageStats, ToolTokenStats } from "./stats";
import type { UsageStats, ToolTokenStats, BudgetStatus } from "./stats";
// Helper function to create ToolTokenStats for tests
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
@@ -600,4 +604,201 @@ describe("stats store", () => {
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);
});
});
});
+93
View File
@@ -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();
});
});
});