generated from nhcarrigan/template
feat: multiple UI improvements, font settings, and memory file display names (#175)
## 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:
@@ -1,4 +1,9 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { searchState } from "$lib/stores/search";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import {
|
||||
slashCommands,
|
||||
parseSlashCommand,
|
||||
@@ -40,6 +45,28 @@ vi.mock("$lib/stores/character", () => ({
|
||||
|
||||
vi.mock("$lib/tauri", () => ({
|
||||
setSkipNextGreeting: vi.fn(),
|
||||
updateDiscordRpc: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/conversations", () => ({
|
||||
conversationsStore: {
|
||||
activeConversation: { subscribe: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/config", () => ({
|
||||
configStore: {
|
||||
getConfig: vi.fn().mockReturnValue({
|
||||
auto_granted_tools: [],
|
||||
model: "claude-sonnet",
|
||||
api_key: null,
|
||||
custom_instructions: null,
|
||||
mcp_servers_json: null,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/search", () => ({
|
||||
@@ -415,4 +442,425 @@ describe("slashCommands", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("command execute functions", () => {
|
||||
let getMock: ReturnType<typeof vi.fn>;
|
||||
let invokeMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getMock = vi.mocked(get);
|
||||
invokeMock = vi.mocked(invoke);
|
||||
});
|
||||
|
||||
describe("/clear execute", () => {
|
||||
it("clears terminal and shows confirmation message", () => {
|
||||
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear")!;
|
||||
clearCmd.execute("");
|
||||
expect(claudeStore.clearTerminal).toHaveBeenCalledWith();
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Terminal cleared");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/help execute", () => {
|
||||
it("shows available commands header", () => {
|
||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||
helpCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
expect.stringContaining("Available commands:")
|
||||
);
|
||||
});
|
||||
|
||||
it("includes all command usages in help text", () => {
|
||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||
helpCmd.execute("");
|
||||
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
||||
const helpText = callArgs[1] as string;
|
||||
expect(helpText).toContain("/cd");
|
||||
expect(helpText).toContain("/clear");
|
||||
expect(helpText).toContain("/help");
|
||||
expect(helpText).toContain("/search");
|
||||
expect(helpText).toContain("/new");
|
||||
expect(helpText).toContain("/summarise");
|
||||
expect(helpText).toContain("/skill");
|
||||
});
|
||||
|
||||
it("includes command descriptions in help text", () => {
|
||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||
helpCmd.execute("");
|
||||
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
||||
const helpText = callArgs[1] as string;
|
||||
expect(helpText).toContain("Change the working directory");
|
||||
expect(helpText).toContain("Show available slash commands");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/search execute", () => {
|
||||
it("clears search when called with empty args", () => {
|
||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||
searchCmd.execute("");
|
||||
expect(searchState.clear).toHaveBeenCalledWith();
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
||||
});
|
||||
|
||||
it("clears search when called with whitespace-only args", () => {
|
||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||
searchCmd.execute(" ");
|
||||
expect(searchState.clear).toHaveBeenCalledWith();
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
||||
});
|
||||
|
||||
it("sets query when called with a search term", () => {
|
||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||
searchCmd.execute("hello world");
|
||||
expect(searchState.setQuery).toHaveBeenCalledWith("hello world");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello world"');
|
||||
});
|
||||
|
||||
it("trims whitespace from query before setting", () => {
|
||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||
searchCmd.execute(" hello ");
|
||||
expect(searchState.setQuery).toHaveBeenCalledWith("hello");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("/cd execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
await cdCmd.execute("/some/path");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("shows current directory when called with empty args", async () => {
|
||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
await cdCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Current directory: /home/naomi/code"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows current directory when called with whitespace-only args", async () => {
|
||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
await cdCmd.execute(" ");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Current directory: /home/naomi/code"
|
||||
);
|
||||
});
|
||||
|
||||
it("validates path and changes directory on success", async () => {
|
||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(null);
|
||||
invokeMock.mockResolvedValue("/validated/path");
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
await cdCmd.execute("/new/path");
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"validate_directory",
|
||||
expect.objectContaining({ path: "/new/path" })
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error when directory change fails", async () => {
|
||||
getMock.mockReturnValueOnce("conv-123");
|
||||
invokeMock.mockRejectedValueOnce(new Error("invalid path"));
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
await cdCmd.execute("/bad/path");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Failed to change directory:")
|
||||
);
|
||||
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/new execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||
await newCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("shows error when starting new conversation fails", async () => {
|
||||
getMock.mockReturnValueOnce("conv-123");
|
||||
invokeMock.mockRejectedValueOnce(new Error("invoke failed"));
|
||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||
await newCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Failed to start new conversation:")
|
||||
);
|
||||
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/summarise execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||
await summariseCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("sends a summary prompt when there is an active conversation", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||
await summariseCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Requesting conversation summary..."
|
||||
);
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"send_prompt",
|
||||
expect.objectContaining({ conversationId: "conv-123" })
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error when send_prompt invoke fails", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockRejectedValue(new Error("network error"));
|
||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||
await summariseCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Failed to request summary:")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/skill execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("onboard-mentee");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("lists available skills when called with no name", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(["onboard-mentee", "other-skill"]);
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("");
|
||||
expect(invokeMock).toHaveBeenCalledWith("list_skills");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
expect.stringContaining("onboard-mentee")
|
||||
);
|
||||
});
|
||||
|
||||
it("shows empty message when no skills are found", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue([]);
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
expect.stringContaining("No skills found")
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes skill when called with a name and no data", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("onboard-mentee");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Invoking skill: onboard-mentee"
|
||||
);
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"send_prompt",
|
||||
expect.objectContaining({ conversationId: "conv-123" })
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes skill with additional data in the prompt", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("onboard-mentee some extra data");
|
||||
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: expect.stringContaining("some extra data"),
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when listing skills fails", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockRejectedValue(new Error("list error"));
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Failed to list skills:")
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error and resets character state when invoking skill fails", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockRejectedValue(new Error("invoke error"));
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("onboard-mentee");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Failed to invoke skill:")
|
||||
);
|
||||
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/cd success path", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("changes directory and shows success message", async () => {
|
||||
getMock
|
||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
||||
invokeMock
|
||||
.mockResolvedValueOnce("/new/path") // validate_directory
|
||||
.mockResolvedValueOnce(undefined) // stop_claude
|
||||
.mockResolvedValueOnce(undefined); // start_claude
|
||||
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
const promise = cdCmd.execute("/new/path");
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Changed directory to: /new/path"
|
||||
);
|
||||
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
||||
});
|
||||
|
||||
it("sends context restoration message when conversation history exists", async () => {
|
||||
getMock
|
||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue(
|
||||
"previous conversation history"
|
||||
);
|
||||
invokeMock
|
||||
.mockResolvedValueOnce("/new/path") // validate_directory
|
||||
.mockResolvedValueOnce(undefined) // stop_claude
|
||||
.mockResolvedValueOnce(undefined) // start_claude
|
||||
.mockResolvedValueOnce(undefined); // send_prompt
|
||||
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
const promise = cdCmd.execute("/new/path");
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"send_prompt",
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("previous conversation history"),
|
||||
})
|
||||
);
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Changed directory to: /new/path"
|
||||
);
|
||||
});
|
||||
|
||||
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
||||
const activeConv = {
|
||||
name: "Test Conversation",
|
||||
model: "claude-sonnet",
|
||||
startedAt: new Date("2026-03-03T12:00:00Z"),
|
||||
grantedTools: new Set<string>(),
|
||||
};
|
||||
getMock
|
||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||
.mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation)
|
||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
||||
invokeMock
|
||||
.mockResolvedValueOnce("/new/path")
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const { updateDiscordRpc } = await import("$lib/tauri");
|
||||
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
const promise = cdCmd.execute("/new/path");
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
||||
"Test Conversation",
|
||||
expect.any(String),
|
||||
expect.any(Date)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/new success path", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts a new conversation and shows success message", async () => {
|
||||
getMock
|
||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||
invokeMock
|
||||
.mockResolvedValueOnce("/working/dir") // get_working_directory
|
||||
.mockResolvedValueOnce(undefined) // interrupt_claude
|
||||
.mockResolvedValueOnce(undefined); // start_claude
|
||||
|
||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||
const promise = newCmd.execute("");
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!");
|
||||
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
||||
});
|
||||
|
||||
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
||||
const activeConv = {
|
||||
name: "My Conv",
|
||||
model: "claude-sonnet",
|
||||
startedAt: new Date("2026-03-03T12:00:00Z"),
|
||||
grantedTools: new Set<string>(["tool1"]),
|
||||
};
|
||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv);
|
||||
invokeMock
|
||||
.mockResolvedValueOnce("/working/dir")
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const { updateDiscordRpc } = await import("$lib/tauri");
|
||||
|
||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||
const promise = newCmd.execute("");
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
||||
"My Conv",
|
||||
expect.any(String),
|
||||
expect.any(Date)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user