Compare commits

..

1 Commits

Author SHA1 Message Date
hikari bef7f9229b fix: render bullets and numbers for markdown lists 2026-02-26 19:04:56 -08:00
14 changed files with 95 additions and 262 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.8.1",
"version": "1.8.0",
"description": "",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1648,7 +1648,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hikari-desktop"
version = "1.8.1"
version = "1.8.0"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.8.1"
version = "1.8.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
+2 -48
View File
@@ -111,9 +111,6 @@ pub struct WslBridge {
conversation_id: Option<String>,
/// Set to true once the `system:init` message arrives, false at the start of every new session.
received_init: Arc<AtomicBool>,
/// Set to true by stop()/interrupt() before killing the process so handle_stdout knows
/// the disconnect was intentional and should not emit a second Disconnected event.
intentional_stop: Arc<AtomicBool>,
}
impl WslBridge {
@@ -127,7 +124,6 @@ impl WslBridge {
stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: None,
received_init: Arc::new(AtomicBool::new(false)),
intentional_stop: Arc::new(AtomicBool::new(false)),
}
}
@@ -141,7 +137,6 @@ impl WslBridge {
stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: Some(conversation_id),
received_init: Arc::new(AtomicBool::new(false)),
intentional_stop: Arc::new(AtomicBool::new(false)),
}
}
@@ -411,9 +406,8 @@ impl WslBridge {
self.stdin = stdin;
*self.process.lock() = Some(child);
// Reset flags so the watchdog and stdout handler start fresh.
// Reset the init flag so the watchdog and stdout handler start fresh.
self.received_init.store(false, Ordering::SeqCst);
self.intentional_stop.store(false, Ordering::SeqCst);
// Note: We no longer reset stats here - stats persist across reconnects
// Stats are only reset when explicitly disconnecting via stop()
@@ -431,16 +425,8 @@ impl WslBridge {
let stats_clone = self.stats.clone();
let conv_id = self.conversation_id.clone();
let received_init_clone = self.received_init.clone();
let intentional_stop_clone = self.intentional_stop.clone();
thread::spawn(move || {
handle_stdout(
stdout,
app_clone,
stats_clone,
conv_id,
received_init_clone,
intentional_stop_clone,
);
handle_stdout(stdout, app_clone, stats_clone, conv_id, received_init_clone);
});
}
@@ -557,11 +543,6 @@ impl WslBridge {
// See: https://github.com/anthropics/claude-code/issues/3455
// Extract the process first so the MutexGuard is dropped before we mutably
// borrow `self` again via estimate_interrupted_request_cost.
// Signal handle_stdout that this is an intentional stop so it doesn't emit
// a second Disconnected event after stdout closes due to the kill.
self.intentional_stop.store(true, Ordering::SeqCst);
let maybe_process = self.process.lock().take();
if let Some(mut process) = maybe_process {
// Estimate cost for interrupted request before killing
@@ -693,9 +674,6 @@ impl WslBridge {
}
pub fn stop(&mut self, app: &AppHandle) {
// Signal handle_stdout that this is an intentional stop so it doesn't emit
// a second Disconnected event after stdout closes due to the kill.
self.intentional_stop.store(true, Ordering::SeqCst);
if let Some(mut process) = self.process.lock().take() {
let _ = process.kill();
let _ = process.wait();
@@ -751,7 +729,6 @@ fn handle_stdout(
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
received_init: Arc<AtomicBool>,
intentional_stop: Arc<AtomicBool>,
) {
let reader = BufReader::new(stdout);
@@ -772,12 +749,6 @@ fn handle_stdout(
}
}
// If this was an intentional stop (stop()/interrupt() was called), the caller already
// emitted a Disconnected event. Skip all post-loop emissions to prevent duplicates.
if intentional_stop.load(Ordering::SeqCst) {
return;
}
// If stdout closed before system:init arrived the process exited without initialising.
// Emit an error line so the user understands why the connection failed.
if !received_init.load(Ordering::SeqCst) {
@@ -794,23 +765,6 @@ fn handle_stdout(
);
}
// If Claude exited while a prompt was in-flight, the user's message was never processed.
// Emit a specific error so they know to resend their prompt.
let had_pending_request = stats.read().current_request_input.is_some();
if had_pending_request {
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: "Claude Code exited before finishing your request — your last prompt was not processed. Please reconnect and try again.".to_string(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.8.1",
"version": "1.8.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
+1 -1
View File
@@ -135,7 +135,7 @@
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted via stop button");
claudeStore.addLine("system", "Interrupted");
characterState.setState("idle");
} catch (error) {
console.error("Failed to interrupt:", error);
+1 -18
View File
@@ -164,17 +164,6 @@
attachments = storedAttachments;
});
// Per-tab draft persistence — restore the draft text whenever the active
// conversation changes, and save it back on every keystroke.
claudeStore.activeConversationId.subscribe((conversationId) => {
if (conversationId) {
const conv = get(claudeStore.conversations).get(conversationId);
inputValue = conv?.draftText ?? "";
} else {
inputValue = "";
}
});
function handleInputChange() {
// If input is empty, allow history navigation again
// Otherwise, mark that user has manually typed
@@ -187,12 +176,6 @@
historyIndex = -1;
tempInput = "";
// Save the current draft so it persists if the user switches tabs.
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
claudeStore.setDraftText(activeId, inputValue);
}
if (isSlashCommand(inputValue)) {
matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0;
@@ -343,7 +326,7 @@ User: ${formattedMessage}`;
throw new Error("No active conversation");
}
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
claudeStore.addLine("system", "Process interrupted - reconnecting...");
characterState.setState("idle");
// Show connecting status while we reconnect
+3 -3
View File
@@ -9,10 +9,10 @@
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let permissions: PermissionRequest[] = [];
let permissions: PermissionRequest[] = $state([]);
let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = [];
let workingDirectory = "";
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms;
+78 -10
View File
@@ -1,8 +1,52 @@
import { characterState } from "$lib/stores/character";
import { notificationManager } from "./notificationManager";
import type { CharacterState } from "$lib/types/states";
import type { ConnectionStatus } from "$lib/types/messages";
// Track previous connection status to detect transitions
// Track previous states to detect transitions
let previousCharacterState: CharacterState | null = null;
let previousConnectionStatus: ConnectionStatus | null = null;
let taskStartTime: number | null = null;
let hasNotifiedTaskStart = false;
export function handleCharacterStateChange(newState: CharacterState): void {
// Detect state transitions
if (previousCharacterState === newState) return;
// Task completion: any state -> success
if (newState === "success" && previousCharacterState !== null) {
const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0;
// Only notify for tasks that took more than 2 seconds
if (taskDuration > 2000) {
notificationManager.notifySuccess();
}
taskStartTime = null;
}
// Error occurred
if (newState === "error" && previousCharacterState !== "error") {
notificationManager.notifyError();
}
// Permission needed
if (newState === "permission") {
notificationManager.notifyPermission();
}
// Starting long tasks - only notify once per response
if (
(newState === "coding" || newState === "searching") &&
previousCharacterState !== "coding" &&
previousCharacterState !== "searching" &&
!hasNotifiedTaskStart
) {
taskStartTime = Date.now();
hasNotifiedTaskStart = true;
notificationManager.notifyTaskStart();
}
previousCharacterState = newState;
}
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
// Only notify on successful connection after being disconnected
@@ -23,13 +67,37 @@ export function handleToolExecution(_toolName: string): void {
// But we could add specific rules here if needed
}
// No-op: sound tracking is now per-conversation in tauri.ts
export function handleNewUserMessage(): void {}
// No-op: all per-conversation sounds are driven by tauri.ts event listeners
export function initializeNotificationRules(): void {}
// Cleanup — reset connection tracking on teardown
export function cleanupNotificationRules(): void {
previousConnectionStatus = null;
// Reset notification state for a new response
export function handleNewUserMessage(): void {
hasNotifiedTaskStart = false;
}
// Store unsubscribe functions
let unsubscribeCharacterState: (() => void) | null = null;
// Initialize listeners
export function initializeNotificationRules(): void {
// Clean up any existing subscriptions first
cleanupNotificationRules();
// Subscribe to character state changes
unsubscribeCharacterState = characterState.subscribe((state) => {
handleCharacterStateChange(state);
});
// We'll connect to connection status in the next step
}
// Cleanup function to prevent duplicate listeners
export function cleanupNotificationRules(): void {
if (unsubscribeCharacterState) {
unsubscribeCharacterState();
unsubscribeCharacterState = null;
}
// Reset state tracking
previousCharacterState = null;
previousConnectionStatus = null;
taskStartTime = null;
hasNotifiedTaskStart = false;
}
-9
View File
@@ -60,15 +60,6 @@ export const claudeStore = {
isToolGranted: conversationsStore.isToolGranted,
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
// Sound tracking
resetSoundState: conversationsStore.resetSoundState,
setTaskStartTime: conversationsStore.setTaskStartTime,
markSuccessSoundFired: conversationsStore.markSuccessSoundFired,
markTaskStartSoundFired: conversationsStore.markTaskStartSoundFired,
// Draft text (per-tab input persistence)
setDraftText: conversationsStore.setDraftText,
// Conversation management
createConversation: conversationsStore.createConversation,
deleteConversation: conversationsStore.deleteConversation,
-38
View File
@@ -523,41 +523,3 @@ describe("pending retry message", () => {
expect(pendingRetryMessage).toBeNull();
});
});
describe("draft text persistence", () => {
it("initialises draft text as empty string", () => {
const conversation = { draftText: "" };
expect(conversation.draftText).toBe("");
});
it("stores draft text per conversation", () => {
const conversations = new Map([
["conv-1", { draftText: "Hello world" }],
["conv-2", { draftText: "" }],
]);
expect(conversations.get("conv-1")?.draftText).toBe("Hello world");
expect(conversations.get("conv-2")?.draftText).toBe("");
});
it("updates draft text independently per conversation", () => {
const conversations = new Map([
["conv-1", { draftText: "Draft A" }],
["conv-2", { draftText: "Draft B" }],
]);
const convA = conversations.get("conv-1");
if (convA) convA.draftText = "Updated A";
expect(conversations.get("conv-1")?.draftText).toBe("Updated A");
expect(conversations.get("conv-2")?.draftText).toBe("Draft B");
});
it("clears draft text after submission", () => {
const conversation = { draftText: "My prompt" };
conversation.draftText = "";
expect(conversation.draftText).toBe("");
});
});
+4 -71
View File
@@ -37,10 +37,6 @@ export interface Conversation {
attachments: Attachment[];
summary: ConversationSummary | null;
startedAt: Date;
taskStartTime: number | null;
successSoundFired: boolean;
taskStartSoundFired: boolean;
draftText: string;
}
function createConversationsStore() {
@@ -79,10 +75,6 @@ function createConversationsStore() {
attachments: [],
summary: null,
startedAt: new Date(),
taskStartTime: null,
successSoundFired: false,
taskStartSoundFired: false,
draftText: "",
};
}
@@ -204,7 +196,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermissions = [...conv.pendingPermissions, request];
conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date();
}
return convs;
@@ -227,7 +219,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermissions = [...conv.pendingPermissions, request];
conv.pendingPermissions.push(request);
conv.lastActivityAt = new Date();
}
return convs;
@@ -372,15 +364,9 @@ function createConversationsStore() {
if (currentId !== id) {
activeConversationId.set(id);
// Update the global character state to match the conversation's state.
// Map success/error → idle since those are transient states that have
// already been displayed — restoring them would re-trigger sound rules.
// Update the global character state to match the conversation's state
if (targetConv) {
const stateToRestore =
targetConv.characterState === "success" || targetConv.characterState === "error"
? "idle"
: targetConv.characterState;
characterState.setState(stateToRestore);
characterState.setState(targetConv.characterState);
}
}
},
@@ -830,59 +816,6 @@ function createConversationsStore() {
});
},
// Sound tracking methods
resetSoundState: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartTime = null;
conv.successSoundFired = false;
conv.taskStartSoundFired = false;
}
return convs;
});
},
setTaskStartTime: (conversationId: string, time: number | null) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartTime = time;
}
return convs;
});
},
markSuccessSoundFired: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.successSoundFired = true;
}
return convs;
});
},
markTaskStartSoundFired: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartSoundFired = true;
}
return convs;
});
},
setDraftText: (conversationId: string, text: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.draftText = text;
}
return convs;
});
},
// Add initialization helper
initialize: () => {
ensureInitialized();
+1 -59
View File
@@ -21,7 +21,6 @@ import {
handleConnectionStatusChange,
handleNewUserMessage,
} from "$lib/notifications/rules";
import { notificationManager } from "$lib/notifications/notificationManager";
interface StateChangePayload {
state: CharacterState;
@@ -221,7 +220,7 @@ export async function initializeTauriListeners() {
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Disconnected from Claude Code unexpectedly — the process may have crashed or been stopped by the system"
"Disconnected from Claude Code"
);
// Clear todos on real disconnect (not on reconnects for permissions)
@@ -271,63 +270,6 @@ export async function initializeTauriListeners() {
const mappedState = stateMap[state.toLowerCase()] || "idle";
// Per-conversation sound tracking — fires for any tab (active or background).
// All sounds are driven from state-change events rather than a global store
// subscription, so background tabs receive their sounds correctly and
// switching tabs never replays a sound that has already fired.
const resolvedConversationId = conversation_id || get(claudeStore.activeConversationId) || null;
if (resolvedConversationId) {
const conv = get(claudeStore.conversations).get(resolvedConversationId);
if (conv) {
const previousState = conv.characterState;
// New response starting — clear all per-task sound flags.
if (mappedState === "thinking") {
claudeStore.resetSoundState(resolvedConversationId);
}
// Record when a long-running phase begins (used for the 2-second
// minimum duration check before playing the completion sound).
if (
(mappedState === "coding" || mappedState === "searching") &&
previousState !== "coding" &&
previousState !== "searching"
) {
claudeStore.setTaskStartTime(resolvedConversationId, Date.now());
}
// Task-start sound — fires once when work enters a long-running phase.
if (
(mappedState === "coding" || mappedState === "searching") &&
previousState !== "coding" &&
previousState !== "searching" &&
!conv.taskStartSoundFired
) {
notificationManager.notifyTaskStart();
claudeStore.markTaskStartSoundFired(resolvedConversationId);
}
// Error sound — fires each time a new error state is entered.
if (mappedState === "error" && previousState !== "error") {
notificationManager.notifyError();
}
// Permission sound — fires each time a permission request arrives.
if (mappedState === "permission") {
notificationManager.notifyPermission();
}
// Completion sound — fires once per task after sufficient duration.
if (mappedState === "success" && !conv.successSoundFired) {
const duration = conv.taskStartTime ? Date.now() - conv.taskStartTime : 0;
if (duration > 2000) {
notificationManager.notifySuccess();
}
claudeStore.markSuccessSoundFired(resolvedConversationId);
}
}
}
// Always update the conversation's state
if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
+1 -1
View File
@@ -337,7 +337,7 @@
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted by keyboard shortcut (Ctrl+C)");
claudeStore.addLine("system", "Process interrupted");
} catch (error) {
console.error("Failed to interrupt:", error);
}