diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3868081..d970133 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -105,6 +105,51 @@ pub async fn get_usage_stats( manager.get_usage_stats(&conversation_id) } +#[tauri::command] +pub async fn validate_directory(path: String, current_dir: Option) -> Result { + use std::path::Path; + + let path = Path::new(&path); + + // Expand ~ to home directory + let expanded_path = if path.starts_with("~") { + if let Some(home) = std::env::var_os("HOME") { + let home_path = Path::new(&home); + if path == Path::new("~") { + home_path.to_path_buf() + } else { + home_path.join(path.strip_prefix("~").unwrap()) + } + } else { + return Err("Could not determine home directory".to_string()); + } + } else if path.is_relative() { + // Handle relative paths (., .., or any relative path) by resolving against current_dir + if let Some(ref cwd) = current_dir { + Path::new(cwd).join(path) + } else { + path.to_path_buf() + } + } else { + path.to_path_buf() + }; + + // Check if the path exists and is a directory + if !expanded_path.exists() { + return Err(format!("Directory does not exist: {}", expanded_path.display())); + } + + if !expanded_path.is_dir() { + return Err(format!("Path is not a directory: {}", expanded_path.display())); + } + + // Return the canonicalized (absolute) path + expanded_path + .canonicalize() + .map(|p| p.to_string_lossy().to_string()) + .map_err(|e| format!("Failed to resolve path: {}", e)) +} + #[tauri::command] pub async fn load_saved_achievements(app: AppHandle) -> Result, String> { use chrono::Utc; diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 56b79e1..5ab84fc 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -22,6 +22,9 @@ pub struct ClaudeStartOptions { #[serde(default)] pub skip_greeting: bool, + + #[serde(default)] + pub resume_session_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0abb223..b2b0a18 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -53,6 +53,7 @@ pub fn run() { send_notify_send, send_wsl_notification, send_vbs_notification, + validate_directory, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 61d33c1..1477eb1 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -195,6 +195,13 @@ impl WslBridge { cmd.args(["--mcp-config", mcp_path]); } + // Add resume flag if session ID provided + if let Some(ref session_id) = options.resume_session_id { + if !session_id.is_empty() { + cmd.args(["--resume", session_id]); + } + } + cmd.current_dir(working_dir); // Set API key as environment variable if specified @@ -251,6 +258,13 @@ impl WslBridge { claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path)); } + // Add resume flag if session ID provided + if let Some(ref session_id) = options.resume_session_id { + if !session_id.is_empty() { + claude_cmd.push_str(&format!(" --resume '{}'", session_id)); + } + } + // Use bash -lc to load login profile (ensures PATH includes claude) cmd.args(["-e", "bash", "-lc", &claude_cmd]); diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index cfab588..cf83a7b 100644 --- a/src/lib/commands/slashCommands.ts +++ b/src/lib/commands/slashCommands.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { claudeStore } from "$lib/stores/claude"; import { characterState } from "$lib/stores/character"; import { setSkipNextGreeting } from "$lib/tauri"; +import { searchState } from "$lib/stores/search"; export interface SlashCommand { name: string; @@ -11,6 +12,71 @@ export interface SlashCommand { execute: (args: string) => Promise | void; } +async function changeDirectory(path: string): Promise { + const conversationId = get(claudeStore.activeConversationId); + if (!conversationId) { + claudeStore.addLine("error", "No active conversation"); + return; + } + + if (!path.trim()) { + const currentDir = get(claudeStore.currentWorkingDirectory); + claudeStore.addLine("system", `Current directory: ${currentDir}`); + return; + } + + try { + characterState.setState("thinking"); + claudeStore.addLine("system", `Changing directory to: ${path}`); + + const currentDir = get(claudeStore.currentWorkingDirectory); + const validatedPath = await invoke("validate_directory", { path, currentDir }); + + // Capture conversation history before disconnecting + const conversationHistory = claudeStore.getConversationHistory(); + + await invoke("stop_claude", { conversationId }); + + // Wait for clean shutdown + await new Promise((resolve) => setTimeout(resolve, 500)); + + claudeStore.setWorkingDirectory(validatedPath); + + setSkipNextGreeting(true); + + await invoke("start_claude", { + conversationId, + options: { + working_dir: validatedPath, + }, + }); + + // Wait for connection to establish + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Restore context if there was conversation history + if (conversationHistory) { + const contextMessage = `[CONTEXT RESTORATION] +I just changed the working directory from ${currentDir} to ${validatedPath}. Here's our conversation so far: + +${conversationHistory} + +Please continue where we left off. You are now operating in the new directory.`; + + await invoke("send_prompt", { + conversationId, + message: contextMessage, + }); + } + + claudeStore.addLine("system", `Changed directory to: ${validatedPath}`); + characterState.setState("idle"); + } catch (error) { + claudeStore.addLine("error", `Failed to change directory: ${error}`); + characterState.setTemporaryState("error", 3000); + } +} + async function startNewConversation(): Promise { const conversationId = get(claudeStore.activeConversationId); if (!conversationId) { @@ -48,6 +114,12 @@ async function startNewConversation(): Promise { } export const slashCommands: SlashCommand[] = [ + { + name: "cd", + description: "Change the working directory", + usage: "/cd ", + execute: changeDirectory, + }, { name: "clear", description: "Clear the terminal display (keeps conversation context)", @@ -74,6 +146,20 @@ export const slashCommands: SlashCommand[] = [ claudeStore.addLine("system", `Available commands:\n${helpText}`); }, }, + { + name: "search", + description: "Search within the conversation (use /search to clear)", + usage: "/search [query]", + execute: (args: string) => { + if (!args.trim()) { + searchState.clear(); + claudeStore.addLine("system", "Search cleared"); + return; + } + searchState.setQuery(args.trim()); + claudeStore.addLine("system", `Searching for: "${args.trim()}"`); + }, + }, { name: "summarise", description: "Get a summary of the entire conversation", diff --git a/src/lib/components/ConversationTabs.svelte b/src/lib/components/ConversationTabs.svelte index 71fcc26..6f2d8c0 100644 --- a/src/lib/components/ConversationTabs.svelte +++ b/src/lib/components/ConversationTabs.svelte @@ -126,6 +126,8 @@ } else if (event.key === "Escape") { editingTabId = null; editingName = ""; + } else if (event.key === " ") { + event.stopPropagation(); } } diff --git a/src/lib/components/HighlightedText.svelte b/src/lib/components/HighlightedText.svelte new file mode 100644 index 0000000..9e0c316 --- /dev/null +++ b/src/lib/components/HighlightedText.svelte @@ -0,0 +1,61 @@ + + + + {#each parts as part, index (index)} + {#if part.isMatch} + {part.text} + {:else} + {part.text} + {/if} + {/each} + diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index 21c2972..de0f48b 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -2,12 +2,14 @@ import { marked } from "marked"; import hljs from "highlight.js"; import { onMount } from "svelte"; + import { openUrl } from "@tauri-apps/plugin-opener"; interface Props { content: string; + searchQuery?: string; } - let { content }: Props = $props(); + let { content, searchQuery = "" }: Props = $props(); let containerElement: HTMLDivElement; const renderer = new marked.Renderer(); @@ -51,10 +53,47 @@ return processed; } + function highlightSearchMatches(html: string, query: string): string { + if (!query) return html; + + const codeBlockPlaceholders: string[] = []; + const tagPlaceholders: string[] = []; + + // Temporarily replace code blocks with placeholders (don't highlight in code) + let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => { + codeBlockPlaceholders.push(match); + return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`; + }); + + // Temporarily replace all HTML tags with placeholders + processed = processed.replace(/<[^>]+>/g, (match) => { + tagPlaceholders.push(match); + return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`; + }); + + // Apply search highlighting to text content + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`(${escapedQuery})`, "gi"); + processed = processed.replace(regex, '$1'); + + // Restore HTML tags + processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => { + return tagPlaceholders[parseInt(index)]; + }); + + // Restore code blocks + processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => { + return codeBlockPlaceholders[parseInt(index)]; + }); + + return processed; + } + function renderMarkdown(text: string): string { try { const html = marked.parse(text) as string; - return processSpoilers(html); + const withSpoilers = processSpoilers(html); + return highlightSearchMatches(withSpoilers, searchQuery); } catch { return text; } @@ -75,6 +114,15 @@ } } + function handleLinkClick(event: MouseEvent) { + const target = event.target as HTMLElement; + const anchor = target.closest("a"); + if (anchor?.href) { + event.preventDefault(); + openUrl(anchor.href); + } + } + onMount(() => { if (containerElement) { containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => { @@ -87,7 +135,10 @@
{ + handleSpoilerClick(e); + handleLinkClick(e); + }} onkeydown={handleSpoilerKeydown} role="presentation" > @@ -291,4 +342,11 @@ color: var(--text-primary); user-select: text; } + + .markdown-content :global(.search-highlight) { + background-color: var(--search-highlight, #fbbf24); + color: var(--search-highlight-text, #000); + border-radius: 2px; + padding: 0 2px; + } diff --git a/src/lib/components/Terminal.svelte b/src/lib/components/Terminal.svelte index 7667349..2ebf51a 100644 --- a/src/lib/components/Terminal.svelte +++ b/src/lib/components/Terminal.svelte @@ -3,10 +3,17 @@ import { afterUpdate } from "svelte"; import ConversationTabs from "./ConversationTabs.svelte"; import Markdown from "./Markdown.svelte"; + import HighlightedText from "./HighlightedText.svelte"; + import { searchState, searchQuery } from "$lib/stores/search"; let terminalElement: HTMLDivElement; let shouldAutoScroll = true; let lines: TerminalLine[] = []; + let currentSearchQuery = ""; + + searchQuery.subscribe((value) => { + currentSearchQuery = value; + }); claudeStore.terminalLines.subscribe((value) => { lines = value; @@ -64,6 +71,23 @@ minute: "2-digit", }); } + + $: { + if (currentSearchQuery && lines.length > 0) { + const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedQuery, "gi"); + let totalMatches = 0; + for (const line of lines) { + const matches = line.content.match(regex); + if (matches) { + totalMatches += matches.length; + } + } + searchState.setMatchCount(totalMatches); + } else { + searchState.setMatchCount(0); + } + }
[{line.toolName}] {/if} {#if line.type === "assistant"} - + {:else} - {line.content} + {/if}
{/each} @@ -162,4 +186,11 @@ .terminal-header-text { color: var(--text-secondary); } + + :global(.search-highlight) { + background-color: var(--search-highlight, #fbbf24); + color: var(--search-highlight-text, #000); + border-radius: 2px; + padding: 0 2px; + } diff --git a/src/lib/stores/search.ts b/src/lib/stores/search.ts new file mode 100644 index 0000000..503ed36 --- /dev/null +++ b/src/lib/stores/search.ts @@ -0,0 +1,63 @@ +import { writable, derived } from "svelte/store"; + +interface SearchState { + query: string; + isActive: boolean; + matchCount: number; + currentMatchIndex: number; +} + +const initialState: SearchState = { + query: "", + isActive: false, + matchCount: 0, + currentMatchIndex: 0, +}; + +const searchStore = writable(initialState); + +export const searchState = { + subscribe: searchStore.subscribe, + + setQuery: (query: string) => { + searchStore.update((state) => ({ + ...state, + query, + isActive: query.length > 0, + currentMatchIndex: 0, + })); + }, + + setMatchCount: (count: number) => { + searchStore.update((state) => ({ + ...state, + matchCount: count, + })); + }, + + nextMatch: () => { + searchStore.update((state) => ({ + ...state, + currentMatchIndex: + state.matchCount > 0 ? (state.currentMatchIndex + 1) % state.matchCount : 0, + })); + }, + + previousMatch: () => { + searchStore.update((state) => ({ + ...state, + currentMatchIndex: + state.matchCount > 0 + ? (state.currentMatchIndex - 1 + state.matchCount) % state.matchCount + : 0, + })); + }, + + clear: () => { + searchStore.set(initialState); + }, +}; + +export const isSearchActive = derived(searchStore, ($search) => $search.isActive); + +export const searchQuery = derived(searchStore, ($search) => $search.query);