feat: multiple UI improvements, font settings, and memory file display names (#175)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
CI / Lint & Test (push) Has been cancelled
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled

## 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: #175
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #175.
This commit is contained in:
2026-03-03 20:21:58 -08:00
committed by Naomi Carrigan
parent 97b8243d24
commit fa906684c2
48 changed files with 7148 additions and 101 deletions
@@ -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);
});
});
});
+160
View File
@@ -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
</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 -->
@@ -917,6 +953,130 @@
</p>
</div>
<!-- Custom Terminal Font -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom Terminal Font</span>
<div class="flex flex-col gap-2">
<input
type="text"
bind:value={customFontPathInput}
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<input
type="text"
bind:value={customFontFamilyInput}
placeholder="Font family name (e.g. FiraCode)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<div class="flex gap-2">
<button
onclick={async () => {
customFontStatus = null;
try {
await configStore.setCustomFont(
customFontPathInput || null,
customFontFamilyInput || null
);
customFontStatus = "Font applied!";
} catch (e) {
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
Apply
</button>
<button
onclick={async () => {
customFontStatus = null;
customFontPathInput = "";
customFontFamilyInput = "";
try {
await configStore.setCustomFont(null, null);
await applyCustomFont(null, null);
customFontStatus = "Font reset to default.";
} catch (e) {
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
>
Reset
</button>
</div>
{#if customFontStatus}
<p class="text-xs text-[var(--text-tertiary)]">{customFontStatus}</p>
{/if}
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is
required to apply the font.
</p>
</div>
<!-- Custom UI Font -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom UI Font</span>
<div class="flex flex-col gap-2">
<input
type="text"
bind:value={customUiFontPathInput}
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<input
type="text"
bind:value={customUiFontFamilyInput}
placeholder="Font family name (e.g. Inter)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<div class="flex gap-2">
<button
onclick={async () => {
customUiFontStatus = null;
try {
await configStore.setCustomUiFont(
customUiFontPathInput || null,
customUiFontFamilyInput || null
);
customUiFontStatus = "Font applied!";
} catch (e) {
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
Apply UI Font
</button>
<button
onclick={async () => {
customUiFontStatus = null;
customUiFontPathInput = "";
customUiFontFamilyInput = "";
try {
await configStore.setCustomUiFont(null, null);
await applyCustomUiFont(null, null);
customUiFontStatus = "Font reset to default.";
} catch (e) {
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
>
Reset
</button>
</div>
{#if customUiFontStatus}
<p class="text-xs text-[var(--text-tertiary)]">{customUiFontStatus}</p>
{/if}
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs,
direct font file URLs, or local file paths.
</p>
</div>
<!-- Show Thinking Blocks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
+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 },
]);
});
});
});
+1 -1
View File
@@ -1011,7 +1011,7 @@ User: ${formattedMessage}`;
: "Connect to Claude first..."}
disabled={isSubmitting}
rows={1}
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
+32 -10
View File
@@ -3,17 +3,22 @@
import { invoke } from "@tauri-apps/api/core";
import Markdown from "./Markdown.svelte";
let memoryFiles: string[] = $state([]);
interface MemoryFileInfo {
path: string;
heading: string | null;
}
interface MemoryFilesResponse {
files: MemoryFileInfo[];
}
let memoryFiles: MemoryFileInfo[] = $state([]);
let selectedFile: string | null = $state(null);
let fileContent: string = $state("");
let isLoading = $state(false);
let error: string | null = $state(null);
let isPanelOpen = $state(false);
interface MemoryFilesResponse {
files: string[];
}
async function loadMemoryFiles() {
isLoading = true;
error = null;
@@ -49,6 +54,10 @@
return path.split("/").pop() || path;
}
function getDisplayName(file: MemoryFileInfo): string {
return file.heading ?? getFileName(file.path);
}
function togglePanel() {
isPanelOpen = !isPanelOpen;
if (isPanelOpen && memoryFiles.length === 0) {
@@ -151,11 +160,12 @@
{:else}
<div class="panel-layout">
<div class="file-list">
{#each memoryFiles as file (file)}
{#each memoryFiles as file (file.path)}
<button
class="file-item"
class:active={selectedFile === file}
onclick={() => loadFileContent(file)}
class:active={selectedFile === file.path}
onclick={() => loadFileContent(file.path)}
title={getFileName(file.path)}
>
<svg
class="file-icon"
@@ -171,7 +181,7 @@
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span class="file-name">{getFileName(file)}</span>
<span class="file-name">{getDisplayName(file)}</span>
</button>
{/each}
</div>
@@ -179,7 +189,12 @@
<div class="file-viewer">
{#if selectedFile && fileContent}
<div class="viewer-header">
<h4>{getFileName(selectedFile)}</h4>
{#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
<h4>{getDisplayName(activeFile)}</h4>
{#if activeFile.heading}
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
{/if}
{/each}
</div>
<div class="viewer-content">
<Markdown content={fileContent} />
@@ -438,6 +453,13 @@
color: var(--text-primary);
}
.viewer-filename {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--text-tertiary);
font-family: monospace;
}
.viewer-content {
flex: 1;
padding: 1.5rem;
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
// Mirror pure logic functions from MemoryBrowserPanel.svelte
interface MemoryFileInfo {
path: string;
heading: string | null;
}
function getFileName(path: string): string {
return path.split("/").pop() || path;
}
function getDisplayName(file: MemoryFileInfo): string {
return file.heading ?? getFileName(file.path);
}
describe("getFileName", () => {
it("extracts the filename from an absolute Unix path", () => {
expect(getFileName("/home/naomi/.claude/projects/foo/memory/MEMORY.md")).toBe("MEMORY.md");
});
it("extracts the filename from a nested path", () => {
expect(getFileName("/home/naomi/.claude/projects/foo/memory/debugging.md")).toBe(
"debugging.md"
);
});
it("returns the path itself when there is no slash", () => {
expect(getFileName("MEMORY.md")).toBe("MEMORY.md");
});
it("returns the path when the path ends with a slash (empty filename)", () => {
// split('/').pop() returns '' for trailing slash → falls back to full path
expect(getFileName("/some/dir/")).toBe("/some/dir/");
});
it("handles single-component paths", () => {
expect(getFileName("notes.md")).toBe("notes.md");
});
it("handles Windows-style paths passed as Unix strings", () => {
// If the path contains no forward slashes, the whole string is the filename
expect(getFileName("C:\\Users\\naomi\\memory\\file.md")).toBe(
"C:\\Users\\naomi\\memory\\file.md"
);
});
});
describe("getDisplayName", () => {
it("returns the heading when the file has one", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
heading: "Hikari Desktop - Memory",
};
expect(getDisplayName(file)).toBe("Hikari Desktop - Memory");
});
it("falls back to the filename when heading is null", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/debugging.md",
heading: null,
};
expect(getDisplayName(file)).toBe("debugging.md");
});
it("falls back to the filename when heading is an empty string stored as null", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/patterns.md",
heading: null,
};
expect(getDisplayName(file)).toBe("patterns.md");
});
it("returns the heading even when it matches the filename", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
heading: "MEMORY",
};
expect(getDisplayName(file)).toBe("MEMORY");
});
it("returns a multi-word heading verbatim", () => {
const file: MemoryFileInfo = {
path: "/some/path/foo.md",
heading: "My Detailed Debugging Notes",
};
expect(getDisplayName(file)).toBe("My Detailed Debugging Notes");
});
it("falls back gracefully when path has no directory separators", () => {
const file: MemoryFileInfo = {
path: "lonely-file.md",
heading: null,
};
expect(getDisplayName(file)).toBe("lonely-file.md");
});
});
+7
View File
@@ -107,9 +107,14 @@
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
});
let streamerModeActive = $state(false);
@@ -185,6 +190,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 +350,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");
});
});
+14 -60
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
@@ -246,7 +231,7 @@
bind:this={terminalElement}
onscroll={handleScroll}
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
style="font-size: var(--terminal-font-size, 14px);"
style="font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
>
{#if lines.length === 0}
<div class="terminal-waiting italic">
@@ -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>