From 2d3adcab1c056da7000a4f7307bd33adf1667abc Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 20 Jan 2026 08:33:39 -0800 Subject: [PATCH] feat: add chat modes and interrupt feature (#46) ### Explanation _No response_ ### Issue Closes #40 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/46 Co-authored-by: Naomi Carrigan Co-committed-by: Naomi Carrigan --- src-tauri/src/commands.rs | 6 + src-tauri/src/config.rs | 3 + src-tauri/src/lib.rs | 1 + src-tauri/src/wsl_bridge.rs | 24 +++ src/lib/components/InputBar.svelte | 183 +++++++++++++++--- src/lib/components/MessageModeSelector.svelte | 157 +++++++++++++++ src/lib/stores/claude.ts | 34 ++++ src/lib/stores/historyRestore.ts | 29 +++ src/lib/stores/messageMode.ts | 20 ++ src/lib/tauri.ts | 23 ++- src/lib/types/messageMode.ts | 69 +++++++ 11 files changed, 521 insertions(+), 28 deletions(-) create mode 100644 src/lib/components/MessageModeSelector.svelte create mode 100644 src/lib/stores/historyRestore.ts create mode 100644 src/lib/stores/messageMode.ts create mode 100644 src/lib/types/messageMode.ts diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1bafa39..6862fa8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -25,6 +25,12 @@ pub async fn stop_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Res Ok(()) } +#[tauri::command] +pub async fn interrupt_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> { + let mut bridge = bridge.lock(); + bridge.interrupt(&app) +} + #[tauri::command] pub async fn send_prompt(bridge: State<'_, SharedBridge>, message: String) -> Result<(), String> { let mut bridge = bridge.lock(); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 8aaf580..a801550 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -19,6 +19,9 @@ pub struct ClaudeStartOptions { #[serde(default)] pub allowed_tools: Vec, + + #[serde(default)] + pub skip_greeting: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7646eb..c595bbf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ start_claude, stop_claude, + interrupt_claude, send_prompt, is_claude_running, get_working_directory, diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index b3744f2..12e78ba 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -332,6 +332,30 @@ impl WslBridge { Ok(()) } + pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> { + // Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work, + // we have to kill the process. This is the only reliable way to stop it. + // See: https://github.com/anthropics/claude-code/issues/3455 + if let Some(mut process) = self.process.take() { + // Kill the process immediately + let _ = process.kill(); + let _ = process.wait(); + + // Clear stdin + self.stdin = None; + + // Keep session_id and working directory for user reference + // The user will see what session was interrupted + + // Emit disconnected status + emit_connection_status(app, ConnectionStatus::Disconnected); + + Ok(()) + } else { + Err("No active process to interrupt".to_string()) + } + } + pub fn stop(&mut self, app: &AppHandle) { if let Some(mut process) = self.process.take() { let _ = process.kill(); diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 3517c19..14af289 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -1,17 +1,33 @@ -
-
- -
+ > + - {:else} - Send + {/if} - +
+ + diff --git a/src/lib/components/MessageModeSelector.svelte b/src/lib/components/MessageModeSelector.svelte new file mode 100644 index 0000000..1088424 --- /dev/null +++ b/src/lib/components/MessageModeSelector.svelte @@ -0,0 +1,157 @@ + + + + +
+ + + {#if isOpen} + + {/if} +
+ + diff --git a/src/lib/stores/claude.ts b/src/lib/stores/claude.ts index 78d982c..6ac81f1 100644 --- a/src/lib/stores/claude.ts +++ b/src/lib/stores/claude.ts @@ -1,5 +1,6 @@ import { writable, derived } from "svelte/store"; import type { ConnectionStatus, PermissionRequest } from "$lib/types/messages"; +import { characterState } from "$lib/stores/character"; export interface TerminalLine { id: string; @@ -18,6 +19,8 @@ function createClaudeStore() { const isProcessing = writable(false); const grantedTools = writable>(new Set()); const pendingRetryMessage = writable(null); + const shouldRestoreHistory = writable(false); + const savedConversationHistory = writable(null); let lineIdCounter = 0; @@ -34,6 +37,8 @@ function createClaudeStore() { isProcessing: { subscribe: isProcessing.subscribe }, grantedTools: { subscribe: grantedTools.subscribe }, pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe }, + shouldRestoreHistory: { subscribe: shouldRestoreHistory.subscribe }, + savedConversationHistory: { subscribe: savedConversationHistory.subscribe }, setConnectionStatus: (status: ConnectionStatus) => connectionStatus.set(status), setSessionId: (id: string | null) => sessionId.set(id), @@ -106,6 +111,21 @@ function createClaudeStore() { setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message), + setShouldRestoreHistory: (should: boolean) => shouldRestoreHistory.set(should), + setSavedConversationHistory: (history: string | null) => savedConversationHistory.set(history), + + getShouldRestoreHistory: (): boolean => { + let should = false; + shouldRestoreHistory.subscribe((s) => (should = s))(); + return should; + }, + + getSavedConversationHistory: (): string | null => { + let history: string | null = null; + savedConversationHistory.subscribe((h) => (history = h))(); + return history; + }, + reset: () => { connectionStatus.set("disconnected"); sessionId.set(null); @@ -115,6 +135,8 @@ function createClaudeStore() { isProcessing.set(false); grantedTools.set(new Set()); pendingRetryMessage.set(null); + shouldRestoreHistory.set(false); + savedConversationHistory.set(null); }, }; } @@ -125,3 +147,15 @@ export const hasPermissionPending = derived( claudeStore.pendingPermission, ($permission) => $permission !== null ); + +// Derived store to check if Claude is currently processing (can be interrupted) +export const isClaudeProcessing = derived( + [claudeStore.connectionStatus, characterState], + ([$connectionStatus, $characterState]) => { + // Must be connected and in one of the processing states + return ( + $connectionStatus === "connected" && + ["thinking", "typing", "searching", "coding", "mcp"].includes($characterState) + ); + } +); diff --git a/src/lib/stores/historyRestore.ts b/src/lib/stores/historyRestore.ts new file mode 100644 index 0000000..a984b59 --- /dev/null +++ b/src/lib/stores/historyRestore.ts @@ -0,0 +1,29 @@ +// Separate module for history restoration to ensure persistence across reconnects +let shouldRestore = false; +let savedHistory: string | null = null; + +export function setShouldRestoreHistory(should: boolean) { + shouldRestore = should; + console.log("Setting shouldRestoreHistory to:", should); +} + +export function setSavedHistory(history: string | null) { + savedHistory = history; + console.log("Setting savedHistory, length:", history?.length || 0); +} + +export function getShouldRestoreHistory(): boolean { + console.log("Getting shouldRestoreHistory:", shouldRestore); + return shouldRestore; +} + +export function getSavedHistory(): string | null { + console.log("Getting savedHistory, length:", savedHistory?.length || 0); + return savedHistory; +} + +export function clearHistoryRestore() { + console.log("Clearing history restore flags"); + shouldRestore = false; + savedHistory = null; +} diff --git a/src/lib/stores/messageMode.ts b/src/lib/stores/messageMode.ts new file mode 100644 index 0000000..2936080 --- /dev/null +++ b/src/lib/stores/messageMode.ts @@ -0,0 +1,20 @@ +import { writable } from "svelte/store"; + +// Default to chat mode +const messageModeStore = writable("chat"); + +export const messageMode = { + subscribe: messageModeStore.subscribe, + set: (mode: string) => { + console.log("Setting message mode to:", mode); + messageModeStore.set(mode); + }, + reset: () => messageModeStore.set("chat"), +}; + +// Helper to get current mode +export function getCurrentMode(): string { + let currentMode = "chat"; + messageMode.subscribe((mode) => (currentMode = mode))(); + return currentMode; +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 406ecf0..0eeb0cc 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -21,6 +21,11 @@ interface StateChangePayload { let hasConnectedThisSession = false; let unlisteners: Array<() => void> = []; +let skipNextGreeting = false; + +export function setSkipNextGreeting(skip: boolean) { + skipNextGreeting = skip; +} function getTimeOfDay(): string { const hour = new Date().getHours(); @@ -42,6 +47,12 @@ function generateGreetingPrompt(): string { } async function sendGreeting() { + // Check if we should skip this greeting + if (skipNextGreeting) { + skipNextGreeting = false; // Reset the flag + return; + } + const config = configStore.getConfig(); if (!config.greeting_enabled) { @@ -100,8 +111,16 @@ export async function initializeTauriListeners() { await sendGreeting(); } } else if (status === "disconnected") { - hasConnectedThisSession = false; - claudeStore.addLine("system", "Disconnected from Claude Code"); + // Only reset session flag if we're not about to reconnect + if (!skipNextGreeting) { + hasConnectedThisSession = false; + } + + // Don't add system message if we're about to reconnect + if (!skipNextGreeting) { + claudeStore.addLine("system", "Disconnected from Claude Code"); + } + characterState.setState("idle"); } else if (status === "error") { hasConnectedThisSession = false; diff --git a/src/lib/types/messageMode.ts b/src/lib/types/messageMode.ts new file mode 100644 index 0000000..2930562 --- /dev/null +++ b/src/lib/types/messageMode.ts @@ -0,0 +1,69 @@ +export interface MessageMode { + id: string; + name: string; + description: string; + prefix?: string; + icon: string; +} + +export const MESSAGE_MODES: MessageMode[] = [ + { + id: "chat", + name: "Chat", + description: "Normal conversation mode", + icon: "💬", + }, + { + id: "architect", + name: "Architect", + description: "High-level design and architecture planning", + prefix: "[Architect Mode] ", + icon: "🏗️", + }, + { + id: "code", + name: "Code", + description: "Focused on writing and editing code", + prefix: "[Code Mode] ", + icon: "💻", + }, + { + id: "debug", + name: "Debug", + description: "Help with debugging and troubleshooting", + prefix: "[Debug Mode] ", + icon: "🐛", + }, + { + id: "ask", + name: "Ask", + description: "Technical questions and explanations", + prefix: "[Ask Mode] ", + icon: "❓", + }, + { + id: "review", + name: "Review", + description: "Code review and feedback", + prefix: "[Review Mode] ", + icon: "👀", + }, +]; + +export function getMessageMode(id: string): MessageMode | undefined { + return MESSAGE_MODES.find((mode) => mode.id === id); +} + +export function formatMessageWithMode(message: string, modeId: string): string { + const mode = getMessageMode(modeId); + if (!mode || !mode.prefix) { + return message; + } + + // Don't double-prefix if the message already starts with the prefix + if (message.startsWith(mode.prefix)) { + return message; + } + + return mode.prefix + message; +}