diff --git a/src/lib/commands/slashCommands.ts b/src/lib/commands/slashCommands.ts index 4066f9c..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; @@ -145,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/Markdown.svelte b/src/lib/components/Markdown.svelte index 6d4de6c..de0f48b 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -6,9 +6,10 @@ interface Props { content: string; + searchQuery?: string; } - let { content }: Props = $props(); + let { content, searchQuery = "" }: Props = $props(); let containerElement: HTMLDivElement; const renderer = new marked.Renderer(); @@ -52,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; } @@ -304,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..92cdcf8 100644 --- a/src/lib/components/Terminal.svelte +++ b/src/lib/components/Terminal.svelte @@ -3,10 +3,16 @@ import { afterUpdate } from "svelte"; import ConversationTabs from "./ConversationTabs.svelte"; import Markdown from "./Markdown.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 +70,30 @@ minute: "2-digit", }); } + + function highlightText(text: string, query: string): string { + if (!query) return text; + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`(${escapedQuery})`, "gi"); + return text.replace(regex, '$1'); + } + + $: { + 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} + + {@html highlightText(line.content, currentSearchQuery)} {/if}
{/each} @@ -162,4 +193,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..35e9635 --- /dev/null +++ b/src/lib/stores/search.ts @@ -0,0 +1,68 @@ +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);