generated from nhcarrigan/template
Compare commits
4 Commits
v1.0.0
..
8cb4c17dc1
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cb4c17dc1 | |||
| bee4eb393e | |||
| 78b951a865 | |||
| cd15247dea |
@@ -0,0 +1,74 @@
|
||||
# Hikari Desktop v0.3.0 Release Notes
|
||||
|
||||
## New Features
|
||||
|
||||
### AskUserQuestion Tool Support (#51)
|
||||
|
||||
- Claude can now ask you questions with multiple choice options during conversations
|
||||
- A dedicated modal appears with answer choices and support for custom responses
|
||||
- Answers are seamlessly integrated back into the conversation context
|
||||
|
||||
### Slash Commands
|
||||
|
||||
- `/cd <path>` - Change the working directory while preserving conversation context (#55)
|
||||
- `/search <query>` - Search and highlight matches within the conversation (#32)
|
||||
- `/skill <name> [data]` - Invoke Claude Code skills from `~/.claude/skills/` (#57)
|
||||
- `/new`, `/clear`, `/rename`, `/help` commands for conversation management (#6)
|
||||
|
||||
### Auto-Update Checker (#17)
|
||||
|
||||
- Automatically checks for new releases on startup
|
||||
- Notification appears when a newer version is available
|
||||
- Can be disabled in settings
|
||||
|
||||
### Font Size & Zoom (#19)
|
||||
|
||||
- Adjust font size with keyboard shortcuts: `Ctrl++`, `Ctrl+-`, `Ctrl+0`
|
||||
- Font size slider in settings (10-24px range)
|
||||
- Preference persists between sessions
|
||||
|
||||
### Resizable Character Panel (#10)
|
||||
|
||||
- Drag the divider between the character panel and terminal to resize
|
||||
- Panel width is saved and restored on app restart
|
||||
- Sprite now uses full height for better proportions
|
||||
|
||||
### Input History Navigation (#13)
|
||||
|
||||
- Use up/down arrows to navigate through previous messages and commands
|
||||
- Arrow keys only navigate history when input is empty - otherwise they move the cursor (#58)
|
||||
|
||||
### Keyboard Shortcuts (#21)
|
||||
|
||||
- `Ctrl+N` - New conversation
|
||||
- `Ctrl+W` - Close current tab
|
||||
- `Ctrl+Tab` / `Ctrl+Shift+Tab` - Switch between tabs
|
||||
- `Ctrl+L` - Clear conversation
|
||||
- `Ctrl+,` - Open settings
|
||||
|
||||
### Always On Top Toggle (#28)
|
||||
|
||||
- Pin the window to stay above other applications
|
||||
- Toggle in settings
|
||||
|
||||
## Improvements
|
||||
|
||||
### UI/UX
|
||||
|
||||
- Resizable chat input with drag handle (expands upward)
|
||||
- Send button properly aligned with input field
|
||||
- Markdown rendering with syntax-highlighted code blocks (#31, #33)
|
||||
- Light mode text colors improved for better readability
|
||||
- Scroll position persists per conversation tab when switching
|
||||
- Confirmation modal when closing connected tabs
|
||||
- Links in chat now open in default browser (#54)
|
||||
- Spaces allowed when renaming tabs (#52)
|
||||
|
||||
### State Management
|
||||
|
||||
- Stats (tokens, cost) persist across session changes and only reset on disconnect (#59)
|
||||
- Multiple tabs can now request permissions simultaneously without conflicts
|
||||
|
||||
## Closed Issues
|
||||
|
||||
#6, #10, #13, #17, #19, #21, #28, #31, #32, #33, #51, #52, #54, #55, #57, #58, #59
|
||||
+1
-1
@@ -27,6 +27,6 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/", "coverage/"],
|
||||
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/"],
|
||||
}
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "1.0.0",
|
||||
"version": "0.3.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "1.0.0"
|
||||
version = "0.3.0"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -408,15 +408,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_max_history_size_is_reasonable() {
|
||||
assert_eq!(MAX_HISTORY_SIZE, 100);
|
||||
// Compile-time assertions for constant bounds
|
||||
const _: () = assert!(MAX_HISTORY_SIZE > 0);
|
||||
const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
|
||||
assert!(MAX_HISTORY_SIZE > 0);
|
||||
assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
|
||||
}
|
||||
|
||||
// ==================== Pinned entry sorting tests ====================
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_pinned_entries_sorting() {
|
||||
let mut entries = vec![
|
||||
ClipboardEntry {
|
||||
@@ -466,7 +464,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_multiple_pinned_entries_sorting() {
|
||||
let mut entries = vec![
|
||||
ClipboardEntry {
|
||||
|
||||
@@ -611,7 +611,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_update_info_serialization() {
|
||||
let info = UpdateInfo {
|
||||
current_version: "1.0.0".to_string(),
|
||||
current_version: "0.3.0".to_string(),
|
||||
latest_version: "0.4.0".to_string(),
|
||||
has_update: true,
|
||||
release_url: "https://example.com/release".to_string(),
|
||||
@@ -619,7 +619,7 @@ mod tests {
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&info).unwrap();
|
||||
assert!(json.contains("1.0.0"));
|
||||
assert!(json.contains("0.3.0"));
|
||||
assert!(json.contains("0.4.0"));
|
||||
assert!(json.contains("true"));
|
||||
assert!(json.contains("New features!"));
|
||||
@@ -628,8 +628,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_update_info_without_notes() {
|
||||
let info = UpdateInfo {
|
||||
current_version: "1.0.0".to_string(),
|
||||
latest_version: "1.0.0".to_string(),
|
||||
current_version: "0.3.0".to_string(),
|
||||
latest_version: "0.3.0".to_string(),
|
||||
has_update: false,
|
||||
release_url: "https://example.com/release".to_string(),
|
||||
release_notes: None,
|
||||
|
||||
@@ -733,8 +733,8 @@ mod tests {
|
||||
|
||||
let result = git_log(working_dir, Some(10));
|
||||
// May fail on empty repo or return empty
|
||||
if let Ok(commits) = result {
|
||||
assert!(commits.is_empty());
|
||||
if result.is_ok() {
|
||||
assert!(result.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -247,7 +247,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_quick_action_sorting_defaults_first() {
|
||||
let mut actions = vec![
|
||||
create_test_action("custom-z", "Zebra", false),
|
||||
@@ -355,7 +354,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_action_find_by_id() {
|
||||
let actions = vec![
|
||||
create_test_action("action-1", "First", false),
|
||||
|
||||
@@ -321,7 +321,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_session_sorting_by_activity() {
|
||||
let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let new_time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
|
||||
|
||||
@@ -281,7 +281,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_snippet_sorting_by_category_then_name() {
|
||||
let mut snippets = vec![
|
||||
create_test_snippet("s1", "Zebra", "B-Category", false),
|
||||
@@ -389,7 +388,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_snippet_find_by_id() {
|
||||
let snippets = vec![
|
||||
create_test_snippet("snippet-1", "First", "Cat", false),
|
||||
@@ -406,7 +404,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::useless_vec)]
|
||||
fn test_extract_categories_sorted_and_deduped() {
|
||||
let snippets = vec![
|
||||
create_test_snippet("s1", "S1", "Zebra", false),
|
||||
|
||||
@@ -523,8 +523,9 @@ mod tests {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.session_start = Some(Instant::now());
|
||||
|
||||
// Verify duration is returned (u64 is always non-negative)
|
||||
let _duration = stats.get_session_duration();
|
||||
// The duration should be at least 0 seconds
|
||||
let duration = stats.get_session_duration();
|
||||
assert!(duration >= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "1.0.0",
|
||||
"version": "0.3.0",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NotificationType, NOTIFICATION_SOUNDS, type NotificationSound } from "./types";
|
||||
import {
|
||||
NotificationType,
|
||||
NOTIFICATION_SOUNDS,
|
||||
type NotificationSound,
|
||||
} from "./types";
|
||||
|
||||
// Mock HTMLAudioElement for soundPlayer tests
|
||||
class MockAudioElement {
|
||||
@@ -23,7 +27,7 @@ class MockAudioElement {
|
||||
}
|
||||
|
||||
// Store original Audio before mocking
|
||||
const OriginalAudio = globalThis.Audio;
|
||||
const OriginalAudio = global.Audio;
|
||||
|
||||
describe("notifications", () => {
|
||||
describe("NotificationType enum", () => {
|
||||
@@ -162,12 +166,12 @@ describe("notifications", () => {
|
||||
describe("SoundPlayer class", () => {
|
||||
beforeEach(() => {
|
||||
// Mock Audio constructor
|
||||
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
|
||||
global.Audio = MockAudioElement as unknown as typeof Audio;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original Audio
|
||||
globalThis.Audio = OriginalAudio;
|
||||
global.Audio = OriginalAudio;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -232,12 +236,12 @@ describe("notifications", () => {
|
||||
|
||||
describe("NotificationManager class", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.Audio = MockAudioElement as unknown as typeof Audio;
|
||||
global.Audio = MockAudioElement as unknown as typeof Audio;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.Audio = OriginalAudio;
|
||||
global.Audio = OriginalAudio;
|
||||
});
|
||||
|
||||
it("can import notificationManager singleton", async () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import {
|
||||
configStore,
|
||||
isDarkTheme,
|
||||
isStreamerMode,
|
||||
isCompactMode,
|
||||
shouldHidePaths,
|
||||
maskPaths,
|
||||
clampFontSize,
|
||||
applyFontSize,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
// Test the Conversation interface and store behavior
|
||||
describe("Conversation interface", () => {
|
||||
@@ -251,7 +252,7 @@ describe("conversation management operations", () => {
|
||||
});
|
||||
|
||||
it("uses default name when not provided", () => {
|
||||
const counter = 5;
|
||||
let counter = 5;
|
||||
const createNewConversation = (name?: string) => ({
|
||||
name: name || `Conversation ${counter}`,
|
||||
});
|
||||
@@ -270,7 +271,9 @@ describe("conversation history formatting", () => {
|
||||
{ type: "user" as const, content: "How are you?" },
|
||||
];
|
||||
|
||||
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
|
||||
const relevantLines = lines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
const history = relevantLines
|
||||
.map((line) => {
|
||||
@@ -287,7 +290,9 @@ describe("conversation history formatting", () => {
|
||||
|
||||
it("returns empty string for no messages", () => {
|
||||
const lines: Array<{ type: string; content: string }> = [];
|
||||
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
|
||||
const relevantLines = lines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
|
||||
expect(relevantLines.length).toBe(0);
|
||||
const history = relevantLines.length === 0 ? "" : "has content";
|
||||
@@ -328,7 +333,7 @@ describe("scroll position handling", () => {
|
||||
});
|
||||
|
||||
it("uses positive values for manual scroll position", () => {
|
||||
const scrollPosition: number = 500;
|
||||
const scrollPosition = 500;
|
||||
const isAutoScroll = scrollPosition === -1;
|
||||
|
||||
expect(isAutoScroll).toBe(false);
|
||||
@@ -447,7 +452,9 @@ describe("attachment management", () => {
|
||||
});
|
||||
|
||||
it("clears all attachments", () => {
|
||||
const cleared: Array<{ id: string }> = [];
|
||||
const attachments = [{ id: "att-1" }, { id: "att-2" }];
|
||||
|
||||
const cleared: typeof attachments = [];
|
||||
expect(cleared).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -461,7 +468,7 @@ describe("derived store behavior", () => {
|
||||
});
|
||||
|
||||
it("defaults to disconnected when no active conversation", () => {
|
||||
const activeConv = null as { connectionStatus?: string } | null;
|
||||
const activeConv = null;
|
||||
const derivedStatus = activeConv?.connectionStatus || "disconnected";
|
||||
|
||||
expect(derivedStatus).toBe("disconnected");
|
||||
@@ -480,7 +487,7 @@ describe("derived store behavior", () => {
|
||||
});
|
||||
|
||||
it("defaults to empty array when no active conversation", () => {
|
||||
const activeConv = null as { terminalLines?: Array<{ id: string; content: string }> } | null;
|
||||
const activeConv = null;
|
||||
const derivedLines = activeConv?.terminalLines || [];
|
||||
|
||||
expect(derivedLines).toEqual([]);
|
||||
|
||||
@@ -264,7 +264,8 @@ describe("quickActionsStore", () => {
|
||||
|
||||
describe("quick action ID generation", () => {
|
||||
it("generates unique custom action IDs", () => {
|
||||
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const generateId = () =>
|
||||
`custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const id1 = generateId();
|
||||
const id2 = generateId();
|
||||
|
||||
@@ -289,7 +289,8 @@ describe("snippetsStore", () => {
|
||||
|
||||
describe("snippet ID generation", () => {
|
||||
it("generates unique custom snippet IDs", () => {
|
||||
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const generateId = () =>
|
||||
`custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
const id1 = generateId();
|
||||
const id2 = generateId();
|
||||
@@ -334,7 +335,7 @@ git push`,
|
||||
|
||||
it("supports content with special characters", () => {
|
||||
const snippet = {
|
||||
content: "echo \"Hello, World!\" && echo 'Single quotes'",
|
||||
content: 'echo "Hello, World!" && echo \'Single quotes\'',
|
||||
};
|
||||
|
||||
expect(snippet.content).toContain('"');
|
||||
|
||||
@@ -319,6 +319,8 @@ describe("tauri event handling", () => {
|
||||
|
||||
describe("mock event system", () => {
|
||||
it("can emit events through mock system", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
// The emitMockEvent function should work
|
||||
expect(typeof emitMockEvent).toBe("function");
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { vi, beforeEach } from "vitest";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock Tauri invoke API
|
||||
const mockInvokeResults: Record<string, unknown> = {};
|
||||
@@ -13,7 +13,7 @@ export function clearMockInvokeResults() {
|
||||
}
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn((command: string) => {
|
||||
invoke: vi.fn((command: string, _args?: Record<string, unknown>) => {
|
||||
if (command in mockInvokeResults) {
|
||||
const result = mockInvokeResults[command];
|
||||
if (result instanceof Error) {
|
||||
|
||||
Reference in New Issue
Block a user