generated from nhcarrigan/template
feat: add /search command to highlight matches in conversation
Closes #32
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user