feat: batch of fixes and features (#56)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
CI / Lint & Test (push) Successful in 14m14s
CI / Build Linux (push) Successful in 16m45s
CI / Build Windows (cross-compile) (push) Successful in 26m50s

## Summary

This PR includes a batch of bug fixes and new features:

### Bug Fixes
- **Links in chat history now open in default browser** instead of navigating within the app
  - Closes #54
- **Allow spaces in tab names** - space key no longer acts like enter when renaming tabs
  - Closes #52

### New Features
- **`/cd` command** - Change the working directory of an active tab with context preservation
  - Closes #55
- **`/search` command** - Search and highlight matches within the conversation
  - Closes #32

## Test Plan
- [ ] Click a link in chat history and verify it opens in the default browser
- [ ] Rename a tab and verify spaces can be typed
- [ ] Use `/cd <path>` and verify the directory changes while preserving conversation context
- [ ] Use `/search <query>` and verify matches are highlighted in yellow
- [ ] Use `/search` with no args to clear the search highlighting

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #56
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #56.
This commit is contained in:
2026-01-23 11:59:21 -08:00
committed by Naomi Carrigan
parent 947e56ef41
commit 94991796be
10 changed files with 369 additions and 5 deletions
@@ -126,6 +126,8 @@
} else if (event.key === "Escape") {
editingTabId = null;
editingName = "";
} else if (event.key === " ") {
event.stopPropagation();
}
}
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
export let content: string;
export let searchQuery: string;
interface TextPart {
text: string;
isMatch: boolean;
}
function getHighlightedParts(text: string, query: string): TextPart[] {
if (!query) {
return [{ text, isMatch: false }];
}
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
const parts: TextPart[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Add non-matching text before the match
if (match.index > lastIndex) {
parts.push({
text: text.slice(lastIndex, match.index),
isMatch: false,
});
}
// Add the matching text
parts.push({
text: match[1],
isMatch: true,
});
lastIndex = regex.lastIndex;
}
// Add any remaining text after the last match
if (lastIndex < text.length) {
parts.push({
text: text.slice(lastIndex),
isMatch: false,
});
}
return parts;
}
$: parts = getHighlightedParts(content, searchQuery);
</script>
<span class="whitespace-pre-wrap">
{#each parts as part, index (index)}
{#if part.isMatch}
<mark class="search-highlight">{part.text}</mark>
{:else}
{part.text}
{/if}
{/each}
</span>
+61 -3
View File
@@ -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, '<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 {
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 @@
<div
bind:this={containerElement}
class="markdown-content"
onclick={handleSpoilerClick}
onclick={(e) => {
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;
}
</style>
+33 -2
View File
@@ -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);
}
}
</script>
<div
@@ -102,9 +126,9 @@
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant"}
<Markdown content={line.content} />
<Markdown content={line.content} searchQuery={currentSearchQuery} />
{:else}
<span class="whitespace-pre-wrap">{line.content}</span>
<HighlightedText content={line.content} searchQuery={currentSearchQuery} />
{/if}
</div>
{/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;
}
</style>