@@ -235,6 +263,158 @@
.character-panel {
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
+ transition: all 0.5s ease;
+ position: relative;
+ }
+
+ .character-panel::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ padding: 3px;
+ background: transparent;
+ -webkit-mask:
+ linear-gradient(#fff 0 0) content-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ opacity: 0;
+ transition: opacity 0.5s ease;
+ pointer-events: none;
+ }
+
+ /* Trans pride gradient glow effects for the character panel */
+ .panel-glow-thinking {
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--bg-secondary) 85%, #9333ea) 0%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
+ );
+ box-shadow:
+ inset 0 0 60px rgba(147, 51, 234, 0.15),
+ inset 0 0 100px rgba(91, 206, 250, 0.1),
+ 0 0 40px rgba(91, 206, 250, 0.2),
+ 0 0 80px rgba(245, 169, 184, 0.15);
+ }
+
+ .panel-glow-thinking::before {
+ background: linear-gradient(180deg, #9333ea, var(--trans-blue), var(--trans-pink));
+ opacity: 1;
+ }
+
+ .panel-glow-typing {
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--bg-secondary) 85%, #3b82f6) 0%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
+ );
+ box-shadow:
+ inset 0 0 60px rgba(59, 130, 246, 0.15),
+ inset 0 0 100px rgba(91, 206, 250, 0.15),
+ 0 0 40px rgba(91, 206, 250, 0.25),
+ 0 0 80px rgba(245, 169, 184, 0.15);
+ }
+
+ .panel-glow-typing::before {
+ background: linear-gradient(180deg, #3b82f6, var(--trans-blue), var(--trans-pink));
+ opacity: 1;
+ }
+
+ .panel-glow-searching {
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--bg-secondary) 85%, #eab308) 0%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
+ );
+ box-shadow:
+ inset 0 0 60px rgba(234, 179, 8, 0.15),
+ inset 0 0 100px rgba(91, 206, 250, 0.1),
+ 0 0 40px rgba(91, 206, 250, 0.2),
+ 0 0 80px rgba(245, 169, 184, 0.15);
+ }
+
+ .panel-glow-searching::before {
+ background: linear-gradient(180deg, #eab308, var(--trans-blue), var(--trans-pink));
+ opacity: 1;
+ }
+
+ .panel-glow-coding {
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--bg-secondary) 85%, #22c55e) 0%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
+ );
+ box-shadow:
+ inset 0 0 60px rgba(34, 197, 94, 0.15),
+ inset 0 0 100px rgba(91, 206, 250, 0.1),
+ 0 0 40px rgba(91, 206, 250, 0.2),
+ 0 0 80px rgba(245, 169, 184, 0.15);
+ }
+
+ .panel-glow-coding::before {
+ background: linear-gradient(180deg, #22c55e, var(--trans-blue), var(--trans-pink));
+ opacity: 1;
+ }
+
+ .panel-glow-mcp {
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--bg-secondary) 80%, var(--trans-blue)) 0%,
+ color-mix(in srgb, var(--bg-primary) 85%, var(--trans-pink)) 50%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 100%
+ );
+ box-shadow:
+ inset 0 0 80px rgba(91, 206, 250, 0.2),
+ inset 0 0 120px rgba(245, 169, 184, 0.15),
+ 0 0 60px rgba(91, 206, 250, 0.3),
+ 0 0 100px rgba(245, 169, 184, 0.2);
+ }
+
+ .panel-glow-mcp::before {
+ background: var(--trans-gradient-vibrant);
+ opacity: 1;
+ }
+
+ .panel-glow-success {
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--bg-secondary) 85%, #10b981) 0%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-blue)) 50%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 100%
+ );
+ box-shadow:
+ inset 0 0 60px rgba(16, 185, 129, 0.15),
+ inset 0 0 100px rgba(91, 206, 250, 0.1),
+ 0 0 40px rgba(91, 206, 250, 0.2),
+ 0 0 80px rgba(245, 169, 184, 0.15);
+ }
+
+ .panel-glow-success::before {
+ background: linear-gradient(180deg, #10b981, var(--trans-blue), var(--trans-pink));
+ opacity: 1;
+ }
+
+ .panel-glow-error {
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--bg-secondary) 80%, #ef4444) 0%,
+ color-mix(in srgb, var(--bg-primary) 90%, var(--trans-pink)) 50%,
+ color-mix(in srgb, var(--bg-primary) 95%, var(--trans-blue)) 100%
+ );
+ box-shadow:
+ inset 0 0 60px rgba(239, 68, 68, 0.2),
+ inset 0 0 100px rgba(245, 169, 184, 0.1),
+ 0 0 40px rgba(245, 169, 184, 0.2),
+ 0 0 80px rgba(239, 68, 68, 0.15);
+ }
+
+ .panel-glow-error::before {
+ background: linear-gradient(180deg, #ef4444, var(--trans-pink), var(--trans-blue));
+ opacity: 1;
}
.resize-handle:hover,
--
2.52.0
From 0a73d2238c4d66e0af2169e87d4d561f3f1686ff Mon Sep 17 00:00:00 2001
From: Naomi Carrigan
Date: Sun, 25 Jan 2026 17:40:03 -0800
Subject: [PATCH 11/21] 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
---
src-tauri/src/clipboard.rs | 259 +++++++++
src-tauri/src/lib.rs | 10 +
.../components/ClipboardHistoryPanel.svelte | 497 ++++++++++++++++++
src/lib/components/InputBar.svelte | 58 +-
src/lib/components/Markdown.svelte | 7 +
src/lib/components/StatusBar.svelte | 4 +-
src/lib/components/Terminal.svelte | 29 +-
src/lib/stores/clipboard.ts | 230 ++++++++
8 files changed, 1086 insertions(+), 8 deletions(-)
create mode 100644 src-tauri/src/clipboard.rs
create mode 100644 src/lib/components/ClipboardHistoryPanel.svelte
create mode 100644 src/lib/stores/clipboard.ts
diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs
new file mode 100644
index 0000000..058d9ac
--- /dev/null
+++ b/src-tauri/src/clipboard.rs
@@ -0,0 +1,259 @@
+// Clipboard history module for tracking and managing copied code snippets
+// Implements issue #25 - Clipboard History feature
+
+use serde::{Deserialize, Serialize};
+use std::sync::Mutex;
+use tauri_plugin_store::StoreExt;
+use uuid::Uuid;
+
+const STORE_FILE: &str = "hikari-clipboard.json";
+const HISTORY_KEY: &str = "clipboard_history";
+const MAX_HISTORY_SIZE: usize = 100;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ClipboardEntry {
+ pub id: String,
+ pub content: String,
+ pub language: Option,
+ pub source: Option,
+ pub timestamp: String,
+ pub is_pinned: bool,
+}
+
+impl ClipboardEntry {
+ pub fn new(content: String, language: Option, source: Option) -> Self {
+ Self {
+ id: Uuid::new_v4().to_string(),
+ content,
+ language,
+ source,
+ timestamp: chrono::Utc::now().to_rfc3339(),
+ is_pinned: false,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+struct ClipboardHistory {
+ entries: Vec,
+}
+
+// Track last clipboard content to avoid duplicates
+#[derive(Default)]
+struct ClipboardState {
+ last_content: Option,
+}
+
+static CLIPBOARD_STATE: Mutex = Mutex::new(ClipboardState { last_content: None });
+
+fn load_history(app: &tauri::AppHandle) -> ClipboardHistory {
+ let store = app.store(STORE_FILE).ok();
+ store
+ .and_then(|s| s.get(HISTORY_KEY))
+ .and_then(|v| serde_json::from_value(v.clone()).ok())
+ .unwrap_or_default()
+}
+
+fn save_history(app: &tauri::AppHandle, history: &ClipboardHistory) -> Result<(), String> {
+ let store = app.store(STORE_FILE).map_err(|e| e.to_string())?;
+ store.set(
+ HISTORY_KEY,
+ serde_json::to_value(history).map_err(|e| e.to_string())?,
+ );
+ store.save().map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+/// List all clipboard entries, optionally filtered by language
+#[tauri::command]
+pub fn list_clipboard_entries(
+ app: tauri::AppHandle,
+ language: Option,
+) -> Result, String> {
+ let history = load_history(&app);
+ let entries = if let Some(lang) = language {
+ history
+ .entries
+ .into_iter()
+ .filter(|e| e.language.as_ref() == Some(&lang))
+ .collect()
+ } else {
+ history.entries
+ };
+ Ok(entries)
+}
+
+/// Capture current clipboard content and add to history
+#[tauri::command]
+pub fn capture_clipboard(
+ app: tauri::AppHandle,
+ content: String,
+ language: Option,
+ source: Option,
+) -> Result {
+ // Check for duplicate (same content as last capture)
+ {
+ let mut state = CLIPBOARD_STATE.lock().map_err(|e| e.to_string())?;
+ if state.last_content.as_ref() == Some(&content) {
+ // Return existing entry if content is the same
+ let history = load_history(&app);
+ if let Some(entry) = history.entries.first() {
+ if entry.content == content {
+ return Ok(entry.clone());
+ }
+ }
+ }
+ state.last_content = Some(content.clone());
+ }
+
+ let entry = ClipboardEntry::new(content, language, source);
+ let mut history = load_history(&app);
+
+ // Add to front of history
+ history.entries.insert(0, entry.clone());
+
+ // Enforce max size (keep pinned entries)
+ let mut pinned: Vec = history
+ .entries
+ .iter()
+ .filter(|e| e.is_pinned)
+ .cloned()
+ .collect();
+ let mut unpinned: Vec = history
+ .entries
+ .into_iter()
+ .filter(|e| !e.is_pinned)
+ .collect();
+
+ // Trim unpinned entries if over max size
+ if unpinned.len() + pinned.len() > MAX_HISTORY_SIZE {
+ let max_unpinned = MAX_HISTORY_SIZE.saturating_sub(pinned.len());
+ unpinned.truncate(max_unpinned);
+ }
+
+ // Merge back, pinned first then unpinned
+ pinned.extend(unpinned);
+ history.entries = pinned;
+
+ // Sort by timestamp descending (newest first), pinned entries stay at top
+ history.entries.sort_by(|a, b| {
+ if a.is_pinned && !b.is_pinned {
+ std::cmp::Ordering::Less
+ } else if !a.is_pinned && b.is_pinned {
+ std::cmp::Ordering::Greater
+ } else {
+ b.timestamp.cmp(&a.timestamp)
+ }
+ });
+
+ save_history(&app, &history)?;
+ Ok(entry)
+}
+
+/// Delete a clipboard entry by ID
+#[tauri::command]
+pub fn delete_clipboard_entry(app: tauri::AppHandle, id: String) -> Result<(), String> {
+ let mut history = load_history(&app);
+ history.entries.retain(|e| e.id != id);
+ save_history(&app, &history)?;
+ Ok(())
+}
+
+/// Toggle pin status of an entry
+#[tauri::command]
+pub fn toggle_pin_clipboard_entry(
+ app: tauri::AppHandle,
+ id: String,
+) -> Result {
+ let mut history = load_history(&app);
+ let entry = history
+ .entries
+ .iter_mut()
+ .find(|e| e.id == id)
+ .ok_or("Entry not found")?;
+
+ entry.is_pinned = !entry.is_pinned;
+ let updated_entry = entry.clone();
+
+ // Re-sort to move pinned entries to top
+ history.entries.sort_by(|a, b| {
+ if a.is_pinned && !b.is_pinned {
+ std::cmp::Ordering::Less
+ } else if !a.is_pinned && b.is_pinned {
+ std::cmp::Ordering::Greater
+ } else {
+ b.timestamp.cmp(&a.timestamp)
+ }
+ });
+
+ save_history(&app, &history)?;
+ Ok(updated_entry)
+}
+
+/// Clear all non-pinned entries
+#[tauri::command]
+pub fn clear_clipboard_history(app: tauri::AppHandle) -> Result<(), String> {
+ let mut history = load_history(&app);
+ history.entries.retain(|e| e.is_pinned);
+ save_history(&app, &history)?;
+ Ok(())
+}
+
+/// Search clipboard entries by content
+#[tauri::command]
+pub fn search_clipboard_entries(
+ app: tauri::AppHandle,
+ query: String,
+) -> Result, String> {
+ let history = load_history(&app);
+ let query_lower = query.to_lowercase();
+ let entries = history
+ .entries
+ .into_iter()
+ .filter(|e| {
+ e.content.to_lowercase().contains(&query_lower)
+ || e.language
+ .as_ref()
+ .is_some_and(|l| l.to_lowercase().contains(&query_lower))
+ || e.source
+ .as_ref()
+ .is_some_and(|s| s.to_lowercase().contains(&query_lower))
+ })
+ .collect();
+ Ok(entries)
+}
+
+/// Get all unique languages from history
+#[tauri::command]
+pub fn get_clipboard_languages(app: tauri::AppHandle) -> Result, String> {
+ let history = load_history(&app);
+ let mut languages: Vec = history
+ .entries
+ .iter()
+ .filter_map(|e| e.language.clone())
+ .collect();
+ languages.sort();
+ languages.dedup();
+ Ok(languages)
+}
+
+/// Update the language of an entry
+#[tauri::command]
+pub fn update_clipboard_language(
+ app: tauri::AppHandle,
+ id: String,
+ language: Option,
+) -> Result {
+ let mut history = load_history(&app);
+ let entry = history
+ .entries
+ .iter_mut()
+ .find(|e| e.id == id)
+ .ok_or("Entry not found")?;
+
+ entry.language = language;
+ let updated_entry = entry.clone();
+
+ save_history(&app, &history)?;
+ Ok(updated_entry)
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index fc3e0b3..e7b4f98 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,5 +1,6 @@
mod achievements;
mod bridge_manager;
+mod clipboard;
mod commands;
mod config;
mod git;
@@ -17,6 +18,7 @@ mod wsl_bridge;
mod wsl_notifications;
use bridge_manager::create_shared_bridge_manager;
+use clipboard::*;
use commands::load_saved_achievements;
use commands::*;
use git::*;
@@ -140,6 +142,14 @@ pub fn run() {
git_log,
git_discard,
git_create_branch,
+ list_clipboard_entries,
+ capture_clipboard,
+ delete_clipboard_entry,
+ toggle_pin_clipboard_entry,
+ clear_clipboard_history,
+ search_clipboard_entries,
+ get_clipboard_languages,
+ update_clipboard_language,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src/lib/components/ClipboardHistoryPanel.svelte b/src/lib/components/ClipboardHistoryPanel.svelte
new file mode 100644
index 0000000..ddbfa3c
--- /dev/null
+++ b/src/lib/components/ClipboardHistoryPanel.svelte
@@ -0,0 +1,497 @@
+
+
+{#if isOpen}
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each languages as lang (lang)}
+
+ {/each}
+
+
+
+
+ {#if isLoading}
+
Loading...
+ {:else if entries.length === 0}
+
+
📭 No clipboard entries yet
+
+ Copy code from Claude's responses or use the copy button on code blocks to save them
+ here.
+
+
+ {:else}
+
+ {#each entries as entry (entry.id)}
+
+
+
{truncateContent(entry.content)}
+ {#if entry.source}
+
From: {entry.source}
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+
+{/if}
+
+
diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte
index 346f826..b8f82cc 100644
--- a/src/lib/components/InputBar.svelte
+++ b/src/lib/components/InputBar.svelte
@@ -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([]);
@@ -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 {
// Quick actions send the prompt directly
if (!isConnected || isSubmitting) return;
@@ -787,6 +809,27 @@ User: ${formattedMessage}`;
Snippets
+