feat: add /search command to highlight matches in conversation
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 54s
CI / Lint & Test (pull_request) Failing after 5m52s
CI / Build Linux (pull_request) Has been skipped
CI / Build Windows (cross-compile) (pull_request) Has been skipped

Closes #32
This commit is contained in:
2026-01-21 20:19:46 -08:00
committed by Naomi Carrigan
parent 19ca7b7c6e
commit 59c7652f3e
4 changed files with 170 additions and 4 deletions
+15
View File
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import { setSkipNextGreeting } from "$lib/tauri"; import { setSkipNextGreeting } from "$lib/tauri";
import { searchState } from "$lib/stores/search";
export interface SlashCommand { export interface SlashCommand {
name: string; name: string;
@@ -145,6 +146,20 @@ export const slashCommands: SlashCommand[] = [
claudeStore.addLine("system", `Available commands:\n${helpText}`); 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", name: "summarise",
description: "Get a summary of the entire conversation", description: "Get a summary of the entire conversation",
+47 -2
View File
@@ -6,9 +6,10 @@
interface Props { interface Props {
content: string; content: string;
searchQuery?: string;
} }
let { content }: Props = $props(); let { content, searchQuery = "" }: Props = $props();
let containerElement: HTMLDivElement; let containerElement: HTMLDivElement;
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
@@ -52,10 +53,47 @@
return processed; 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, '<mark class="search-highlight">$1</mark>');
// 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 { function renderMarkdown(text: string): string {
try { try {
const html = marked.parse(text) as string; const html = marked.parse(text) as string;
return processSpoilers(html); const withSpoilers = processSpoilers(html);
return highlightSearchMatches(withSpoilers, searchQuery);
} catch { } catch {
return text; return text;
} }
@@ -304,4 +342,11 @@
color: var(--text-primary); color: var(--text-primary);
user-select: text; 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;
}
</style> </style>
+40 -2
View File
@@ -3,10 +3,16 @@
import { afterUpdate } from "svelte"; import { afterUpdate } from "svelte";
import ConversationTabs from "./ConversationTabs.svelte"; import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte"; import Markdown from "./Markdown.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
let terminalElement: HTMLDivElement; let terminalElement: HTMLDivElement;
let shouldAutoScroll = true; let shouldAutoScroll = true;
let lines: TerminalLine[] = []; let lines: TerminalLine[] = [];
let currentSearchQuery = "";
searchQuery.subscribe((value) => {
currentSearchQuery = value;
});
claudeStore.terminalLines.subscribe((value) => { claudeStore.terminalLines.subscribe((value) => {
lines = value; lines = value;
@@ -64,6 +70,30 @@
minute: "2-digit", 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, '<mark class="search-highlight">$1</mark>');
}
$: {
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);
}
}
</script> </script>
<div <div
@@ -102,9 +132,10 @@
<span class="terminal-tool-name mr-2">[{line.toolName}]</span> <span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if} {/if}
{#if line.type === "assistant"} {#if line.type === "assistant"}
<Markdown content={line.content} /> <Markdown content={line.content} searchQuery={currentSearchQuery} />
{:else} {:else}
<span class="whitespace-pre-wrap">{line.content}</span> <!-- eslint-disable-next-line svelte/no-at-html-tags -- Search highlighting on internal terminal content -->
<span class="whitespace-pre-wrap">{@html highlightText(line.content, currentSearchQuery)}</span>
{/if} {/if}
</div> </div>
{/each} {/each}
@@ -162,4 +193,11 @@
.terminal-header-text { .terminal-header-text {
color: var(--text-secondary); 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;
}
</style> </style>
+68
View File
@@ -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<SearchState>(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);