feat: add clipboard history for code snippets

Implements issue #25 - Clipboard History feature that tracks copied
code snippets with language detection, search, and filtering.

Backend (Rust):
- New clipboard.rs module with persistent storage via tauri-plugin-store
- Commands: capture, list, delete, toggle pin, clear, search, update language
- Auto-deduplication and max history size (100 entries)
- Pinned entries stay at top and persist through clear

Frontend (Svelte/TypeScript):
- Clipboard store with filtering, search, and language detection
- ClipboardHistoryPanel component with search, language filter, pin/delete
- Clipboard button added to InputBar next to Snippets/Actions
- Auto-capture from code block copy buttons
- Auto-capture from manual text selection in terminal
- Insert snippets directly into input field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-25 17:40:03 -08:00
parent c34720346e
commit 0a73d2238c
8 changed files with 1086 additions and 8 deletions
@@ -0,0 +1,497 @@
<script lang="ts">
import { onMount } from "svelte";
import { clipboardStore, type ClipboardEntry } from "$lib/stores/clipboard";
export let isOpen = false;
export let onClose: () => void;
export let onInsert: (content: string) => void = () => {};
let searchQuery = "";
let selectedLanguage: string | null = null;
let confirmingDeleteId: string | null = null;
let copiedId: string | null = null;
// Subscribe to derived stores
const filteredEntries = clipboardStore.filteredEntries;
const languagesStore = clipboardStore.languages;
const isLoadingStore = clipboardStore.isLoading;
$: entries = $filteredEntries;
$: languages = $languagesStore;
$: isLoading = $isLoadingStore;
onMount(() => {
if (isOpen) {
clipboardStore.loadEntries();
}
});
$: if (isOpen) {
clipboardStore.loadEntries();
}
function handleSearch() {
clipboardStore.setSearchQuery(searchQuery);
}
function handleLanguageFilter(lang: string | null) {
selectedLanguage = lang;
clipboardStore.setLanguageFilter(lang);
}
async function handleCopy(entry: ClipboardEntry) {
const success = await clipboardStore.copyToClipboard(entry.content);
if (success) {
copiedId = entry.id;
setTimeout(() => {
copiedId = null;
}, 2000);
}
}
function handleInsert(entry: ClipboardEntry) {
onInsert(entry.content);
onClose();
}
async function handleDelete(id: string) {
await clipboardStore.deleteEntry(id);
confirmingDeleteId = null;
}
async function handleTogglePin(id: string) {
await clipboardStore.togglePin(id);
}
async function handleClearHistory() {
if (confirm("Clear all non-pinned clipboard entries?")) {
await clipboardStore.clearHistory();
}
}
function truncateContent(content: string, maxLength: number = 200): string {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + "...";
}
function getLanguageIcon(language: string | null): string {
const icons: Record<string, string> = {
typescript: "TS",
javascript: "JS",
python: "PY",
rust: "RS",
go: "GO",
java: "JV",
c: "C",
cpp: "C++",
csharp: "C#",
php: "PHP",
ruby: "RB",
swift: "SW",
kotlin: "KT",
sql: "SQL",
html: "HTML",
css: "CSS",
json: "JSON",
yaml: "YAML",
bash: "SH",
shell: "SH",
};
return language ? icons[language.toLowerCase()] || language.toUpperCase().slice(0, 3) : "TXT";
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="clipboard-overlay" on:click={onClose}>
<div class="clipboard-panel" on:click|stopPropagation>
<div class="clipboard-header">
<h2>📋 Clipboard History</h2>
<div class="header-actions">
{#if entries.length > 0}
<button
class="clear-btn"
on:click={handleClearHistory}
title="Clear non-pinned entries"
>
🗑️ Clear
</button>
{/if}
<button class="close-btn" on:click={onClose}>✕</button>
</div>
</div>
<div class="clipboard-controls">
<div class="search-box">
<input
type="text"
placeholder="Search clipboard..."
bind:value={searchQuery}
on:input={handleSearch}
/>
</div>
<div class="language-filter">
<button
class="filter-btn"
class:active={selectedLanguage === null}
on:click={() => handleLanguageFilter(null)}
>
All
</button>
{#each languages as lang (lang)}
<button
class="filter-btn"
class:active={selectedLanguage === lang}
on:click={() => handleLanguageFilter(lang)}
>
{getLanguageIcon(lang)}
</button>
{/each}
</div>
</div>
<div class="clipboard-content">
{#if isLoading}
<div class="loading">Loading...</div>
{:else if entries.length === 0}
<div class="empty-state">
<p>📭 No clipboard entries yet</p>
<p class="hint">
Copy code from Claude's responses or use the copy button on code blocks to save them
here.
</p>
</div>
{:else}
<div class="entries-list">
{#each entries as entry (entry.id)}
<div class="entry" class:pinned={entry.is_pinned}>
<div class="entry-header">
<div class="entry-meta">
<span class="language-badge">{getLanguageIcon(entry.language)}</span>
<span class="timestamp">{clipboardStore.formatTimestamp(entry.timestamp)}</span>
{#if entry.is_pinned}
<span class="pin-badge">📌</span>
{/if}
</div>
<div class="entry-actions">
<button
class="action-btn"
title={entry.is_pinned ? "Unpin" : "Pin"}
on:click={() => handleTogglePin(entry.id)}
>
{entry.is_pinned ? "📌" : "📍"}
</button>
<button
class="action-btn"
title="Copy to clipboard"
on:click={() => handleCopy(entry)}
>
{copiedId === entry.id ? "✓" : "📋"}
</button>
<button
class="action-btn insert-btn"
title="Insert"
on:click={() => handleInsert(entry)}
>
➡️
</button>
{#if confirmingDeleteId === entry.id}
<button
class="action-btn confirm-delete"
on:click={() => handleDelete(entry.id)}
>
</button>
<button class="action-btn" on:click={() => (confirmingDeleteId = null)}>
</button>
{:else}
<button
class="action-btn delete-btn"
title="Delete"
on:click={() => (confirmingDeleteId = entry.id)}
>
🗑️
</button>
{/if}
</div>
</div>
<pre class="entry-content"><code>{truncateContent(entry.content)}</code></pre>
{#if entry.source}
<div class="entry-source">From: {entry.source}</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.clipboard-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.clipboard-panel {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 700px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.clipboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.clipboard-header h2 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.clear-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.clear-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.close-btn {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
}
.close-btn:hover {
color: var(--text-primary);
}
.clipboard-controls {
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 12px;
}
.search-box input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
}
.search-box input:focus {
outline: none;
border-color: var(--trans-pink);
box-shadow: 0 0 0 2px rgba(245, 169, 184, 0.2);
}
.language-filter {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.filter-btn {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
}
.filter-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--trans-gradient-vibrant);
color: #1a1a2e;
border-color: transparent;
}
.clipboard-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.loading,
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.empty-state p {
margin: 8px 0;
}
.empty-state .hint {
font-size: 13px;
opacity: 0.7;
}
.entries-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.entry {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px;
transition: border-color 0.2s;
}
.entry:hover {
border-color: var(--trans-pink);
}
.entry.pinned {
border-color: var(--trans-blue);
background: linear-gradient(
135deg,
rgba(91, 206, 250, 0.05) 0%,
rgba(245, 169, 184, 0.05) 100%
);
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.entry-meta {
display: flex;
align-items: center;
gap: 8px;
}
.language-badge {
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
font-family: monospace;
}
.timestamp {
color: var(--text-tertiary);
font-size: 11px;
}
.pin-badge {
font-size: 12px;
}
.entry-actions {
display: flex;
gap: 4px;
}
.action-btn {
background: transparent;
border: none;
padding: 4px 6px;
cursor: pointer;
font-size: 14px;
opacity: 0.6;
transition: opacity 0.2s;
}
.action-btn:hover {
opacity: 1;
}
.insert-btn {
background: var(--trans-gradient-vibrant);
border-radius: 4px;
opacity: 1;
}
.delete-btn:hover {
color: #ff6b6b;
}
.confirm-delete {
color: #ff6b6b;
opacity: 1;
}
.entry-content {
margin: 0;
padding: 10px;
background: var(--bg-primary);
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.entry-content code {
font-family: "JetBrains Mono", "Fira Code", monospace;
color: var(--text-primary);
}
.entry-source {
margin-top: 8px;
font-size: 11px;
color: var(--text-tertiary);
font-style: italic;
}
</style>
+54 -4
View File
@@ -7,6 +7,7 @@
import { characterState } from "$lib/stores/character";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import { clipboardStore } from "$lib/stores/clipboard";
import {
setShouldRestoreHistory,
setSavedHistory,
@@ -27,6 +28,7 @@
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history";
@@ -43,6 +45,7 @@
let isDragging = $state(false);
let showSnippetLibrary = $state(false);
let showQuickActions = $state(false);
let showClipboardHistory = $state(false);
// Input history state
let inputHistory = $state<string[]>([]);
@@ -504,6 +507,15 @@ User: ${formattedMessage}`;
const items = event.clipboardData?.items;
let handledFile = false;
// Also capture text content to clipboard history
const textContent = event.clipboardData?.getData("text/plain");
if (textContent && textContent.trim().length > 0) {
// Only capture multi-line or longer text (likely code snippets)
if (textContent.includes("\n") || textContent.length > 50) {
clipboardStore.captureClipboard(textContent, null, "Pasted into chat");
}
}
if (items && items.length > 0) {
for (const item of items) {
if (item.kind === "file") {
@@ -631,6 +643,16 @@ User: ${formattedMessage}`;
userHasTyped = true;
}
function handleClipboardInsert(content: string): void {
// Insert clipboard content at cursor position or append to input
if (inputValue.trim()) {
inputValue = inputValue + "\n\n" + content;
} else {
inputValue = content;
}
userHasTyped = true;
}
async function handleQuickAction(prompt: string): Promise<void> {
// Quick actions send the prompt directly
if (!isConnected || isSubmitting) return;
@@ -787,6 +809,27 @@ User: ${formattedMessage}`;
</svg>
<span>Snippets</span>
</button>
<button
type="button"
onclick={() => (showClipboardHistory = true)}
class="control-button"
title="Clipboard History"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
<span>Clipboard</span>
</button>
</div>
<div class="input-row">
@@ -870,9 +913,14 @@ User: ${formattedMessage}`;
{/if}
{#if showQuickActions}
<QuickActionsPanel
onClose={() => (showQuickActions = false)}
onAction={handleQuickAction}
<QuickActionsPanel onClose={() => (showQuickActions = false)} onAction={handleQuickAction} />
{/if}
{#if showClipboardHistory}
<ClipboardHistoryPanel
isOpen={showClipboardHistory}
onClose={() => (showClipboardHistory = false)}
onInsert={handleClipboardInsert}
/>
{/if}
@@ -1043,6 +1091,8 @@ User: ${formattedMessage}`;
.trans-gradient-button:hover:not(:disabled) {
filter: brightness(1.1);
box-shadow: 0 0 20px rgba(91, 206, 250, 0.4), 0 0 30px rgba(245, 169, 184, 0.3);
box-shadow:
0 0 20px rgba(91, 206, 250, 0.4),
0 0 30px rgba(245, 169, 184, 0.3);
}
</style>
+7
View File
@@ -3,6 +3,7 @@
import hljs from "highlight.js";
import { onMount } from "svelte";
import { openUrl } from "@tauri-apps/plugin-opener";
import { clipboardStore } from "$lib/stores/clipboard";
interface Props {
content: string;
@@ -147,6 +148,12 @@
.replace(/&gt;/g, ">");
if (code) {
await navigator.clipboard.writeText(code);
// Capture to clipboard history
const langElement = copyBtn.parentElement?.querySelector(".code-block-lang");
const language = langElement?.textContent || null;
await clipboardStore.captureClipboard(code, language, "Claude response");
const textSpan = copyBtn.querySelector(".copy-text");
if (textSpan) {
textSpan.textContent = "Copied!";
+1 -3
View File
@@ -251,9 +251,7 @@
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 icon-trans-hover {showStats
? 'text-[var(--trans-pink)]'
: ''}"
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
title="Usage Stats"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+28 -1
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate, tick } from "svelte";
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
import { clipboardStore } from "$lib/stores/clipboard";
let terminalElement: HTMLDivElement;
let shouldAutoScroll = true;
@@ -122,6 +123,32 @@
searchState.setMatchCount(0);
}
}
// Handle manual text selection copy events
function handleCopy() {
const selection = window.getSelection();
const selectedText = selection?.toString();
if (selectedText && selectedText.trim().length > 0) {
// Only capture multi-line or longer text (likely code/meaningful content)
if (selectedText.includes("\n") || selectedText.length > 50) {
clipboardStore.captureClipboard(selectedText, null, "Copied from chat");
}
}
}
onMount(() => {
// Listen for copy events on the terminal
if (terminalElement) {
terminalElement.addEventListener("copy", handleCopy);
}
});
onDestroy(() => {
if (terminalElement) {
terminalElement.removeEventListener("copy", handleCopy);
}
});
</script>
<div
+230
View File
@@ -0,0 +1,230 @@
// Clipboard history store for managing copied code snippets
// Implements issue #25 - Clipboard History feature
import { invoke } from "@tauri-apps/api/core";
import { writable, derived } from "svelte/store";
export interface ClipboardEntry {
id: string;
content: string;
language: string | null;
source: string | null;
timestamp: string;
is_pinned: boolean;
}
// Create stores
const entriesStore = writable<ClipboardEntry[]>([]);
const searchQueryStore = writable<string>("");
const languageFilterStore = writable<string | null>(null);
const isLoadingStore = writable<boolean>(false);
// Derived store for filtered entries
const filteredEntriesStore = derived(
[entriesStore, searchQueryStore, languageFilterStore],
([$entries, $searchQuery, $languageFilter]) => {
let filtered = $entries;
// Filter by language
if ($languageFilter) {
filtered = filtered.filter((e) => e.language === $languageFilter);
}
// Filter by search query
if ($searchQuery) {
const query = $searchQuery.toLowerCase();
filtered = filtered.filter(
(e) =>
e.content.toLowerCase().includes(query) ||
e.language?.toLowerCase().includes(query) ||
e.source?.toLowerCase().includes(query)
);
}
return filtered;
}
);
// Derived store for unique languages
const languagesStore = derived(entriesStore, ($entries) => {
const languages = new Set<string>();
for (const entry of $entries) {
if (entry.language) {
languages.add(entry.language);
}
}
return Array.from(languages).sort();
});
// Helper function to detect language from content
function detectLanguage(content: string): string | null {
// Common language patterns
const patterns: [RegExp, string][] = [
[/^(import|export|const|let|var|function|class|interface|type)\s/m, "typescript"],
[/^(def|class|import|from|if __name__|async def)\s/m, "python"],
[/^(fn|let|mut|impl|struct|enum|use|mod|pub)\s/m, "rust"],
[/^(package|import|func|type|var|const)\s/m, "go"],
[/<\?php/m, "php"],
[/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/im, "sql"],
[/^<!DOCTYPE|^<html|^<div|^<span/im, "html"],
[/^\s*\{[\s\S]*"[\w-]+":/m, "json"],
[/^---\s*\n/m, "yaml"],
[/^\s*#\s*(include|define|ifdef)/m, "c"],
[/^(public|private|protected)\s+(class|interface|static)/m, "java"],
[/^\$[\w_]+\s*=/m, "bash"],
];
for (const [pattern, lang] of patterns) {
if (pattern.test(content)) {
return lang;
}
}
return null;
}
// Store actions
async function loadEntries(): Promise<void> {
isLoadingStore.set(true);
try {
const entries = await invoke<ClipboardEntry[]>("list_clipboard_entries", {
language: null,
});
entriesStore.set(entries);
} catch (error) {
console.error("Failed to load clipboard entries:", error);
} finally {
isLoadingStore.set(false);
}
}
async function captureClipboard(
content: string,
language?: string | null,
source?: string | null
): Promise<ClipboardEntry | null> {
try {
// Auto-detect language if not provided
const detectedLanguage = language ?? detectLanguage(content);
const entry = await invoke<ClipboardEntry>("capture_clipboard", {
content,
language: detectedLanguage,
source: source ?? null,
});
// Reload entries to get the updated list
await loadEntries();
return entry;
} catch (error) {
console.error("Failed to capture clipboard:", error);
return null;
}
}
async function deleteEntry(id: string): Promise<void> {
try {
await invoke("delete_clipboard_entry", { id });
entriesStore.update((entries) => entries.filter((e) => e.id !== id));
} catch (error) {
console.error("Failed to delete clipboard entry:", error);
}
}
async function togglePin(id: string): Promise<void> {
try {
const updated = await invoke<ClipboardEntry>("toggle_pin_clipboard_entry", { id });
entriesStore.update((entries) =>
entries
.map((e) => (e.id === id ? updated : e))
.sort((a, b) => {
// Pinned first, then by timestamp
if (a.is_pinned && !b.is_pinned) return -1;
if (!a.is_pinned && b.is_pinned) return 1;
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
})
);
} catch (error) {
console.error("Failed to toggle pin:", error);
}
}
async function clearHistory(): Promise<void> {
try {
await invoke("clear_clipboard_history");
entriesStore.update((entries) => entries.filter((e) => e.is_pinned));
} catch (error) {
console.error("Failed to clear clipboard history:", error);
}
}
async function updateLanguage(id: string, language: string | null): Promise<void> {
try {
const updated = await invoke<ClipboardEntry>("update_clipboard_language", {
id,
language,
});
entriesStore.update((entries) => entries.map((e) => (e.id === id ? updated : e)));
} catch (error) {
console.error("Failed to update language:", error);
}
}
function setSearchQuery(query: string): void {
searchQueryStore.set(query);
}
function setLanguageFilter(language: string | null): void {
languageFilterStore.set(language);
}
// Copy entry content to clipboard
async function copyToClipboard(content: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(content);
return true;
} catch (error) {
console.error("Failed to copy to clipboard:", error);
return false;
}
}
// Format timestamp for display
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
// Export the store
export const clipboardStore = {
subscribe: entriesStore.subscribe,
entries: entriesStore,
filteredEntries: filteredEntriesStore,
languages: languagesStore,
searchQuery: searchQueryStore,
languageFilter: languageFilterStore,
isLoading: isLoadingStore,
loadEntries,
captureClipboard,
deleteEntry,
togglePin,
clearHistory,
updateLanguage,
setSearchQuery,
setLanguageFilter,
copyToClipboard,
formatTimestamp,
detectLanguage,
};