Compare commits

...

10 Commits

Author SHA1 Message Date
naomi 0ac52c8c8d style: apply linter formatting and eslint-disable comments
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 55s
CI / Lint & Test (pull_request) Successful in 14m40s
CI / Build Linux (pull_request) Successful in 19m9s
CI / Build Windows (cross-compile) (pull_request) Successful in 28m34s
- Format multiline CSS gradients for readability
- Clean up whitespace in Svelte components
- Add eslint-disable comments for controlled @html directives
- Format function signatures consistently

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 17:41:20 -08:00
naomi 0a73d2238c 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>
2026-01-25 17:40:03 -08:00
naomi c34720346e feat: apply trans-pride gradient theme across UI elements
- Add global CSS classes for trans gradient styling (btn-trans-gradient,
  input-trans-focus, icon-trans-hover)
- Update all primary action buttons to use trans gradient
- Add trans-pride glow effect to character panel based on state
- Update StatusBar icon buttons with trans gradient hover
- Update chat input focus border with trans gradient glow
- Update stop button to use trans gradient
2026-01-25 17:07:32 -08:00
hikari 02cc8bd0d5 feat: add quick actions panel for common prompts
- Add Rust backend for managing quick actions with persistent storage
- Create QuickActionsPanel component with edit/delete functionality
- Add quickActions store for frontend state management
- Move Actions and Snippets buttons to input controls row
- Include 6 default quick actions: Review PR, Run Tests, Explain File,
  Fix Error, Write Tests, and Refactor
- Support custom quick action creation and management

Closes #15
2026-01-25 16:25:59 -08:00
hikari 87cf633564 feat: add git integration panel
- Add Rust backend for git operations (status, diff, branches, commit, push, pull, fetch, stage, unstage, discard, create branch)
- Add GitPanel.svelte component with three tabs: Changes, Branches, History
- Integrate git button into StatusBar with link icon
- Use consistent accent color theming for buttons
2026-01-25 16:04:09 -08:00
naomi a30b3a282c feat: add session import/export functionality
- Add HTML export with dark/light theme support and styled message types
- Add PDF export via print-optimized HTML opened in browser
- Include metadata toggle option for all export formats
- Add export buttons to SessionHistoryPanel dropdown

Closes #34
2026-01-25 15:28:06 -08:00
hikari 02987f1a17 feat: add snippet library for prompt templates
- Add Rust backend with persistent storage for snippets
- Include 8 default snippets across categories (Code Review, Debugging, Testing, etc.)
- Create SnippetLibraryPanel component with category filtering
- Support create/edit/delete operations for custom snippets
- Default snippets can be edited but not deleted
- Add snippet button to InputBar for quick access

Closes #22
2026-01-25 15:15:52 -08:00
naomi 4a8a1d564a feat: add session import/export functionality
- Add JSON export with full session metadata for backup/restore
- Add Markdown export with formatted conversation history
- Add import capability for previously exported sessions
- Add tauri-plugin-fs for file system operations
- Add export dropdown menu and import button to session history panel
2026-01-25 15:00:52 -08:00
hikari 89cc655fd1 feat: add copy button to code blocks
Adds a convenient copy-to-clipboard button on all code snippets in
markdown output. Each code block now displays:
- Language label in the header
- Copy button with visual feedback ("Copied!" for 2 seconds)

Refs #68
2026-01-25 14:47:24 -08:00
hikari ce97c51cd8 feat: add session history with auto-save
- Add Rust backend for session persistence using tauri-plugin-store
- Create SessionHistoryPanel component for browsing saved sessions
- Implement debounced auto-save (2s delay) when messages are added
- Add session search, resume, and delete functionality
- Add clock icon button in StatusBar to access session history

Closes #14
2026-01-25 14:10:05 -08:00
31 changed files with 6074 additions and 102 deletions
+1
View File
@@ -26,6 +26,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
+10
View File
@@ -17,6 +17,9 @@ importers:
'@tauri-apps/plugin-dialog':
specifier: ^2
version: 2.6.0
'@tauri-apps/plugin-fs':
specifier: ^2.4.5
version: 2.4.5
'@tauri-apps/plugin-notification':
specifier: ^2
version: 2.3.3
@@ -744,6 +747,9 @@ packages:
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
'@tauri-apps/plugin-fs@2.4.5':
resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
'@tauri-apps/plugin-notification@2.3.3':
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
@@ -2300,6 +2306,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-fs@2.4.5':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-notification@2.3.3':
dependencies:
'@tauri-apps/api': 2.9.1
+1
View File
@@ -1613,6 +1613,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-notification",
"tauri-plugin-opener",
+1
View File
@@ -27,6 +27,7 @@ tauri-plugin-notification = "2"
tauri-plugin-os = "2"
tauri-plugin-http = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-fs = "2"
tempfile = "3"
semver = "1"
chrono = { version = "0.4.43", features = ["serde"] }
+4 -1
View File
@@ -16,6 +16,9 @@
"notification:allow-notify",
"clipboard-manager:default",
"clipboard-manager:allow-read-image",
"core:tray:default"
"core:tray:default",
"fs:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file"
]
}
+259
View File
@@ -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<String>,
pub source: Option<String>,
pub timestamp: String,
pub is_pinned: bool,
}
impl ClipboardEntry {
pub fn new(content: String, language: Option<String>, source: Option<String>) -> 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<ClipboardEntry>,
}
// Track last clipboard content to avoid duplicates
#[derive(Default)]
struct ClipboardState {
last_content: Option<String>,
}
static CLIPBOARD_STATE: Mutex<ClipboardState> = 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<String>,
) -> Result<Vec<ClipboardEntry>, 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<String>,
source: Option<String>,
) -> Result<ClipboardEntry, String> {
// 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<ClipboardEntry> = history
.entries
.iter()
.filter(|e| e.is_pinned)
.cloned()
.collect();
let mut unpinned: Vec<ClipboardEntry> = 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<ClipboardEntry, String> {
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<Vec<ClipboardEntry>, 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<Vec<String>, String> {
let history = load_history(&app);
let mut languages: Vec<String> = 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<String>,
) -> Result<ClipboardEntry, String> {
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)
}
+288
View File
@@ -0,0 +1,288 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitStatus {
pub is_repo: bool,
pub branch: Option<String>,
pub upstream: Option<String>,
pub ahead: u32,
pub behind: u32,
pub staged: Vec<GitFileChange>,
pub unstaged: Vec<GitFileChange>,
pub untracked: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitFileChange {
pub path: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitBranch {
pub name: String,
pub is_current: bool,
pub is_remote: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitLogEntry {
pub hash: String,
pub short_hash: String,
pub author: String,
pub date: String,
pub message: String,
}
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.args(args)
.current_dir(working_dir)
.output()
.map_err(|e| format!("Failed to execute git: {}", e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[tauri::command]
pub fn git_status(working_dir: String) -> Result<GitStatus, String> {
// Check if it's a git repo
let is_repo = run_git_command(&working_dir, &["rev-parse", "--git-dir"]).is_ok();
if !is_repo {
return Ok(GitStatus {
is_repo: false,
branch: None,
upstream: None,
ahead: 0,
behind: 0,
staged: vec![],
unstaged: vec![],
untracked: vec![],
});
}
// Get current branch
let branch = run_git_command(&working_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
.ok()
.map(|s| s.trim().to_string());
// Get upstream branch
let upstream = run_git_command(
&working_dir,
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
)
.ok()
.map(|s| s.trim().to_string());
// Get ahead/behind counts
let (ahead, behind) = if upstream.is_some() {
let rev_list =
run_git_command(&working_dir, &["rev-list", "--left-right", "--count", "@{u}...HEAD"])
.unwrap_or_default();
let parts: Vec<&str> = rev_list.trim().split('\t').collect();
if parts.len() == 2 {
(
parts[1].parse().unwrap_or(0),
parts[0].parse().unwrap_or(0),
)
} else {
(0, 0)
}
} else {
(0, 0)
};
// Get status with porcelain format
let status_output =
run_git_command(&working_dir, &["status", "--porcelain=v1"]).unwrap_or_default();
let mut staged = vec![];
let mut unstaged = vec![];
let mut untracked = vec![];
for line in status_output.lines() {
if line.len() < 3 {
continue;
}
let index_status = line.chars().next().unwrap_or(' ');
let worktree_status = line.chars().nth(1).unwrap_or(' ');
let path = line[3..].to_string();
// Untracked files
if index_status == '?' && worktree_status == '?' {
untracked.push(path);
continue;
}
// Staged changes (index status)
if index_status != ' ' && index_status != '?' {
staged.push(GitFileChange {
path: path.clone(),
status: match index_status {
'M' => "modified".to_string(),
'A' => "added".to_string(),
'D' => "deleted".to_string(),
'R' => "renamed".to_string(),
'C' => "copied".to_string(),
_ => "unknown".to_string(),
},
});
}
// Unstaged changes (worktree status)
if worktree_status != ' ' && worktree_status != '?' {
unstaged.push(GitFileChange {
path,
status: match worktree_status {
'M' => "modified".to_string(),
'D' => "deleted".to_string(),
_ => "unknown".to_string(),
},
});
}
}
Ok(GitStatus {
is_repo: true,
branch,
upstream,
ahead,
behind,
staged,
unstaged,
untracked,
})
}
#[tauri::command]
pub fn git_diff(working_dir: String, file_path: Option<String>, staged: bool) -> Result<String, String> {
let mut args = vec!["diff"];
if staged {
args.push("--cached");
}
if let Some(ref path) = file_path {
args.push("--");
args.push(path);
}
run_git_command(&working_dir, &args)
}
#[tauri::command]
pub fn git_branches(working_dir: String) -> Result<Vec<GitBranch>, String> {
let output = run_git_command(&working_dir, &["branch", "-a", "--format=%(refname:short)\t%(HEAD)"])?;
let branches: Vec<GitBranch> = output
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.is_empty() {
return None;
}
let name = parts[0].to_string();
let is_current = parts.get(1).map(|s| *s == "*").unwrap_or(false);
let is_remote = name.starts_with("remotes/") || name.starts_with("origin/");
Some(GitBranch {
name,
is_current,
is_remote,
})
})
.collect();
Ok(branches)
}
#[tauri::command]
pub fn git_checkout(working_dir: String, branch: String) -> Result<String, String> {
run_git_command(&working_dir, &["checkout", &branch])
}
#[tauri::command]
pub fn git_stage(working_dir: String, file_path: String) -> Result<String, String> {
run_git_command(&working_dir, &["add", &file_path])
}
#[tauri::command]
pub fn git_unstage(working_dir: String, file_path: String) -> Result<String, String> {
run_git_command(&working_dir, &["restore", "--staged", &file_path])
}
#[tauri::command]
pub fn git_stage_all(working_dir: String) -> Result<String, String> {
run_git_command(&working_dir, &["add", "-A"])
}
#[tauri::command]
pub fn git_commit(working_dir: String, message: String) -> Result<String, String> {
run_git_command(&working_dir, &["commit", "-m", &message])
}
#[tauri::command]
pub fn git_push(working_dir: String) -> Result<String, String> {
run_git_command(&working_dir, &["push"])
}
#[tauri::command]
pub fn git_pull(working_dir: String) -> Result<String, String> {
run_git_command(&working_dir, &["pull"])
}
#[tauri::command]
pub fn git_fetch(working_dir: String) -> Result<String, String> {
run_git_command(&working_dir, &["fetch", "--all"])
}
#[tauri::command]
pub fn git_log(working_dir: String, limit: Option<u32>) -> Result<Vec<GitLogEntry>, String> {
let limit_str = limit.unwrap_or(10).to_string();
let output = run_git_command(
&working_dir,
&[
"log",
&format!("-{}", limit_str),
"--pretty=format:%H\t%h\t%an\t%ar\t%s",
],
)?;
let entries: Vec<GitLogEntry> = output
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 5 {
return None;
}
Some(GitLogEntry {
hash: parts[0].to_string(),
short_hash: parts[1].to_string(),
author: parts[2].to_string(),
date: parts[3].to_string(),
message: parts[4..].join("\t"),
})
})
.collect();
Ok(entries)
}
#[tauri::command]
pub fn git_discard(working_dir: String, file_path: String) -> Result<String, String> {
run_git_command(&working_dir, &["checkout", "--", &file_path])
}
#[tauri::command]
pub fn git_create_branch(working_dir: String, branch_name: String) -> Result<String, String> {
run_git_command(&working_dir, &["checkout", "-b", &branch_name])
}
+48
View File
@@ -1,8 +1,13 @@
mod achievements;
mod bridge_manager;
mod clipboard;
mod commands;
mod config;
mod git;
mod notifications;
mod quick_actions;
mod sessions;
mod snippets;
mod stats;
mod temp_manager;
mod tray;
@@ -13,9 +18,14 @@ mod wsl_bridge;
mod wsl_notifications;
use bridge_manager::create_shared_bridge_manager;
use clipboard::*;
use commands::load_saved_achievements;
use commands::*;
use git::*;
use notifications::*;
use quick_actions::*;
use sessions::*;
use snippets::*;
use tauri::Manager;
use temp_manager::create_shared_temp_manager;
use tray::{setup_tray, should_minimize_to_tray};
@@ -37,6 +47,7 @@ pub fn run() {
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_fs::init())
.manage(bridge_manager.clone())
.manage(temp_manager.clone())
.setup(move |app| {
@@ -102,6 +113,43 @@ pub fn run() {
cleanup_all_temp_files,
cleanup_orphaned_temp_files,
get_file_size,
list_sessions,
save_session,
load_session,
delete_session,
search_sessions,
clear_all_sessions,
list_snippets,
save_snippet,
delete_snippet,
get_snippet_categories,
reset_default_snippets,
list_quick_actions,
save_quick_action,
delete_quick_action,
reset_default_quick_actions,
git_status,
git_diff,
git_branches,
git_checkout,
git_stage,
git_unstage,
git_stage_all,
git_commit,
git_push,
git_pull,
git_fetch,
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");
+191
View File
@@ -0,0 +1,191 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const QUICK_ACTIONS_STORE_KEY: &str = "quick_actions";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuickAction {
pub id: String,
pub name: String,
pub prompt: String,
pub icon: String,
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn get_default_quick_actions() -> Vec<QuickAction> {
let now = Utc::now();
vec![
QuickAction {
id: "default-review-pr".to_string(),
name: "Review PR".to_string(),
prompt: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
icon: "git-pull-request".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
QuickAction {
id: "default-run-tests".to_string(),
name: "Run Tests".to_string(),
prompt: "Please run the test suite for this project and report any failures or issues.".to_string(),
icon: "play".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
QuickAction {
id: "default-explain-file".to_string(),
name: "Explain File".to_string(),
prompt: "Please explain what this file does, its purpose, and how it fits into the overall project structure.".to_string(),
icon: "file-text".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
QuickAction {
id: "default-fix-error".to_string(),
name: "Fix Error".to_string(),
prompt: "I'm getting an error. Can you help me identify the cause and fix it?".to_string(),
icon: "alert-circle".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
QuickAction {
id: "default-write-tests".to_string(),
name: "Write Tests".to_string(),
prompt: "Please write comprehensive unit tests for the current code with good coverage.".to_string(),
icon: "check-square".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
QuickAction {
id: "default-refactor".to_string(),
name: "Refactor".to_string(),
prompt: "Please refactor this code to improve readability, maintainability, and performance.".to_string(),
icon: "refresh-cw".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
]
}
fn load_all_quick_actions(app: &AppHandle) -> Result<Vec<QuickAction>, String> {
let store = app
.store("hikari-quick-actions.json")
.map_err(|e| e.to_string())?;
match store.get(QUICK_ACTIONS_STORE_KEY) {
Some(value) => {
let mut actions: Vec<QuickAction> =
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
let defaults = get_default_quick_actions();
for default in defaults {
if !actions.iter().any(|a| a.id == default.id) {
actions.push(default);
}
}
Ok(actions)
}
None => Ok(get_default_quick_actions()),
}
}
fn save_all_quick_actions(app: &AppHandle, actions: &[QuickAction]) -> Result<(), String> {
let store = app
.store("hikari-quick-actions.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(actions).map_err(|e| e.to_string())?;
store.set(QUICK_ACTIONS_STORE_KEY, value);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn list_quick_actions(app: AppHandle) -> Result<Vec<QuickAction>, String> {
let mut actions = load_all_quick_actions(&app)?;
actions.sort_by(|a, b| {
let default_cmp = b.is_default.cmp(&a.is_default);
if default_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
default_cmp
}
});
Ok(actions)
}
#[tauri::command]
pub async fn save_quick_action(app: AppHandle, action: QuickAction) -> Result<(), String> {
let mut actions = load_all_quick_actions(&app)?;
if let Some(existing) = actions.iter_mut().find(|a| a.id == action.id) {
let mut updated = action;
updated.is_default = existing.is_default;
*existing = updated;
} else {
actions.push(action);
}
save_all_quick_actions(&app, &actions)
}
#[tauri::command]
pub async fn delete_quick_action(app: AppHandle, action_id: String) -> Result<(), String> {
let mut actions = load_all_quick_actions(&app)?;
if actions
.iter()
.any(|a| a.id == action_id && a.is_default)
{
return Err("Cannot delete default quick actions".to_string());
}
actions.retain(|a| a.id != action_id);
save_all_quick_actions(&app, &actions)
}
#[tauri::command]
pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> {
let mut actions = load_all_quick_actions(&app)?;
actions.retain(|a| !a.is_default);
actions.extend(get_default_quick_actions());
save_all_quick_actions(&app, &actions)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_quick_actions_exist() {
let defaults = get_default_quick_actions();
assert!(!defaults.is_empty());
assert!(defaults.iter().all(|a| a.is_default));
}
#[test]
fn test_default_quick_actions_have_required_fields() {
let defaults = get_default_quick_actions();
for action in defaults {
assert!(!action.id.is_empty());
assert!(!action.name.is_empty());
assert!(!action.prompt.is_empty());
assert!(!action.icon.is_empty());
}
}
}
+167
View File
@@ -0,0 +1,167 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const SESSIONS_STORE_KEY: &str = "sessions";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedSession {
pub id: String,
pub name: String,
pub created_at: DateTime<Utc>,
pub last_activity_at: DateTime<Utc>,
pub working_directory: String,
pub message_count: usize,
pub preview: String, // First ~100 chars of conversation for preview
pub messages: Vec<SavedMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedMessage {
pub id: String,
#[serde(rename = "type")]
pub message_type: String,
pub content: String,
pub timestamp: DateTime<Utc>,
pub tool_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionListItem {
pub id: String,
pub name: String,
pub created_at: DateTime<Utc>,
pub last_activity_at: DateTime<Utc>,
pub working_directory: String,
pub message_count: usize,
pub preview: String,
}
impl From<&SavedSession> for SessionListItem {
fn from(session: &SavedSession) -> Self {
SessionListItem {
id: session.id.clone(),
name: session.name.clone(),
created_at: session.created_at,
last_activity_at: session.last_activity_at,
working_directory: session.working_directory.clone(),
message_count: session.message_count,
preview: session.preview.clone(),
}
}
}
fn load_all_sessions(app: &AppHandle) -> Result<Vec<SavedSession>, String> {
let store = app
.store("hikari-sessions.json")
.map_err(|e| e.to_string())?;
match store.get(SESSIONS_STORE_KEY) {
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
None => Ok(Vec::new()),
}
}
fn save_all_sessions(app: &AppHandle, sessions: &[SavedSession]) -> Result<(), String> {
let store = app
.store("hikari-sessions.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(sessions).map_err(|e| e.to_string())?;
store.set(SESSIONS_STORE_KEY, value);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn list_sessions(app: AppHandle) -> Result<Vec<SessionListItem>, String> {
let sessions = load_all_sessions(&app)?;
let mut items: Vec<SessionListItem> = sessions.iter().map(SessionListItem::from).collect();
// Sort by last activity, most recent first
items.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
Ok(items)
}
#[tauri::command]
pub async fn save_session(app: AppHandle, session: SavedSession) -> Result<(), String> {
let mut sessions = load_all_sessions(&app)?;
// Update existing or add new
if let Some(existing) = sessions.iter_mut().find(|s| s.id == session.id) {
*existing = session;
} else {
sessions.push(session);
}
save_all_sessions(&app, &sessions)
}
#[tauri::command]
pub async fn load_session(app: AppHandle, session_id: String) -> Result<Option<SavedSession>, String> {
let sessions = load_all_sessions(&app)?;
Ok(sessions.into_iter().find(|s| s.id == session_id))
}
#[tauri::command]
pub async fn delete_session(app: AppHandle, session_id: String) -> Result<(), String> {
let mut sessions = load_all_sessions(&app)?;
sessions.retain(|s| s.id != session_id);
save_all_sessions(&app, &sessions)
}
#[tauri::command]
pub async fn search_sessions(app: AppHandle, query: String) -> Result<Vec<SessionListItem>, String> {
let sessions = load_all_sessions(&app)?;
let query_lower = query.to_lowercase();
let mut matching: Vec<SessionListItem> = sessions
.iter()
.filter(|s| {
s.name.to_lowercase().contains(&query_lower)
|| s.preview.to_lowercase().contains(&query_lower)
|| s.working_directory.to_lowercase().contains(&query_lower)
|| s.messages
.iter()
.any(|m| m.content.to_lowercase().contains(&query_lower))
})
.map(SessionListItem::from)
.collect();
// Sort by last activity, most recent first
matching.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
Ok(matching)
}
#[tauri::command]
pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> {
save_all_sessions(&app, &[])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_list_item_from_saved_session() {
let session = SavedSession {
id: "test-id".to_string(),
name: "Test Session".to_string(),
created_at: Utc::now(),
last_activity_at: Utc::now(),
working_directory: "/home/test".to_string(),
message_count: 5,
preview: "Hello world".to_string(),
messages: vec![],
};
let item = SessionListItem::from(&session);
assert_eq!(item.id, "test-id");
assert_eq!(item.name, "Test Session");
assert_eq!(item.message_count, 5);
}
}
+226
View File
@@ -0,0 +1,226 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const SNIPPETS_STORE_KEY: &str = "snippets";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snippet {
pub id: String,
pub name: String,
pub content: String,
pub category: String,
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn get_default_snippets() -> Vec<Snippet> {
let now = Utc::now();
vec![
Snippet {
id: "default-explain-code".to_string(),
name: "Explain this code".to_string(),
content: "Please explain what this code does, step by step:".to_string(),
category: "Code Review".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-fix-error".to_string(),
name: "Fix this error".to_string(),
content: "I'm getting the following error. Can you help me fix it?".to_string(),
category: "Debugging".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-write-tests".to_string(),
name: "Write tests".to_string(),
content: "Please write unit tests for this code with good coverage:".to_string(),
category: "Testing".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-refactor".to_string(),
name: "Refactor for clarity".to_string(),
content: "Please refactor this code to improve readability and maintainability:".to_string(),
category: "Code Review".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-optimize".to_string(),
name: "Optimize performance".to_string(),
content: "Please analyze this code for performance issues and suggest optimizations:".to_string(),
category: "Performance".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-review-pr".to_string(),
name: "Review PR".to_string(),
content: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
category: "Code Review".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-add-comments".to_string(),
name: "Add documentation".to_string(),
content: "Please add clear documentation comments to this code explaining what it does:".to_string(),
category: "Documentation".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-security-review".to_string(),
name: "Security review".to_string(),
content: "Please review this code for security vulnerabilities and suggest fixes:".to_string(),
category: "Security".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
]
}
fn load_all_snippets(app: &AppHandle) -> Result<Vec<Snippet>, String> {
let store = app
.store("hikari-snippets.json")
.map_err(|e| e.to_string())?;
match store.get(SNIPPETS_STORE_KEY) {
Some(value) => {
let mut snippets: Vec<Snippet> =
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
// Ensure default snippets exist (in case new ones were added in an update)
let defaults = get_default_snippets();
for default in defaults {
if !snippets.iter().any(|s| s.id == default.id) {
snippets.push(default);
}
}
Ok(snippets)
}
None => Ok(get_default_snippets()),
}
}
fn save_all_snippets(app: &AppHandle, snippets: &[Snippet]) -> Result<(), String> {
let store = app
.store("hikari-snippets.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(snippets).map_err(|e| e.to_string())?;
store.set(SNIPPETS_STORE_KEY, value);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn list_snippets(app: AppHandle) -> Result<Vec<Snippet>, String> {
let mut snippets = load_all_snippets(&app)?;
// Sort by category, then by name
snippets.sort_by(|a, b| {
let cat_cmp = a.category.cmp(&b.category);
if cat_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
cat_cmp
}
});
Ok(snippets)
}
#[tauri::command]
pub async fn save_snippet(app: AppHandle, snippet: Snippet) -> Result<(), String> {
let mut snippets = load_all_snippets(&app)?;
// Update existing or add new
if let Some(existing) = snippets.iter_mut().find(|s| s.id == snippet.id) {
// Don't allow editing default snippets' is_default flag
let mut updated = snippet;
updated.is_default = existing.is_default;
*existing = updated;
} else {
snippets.push(snippet);
}
save_all_snippets(&app, &snippets)
}
#[tauri::command]
pub async fn delete_snippet(app: AppHandle, snippet_id: String) -> Result<(), String> {
let mut snippets = load_all_snippets(&app)?;
// Don't allow deleting default snippets
if snippets
.iter()
.any(|s| s.id == snippet_id && s.is_default)
{
return Err("Cannot delete default snippets".to_string());
}
snippets.retain(|s| s.id != snippet_id);
save_all_snippets(&app, &snippets)
}
#[tauri::command]
pub async fn get_snippet_categories(app: AppHandle) -> Result<Vec<String>, String> {
let snippets = load_all_snippets(&app)?;
let mut categories: Vec<String> = snippets.iter().map(|s| s.category.clone()).collect();
categories.sort();
categories.dedup();
Ok(categories)
}
#[tauri::command]
pub async fn reset_default_snippets(app: AppHandle) -> Result<(), String> {
let mut snippets = load_all_snippets(&app)?;
// Remove all default snippets
snippets.retain(|s| !s.is_default);
// Add fresh default snippets
snippets.extend(get_default_snippets());
save_all_snippets(&app, &snippets)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_snippets_exist() {
let defaults = get_default_snippets();
assert!(!defaults.is_empty());
assert!(defaults.iter().all(|s| s.is_default));
}
#[test]
fn test_default_snippets_have_required_fields() {
let defaults = get_default_snippets();
for snippet in defaults {
assert!(!snippet.id.is_empty());
assert!(!snippet.name.is_empty());
assert!(!snippet.content.is_empty());
assert!(!snippet.category.is_empty());
}
}
}
+106
View File
@@ -14,6 +14,25 @@
--text-tertiary: #6b7280;
--border-color: #2a2a4a;
/* Trans pride colors */
--trans-blue: #5bcefa;
--trans-pink: #f5a9b8;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors */
--terminal-user: #22d3ee;
--terminal-tool: #c084fc;
@@ -44,6 +63,25 @@
--text-tertiary: #9ca3af;
--border-color: #d0d0e0;
/* Trans pride colors */
--trans-blue: #5bcefa;
--trans-pink: #f5a9b8;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors */
--terminal-user: #0891b2;
--terminal-tool: #7c3aed;
@@ -74,6 +112,25 @@
--text-tertiary: #b0b0b0;
--border-color: #ffffff;
/* Trans pride colors (high contrast) */
--trans-blue: #00d4ff;
--trans-pink: #ff99cc;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors - bright and saturated */
--terminal-user: #00ffff;
--terminal-tool: #ff00ff;
@@ -131,3 +188,52 @@ body {
background: var(--accent-primary);
color: var(--text-primary);
}
/* Trans gradient button - primary action buttons */
.btn-trans-gradient {
background: var(--trans-gradient-vibrant) !important;
border: none !important;
color: #1a1a2e !important;
font-weight: 600;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
transition: all 0.2s ease;
}
.btn-trans-gradient: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);
}
.btn-trans-gradient:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(0.3);
}
/* Trans gradient focus border for inputs */
.input-trans-focus {
position: relative;
transition: all 0.2s ease;
}
.input-trans-focus:focus {
border-color: var(--trans-pink) !important;
box-shadow:
0 0 0 1px var(--trans-blue),
0 0 12px rgba(91, 206, 250, 0.3),
0 0 20px rgba(245, 169, 184, 0.2) !important;
outline: none !important;
}
/* Trans gradient hover for icon buttons */
.icon-trans-hover {
transition: all 0.2s ease;
}
.icon-trans-hover:hover {
color: var(--trans-pink) !important;
filter: drop-shadow(0 0 6px rgba(91, 206, 250, 0.5))
drop-shadow(0 0 10px rgba(245, 169, 184, 0.4));
}
+6 -54
View File
@@ -34,35 +34,12 @@
return "animate-idle";
}
}
function getBackgroundGlow(): string {
switch (currentState) {
case "thinking":
return "shadow-thinking";
case "typing":
return "shadow-typing";
case "searching":
return "shadow-searching";
case "coding":
return "shadow-coding";
case "mcp":
return "shadow-mcp";
case "success":
return "shadow-success";
case "error":
return "shadow-error";
default:
return "";
}
}
</script>
<div
class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
>
<div
class="character-frame relative {getBackgroundGlow()} flex-1 flex items-center justify-center min-h-0"
>
<div class="character-frame relative flex-1 flex items-center justify-center min-h-0">
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
<img
src="/sprites/{info.spriteFile}"
@@ -97,37 +74,12 @@
</div>
<style>
.anime-girl-container {
transition: all 0.3s ease;
}
.character-frame {
border-radius: 50%;
transition: box-shadow 0.3s ease;
}
.shadow-thinking {
box-shadow: 0 0 30px rgba(147, 51, 234, 0.5);
}
.shadow-typing {
box-shadow: 0 0 30px rgba(59, 130, 246, 0.5);
}
.shadow-searching {
box-shadow: 0 0 30px rgba(234, 179, 8, 0.5);
}
.shadow-coding {
box-shadow: 0 0 30px rgba(34, 197, 94, 0.5);
}
.shadow-mcp {
box-shadow: 0 0 30px rgba(236, 72, 153, 0.5);
}
.shadow-success {
box-shadow: 0 0 30px rgba(16, 185, 129, 0.5);
}
.shadow-error {
box-shadow: 0 0 30px rgba(239, 68, 68, 0.5);
transition: all 0.3s ease;
}
@keyframes idle-bob {
@@ -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>
+2 -2
View File
@@ -386,7 +386,7 @@
<button
onclick={addCustomTool}
disabled={!newToolName.trim()}
class="px-3 py-1.5 text-sm bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg"
>
Add
</button>
@@ -569,7 +569,7 @@
<button
onclick={handleSave}
disabled={isSaving}
class="w-full py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] text-white font-medium rounded-lg transition-colors disabled:opacity-50"
class="btn-trans-gradient w-full py-3 font-medium rounded-lg"
>
{isSaving ? "Saving..." : "Save Settings"}
</button>
+5 -1
View File
@@ -236,10 +236,13 @@
</div>
{/if}
<div
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
{#if $conversations.size > 1}
<button
onclick={(e) => deleteTab(id, e)}
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
class="w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)]"
title="Close tab"
>
<svg
@@ -259,6 +262,7 @@
</button>
{/if}
</div>
</div>
{/each}
<button
File diff suppressed because it is too large Load Diff
+200 -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,
@@ -25,6 +26,9 @@
type SlashCommand,
} from "$lib/commands/slashCommands";
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";
@@ -39,6 +43,9 @@
let selectedCommandIndex = $state(0);
let attachments = $state<Attachment[]>([]);
let isDragging = $state(false);
let showSnippetLibrary = $state(false);
let showQuickActions = $state(false);
let showClipboardHistory = $state(false);
// Input history state
let inputHistory = $state<string[]>([]);
@@ -500,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") {
@@ -617,6 +633,62 @@ User: ${formattedMessage}`;
}
}
function handleSnippetInsert(content: string): void {
// Insert snippet at cursor position or append to input
if (inputValue.trim()) {
inputValue = inputValue + "\n\n" + content;
} else {
inputValue = content;
}
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;
// Add to history
addToHistory(prompt);
historyIndex = -1;
tempInput = "";
userHasTyped = false;
isSubmitting = true;
// Reset notification state for new user message
handleNewUserMessage();
claudeStore.addLine("user", prompt);
characterState.setState("thinking");
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("send_prompt", {
conversationId,
message: prompt,
});
} catch (error) {
console.error("Failed to send quick action:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
characterState.setTemporaryState("error", 3000);
} finally {
isSubmitting = false;
}
}
function handleKeyDown(event: KeyboardEvent) {
// Handle command menu navigation
if (showCommandMenu && matchingCommands.length > 0) {
@@ -693,6 +765,71 @@ User: ${formattedMessage}`;
<div class="input-controls flex gap-2 mb-2">
<MessageModeSelector />
<button
type="button"
onclick={() => (showQuickActions = true)}
class="control-button"
title="Quick Actions"
>
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Actions</span>
</button>
<button
type="button"
onclick={() => (showSnippetLibrary = true)}
class="control-button"
title="Snippet Library"
>
<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="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</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">
@@ -717,8 +854,7 @@ User: ${formattedMessage}`;
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
disabled:opacity-50 disabled:cursor-not-allowed"
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
></textarea>
</div>
@@ -744,7 +880,7 @@ User: ${formattedMessage}`;
<button
type="button"
onclick={handleInterrupt}
class="send-button bg-red-600 hover:bg-red-700"
class="send-button btn-trans-gradient"
title="Interrupt the current response (Ctrl+C)"
>
<span class="font-bold">â– </span> Stop
@@ -755,7 +891,7 @@ User: ${formattedMessage}`;
disabled={!isConnected ||
isSubmitting ||
(!inputValue.trim() && attachments.length === 0)}
class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
class="send-button trans-gradient-button
disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if isSubmitting}
@@ -769,6 +905,25 @@ User: ${formattedMessage}`;
</div>
</form>
{#if showSnippetLibrary}
<SnippetLibraryPanel
onClose={() => (showSnippetLibrary = false)}
onInsert={handleSnippetInsert}
/>
{/if}
{#if showQuickActions}
<QuickActionsPanel onClose={() => (showQuickActions = false)} onAction={handleQuickAction} />
{/if}
{#if showClipboardHistory}
<ClipboardHistoryPanel
isOpen={showClipboardHistory}
onClose={() => (showClipboardHistory = false)}
onInsert={handleClipboardInsert}
/>
{/if}
<style>
.input-bar {
display: flex;
@@ -811,6 +966,32 @@ User: ${formattedMessage}`;
gap: 8px;
}
.control-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
white-space: nowrap;
}
.control-button:hover {
background: var(--bg-tertiary);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.control-button:active {
transform: scale(0.95);
}
.input-row {
display: flex;
gap: 12px;
@@ -899,4 +1080,19 @@ User: ${formattedMessage}`;
.send-button:active:not(:disabled) {
transform: scale(0.95);
}
.trans-gradient-button {
background: var(--trans-gradient-vibrant);
border: none;
color: #1a1a2e;
font-weight: 600;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
}
.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);
}
</style>
+93 -4
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;
@@ -17,7 +18,20 @@
renderer.code = ({ text, lang }) => {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
return `<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
const escapedText = text.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return `<div class="code-block-wrapper">
<div class="code-block-header">
<span class="code-block-lang">${language}</span>
<button class="copy-code-btn" data-code="${escapedText}" title="Copy code">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
</div>
<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>
</div>`;
};
renderer.codespan = ({ text }) => {
@@ -123,6 +137,34 @@
}
}
async function handleCopyClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const copyBtn = target.closest(".copy-code-btn") as HTMLButtonElement;
if (copyBtn) {
event.preventDefault();
const code = copyBtn.dataset.code
?.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.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!";
setTimeout(() => {
textSpan.textContent = "Copy";
}, 2000);
}
}
}
}
onMount(() => {
if (containerElement) {
containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => {
@@ -138,6 +180,7 @@
onclick={(e) => {
handleSpoilerClick(e);
handleLinkClick(e);
handleCopyClick(e);
}}
onkeydown={handleSpoilerKeydown}
role="presentation"
@@ -163,13 +206,59 @@
margin-bottom: 0;
}
.markdown-content :global(.code-block-wrapper) {
margin: 0.75em 0;
border-radius: 6px;
border: 1px solid var(--border-color);
overflow: hidden;
}
.markdown-content :global(.code-block-header) {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary);
padding: 0.4em 0.75em;
border-bottom: 1px solid var(--border-color);
font-size: 0.8em;
}
.markdown-content :global(.code-block-lang) {
color: var(--text-secondary);
font-family: "JetBrains Mono", "Fira Code", monospace;
text-transform: lowercase;
}
.markdown-content :global(.copy-code-btn) {
display: flex;
align-items: center;
gap: 0.4em;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25em 0.5em;
border-radius: 4px;
font-size: 0.9em;
transition: all 0.15s ease;
}
.markdown-content :global(.copy-code-btn:hover) {
background: var(--bg-hover);
color: var(--text-primary);
}
.markdown-content :global(.copy-code-btn svg) {
flex-shrink: 0;
}
.markdown-content :global(.hljs-code-block) {
background: var(--bg-code, #1e1e2e);
border-radius: 6px;
border-radius: 0;
padding: 1em;
margin: 0.75em 0;
margin: 0;
overflow-x: auto;
border: 1px solid var(--border-color);
border: none;
}
.markdown-content :global(.hljs-code-block code) {
+455
View File
@@ -0,0 +1,455 @@
<script lang="ts">
import { onMount } from "svelte";
import { quickActionsStore, type QuickAction } from "$lib/stores/quickActions";
interface Props {
onClose: () => void;
onAction: (prompt: string) => void;
}
const { onClose, onAction }: Props = $props();
let editingAction = $state<QuickAction | null>(null);
let isCreating = $state(false);
let showDeleteConfirm = $state<string | null>(null);
let editName = $state("");
let editPrompt = $state("");
let editIcon = $state("zap");
const actions = $derived(quickActionsStore.actions);
const isLoading = $derived(quickActionsStore.isLoading);
const availableIcons = [
{ id: "zap", label: "Lightning" },
{ id: "play", label: "Play" },
{ id: "file-text", label: "File" },
{ id: "alert-circle", label: "Alert" },
{ id: "check-square", label: "Check" },
{ id: "refresh-cw", label: "Refresh" },
{ id: "git-pull-request", label: "Git PR" },
{ id: "code", label: "Code" },
{ id: "search", label: "Search" },
{ id: "terminal", label: "Terminal" },
{ id: "bug", label: "Bug" },
{ id: "shield", label: "Shield" },
];
onMount(() => {
quickActionsStore.loadQuickActions();
});
function handleAction(action: QuickAction): void {
onAction(action.prompt);
onClose();
}
function handleStartCreate(): void {
isCreating = true;
editingAction = null;
editName = "";
editPrompt = "";
editIcon = "zap";
}
function handleStartEdit(action: QuickAction): void {
editingAction = action;
isCreating = false;
editName = action.name;
editPrompt = action.prompt;
editIcon = action.icon;
}
function handleCancelEdit(): void {
editingAction = null;
isCreating = false;
}
async function handleSave(): Promise<void> {
if (!editName.trim() || !editPrompt.trim()) {
return;
}
if (isCreating) {
await quickActionsStore.createQuickAction(editName.trim(), editPrompt.trim(), editIcon);
} else if (editingAction) {
await quickActionsStore.updateQuickAction(
editingAction.id,
editName.trim(),
editPrompt.trim(),
editIcon
);
}
handleCancelEdit();
}
async function handleDelete(actionId: string): Promise<void> {
await quickActionsStore.deleteQuickAction(actionId);
showDeleteConfirm = null;
}
function getIconSvg(icon: string): string {
const icons: Record<string, string> = {
"git-pull-request":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 3v12M18 9a3 3 0 100 6 3 3 0 000-6zm0 0V3m0 18v-6M6 21a3 3 0 100-6 3 3 0 000 6z" />',
play: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
"file-text":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />',
"alert-circle":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
"check-square":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />',
"refresh-cw":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />',
zap: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />',
code: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />',
search:
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />',
terminal:
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />',
bug: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />',
shield:
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />',
};
return icons[icon] || icons["zap"];
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="quick-actions-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
{#if editingAction || isCreating}
<button
onclick={handleCancelEdit}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Back to list"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
<h2 id="quick-actions-title" class="text-xl font-semibold text-[var(--text-primary)]">
{#if isCreating}
Create Quick Action
{:else if editingAction}
Edit Quick Action
{:else}
Quick Actions
{/if}
</h2>
</div>
<div class="flex items-center gap-2">
{#if !editingAction && !isCreating}
<button
onclick={handleStartCreate}
class="px-3 py-1.5 text-sm font-medium btn-trans-gradient rounded flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
New Action
</button>
{/if}
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{#if editingAction || isCreating}
<div class="p-6 flex-1 overflow-y-auto">
<div class="space-y-4">
<div>
<label
for="action-name"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Name
</label>
<input
id="action-name"
type="text"
bind:value={editName}
placeholder="Enter action name..."
class="w-full px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label
for="action-icon"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Icon
</label>
<div class="grid grid-cols-6 gap-2">
{#each availableIcons as icon (icon.id)}
<button
onclick={() => (editIcon = icon.id)}
class="p-3 rounded-lg border transition-colors {editIcon === icon.id
? 'bg-[var(--accent-primary)]/10 border-[var(--accent-primary)] text-[var(--accent-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]/50'}"
title={icon.label}
>
<svg
class="w-5 h-5 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Icons are from controlled internal function -->
{@html getIconSvg(icon.id)}
</svg>
</button>
{/each}
</div>
</div>
<div>
<label
for="action-prompt"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Prompt
</label>
<textarea
id="action-prompt"
bind:value={editPrompt}
placeholder="Enter the prompt to send..."
rows="4"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none font-mono text-sm"
></textarea>
</div>
<div class="flex justify-end gap-2 pt-4">
<button
onclick={handleCancelEdit}
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
>
Cancel
</button>
<button
onclick={handleSave}
disabled={!editName.trim() || !editPrompt.trim()}
class="px-4 py-2 text-sm font-medium btn-trans-gradient rounded-lg"
>
{isCreating ? "Create" : "Save"}
</button>
</div>
</div>
</div>
{:else}
<div class="flex-1 overflow-y-auto p-6">
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading quick actions...</div>
</div>
{:else if $actions.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<p class="text-[var(--text-secondary)]">No quick actions available</p>
<button
onclick={handleStartCreate}
class="mt-4 px-4 py-2 text-sm font-medium btn-trans-gradient rounded-lg"
>
Create your first action
</button>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
{#each $actions as action (action.id)}
<div
class="group relative bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg hover:border-[var(--accent-primary)]/50 transition-colors"
>
<button onclick={() => handleAction(action)} class="w-full p-4 text-left">
<div class="flex items-center gap-3 mb-2">
<div
class="w-10 h-10 rounded-lg bg-[var(--accent-primary)]/10 flex items-center justify-center text-[var(--accent-primary)]"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Icons are from controlled internal function -->
{@html getIconSvg(action.icon)}
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-[var(--text-primary)] truncate">{action.name}</h3>
{#if action.is_default}
<span class="text-xs text-[var(--text-tertiary)]">Default</span>
{:else}
<span class="text-xs text-[var(--accent-secondary)]">Custom</span>
{/if}
</div>
</div>
<p class="text-xs text-[var(--text-tertiary)] line-clamp-2">{action.prompt}</p>
</button>
<div
class="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={(e) => {
e.stopPropagation();
handleStartEdit(action);
}}
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)] rounded transition-colors"
title="Edit action"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{#if !action.is_default}
{#if showDeleteConfirm === action.id}
<div class="flex items-center gap-1 bg-[var(--bg-primary)] rounded p-1">
<button
onclick={(e) => {
e.stopPropagation();
handleDelete(action.id);
}}
class="px-2 py-0.5 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Delete
</button>
<button
onclick={(e) => {
e.stopPropagation();
showDeleteConfirm = null;
}}
class="px-2 py-0.5 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
>
Cancel
</button>
</div>
{:else}
<button
onclick={(e) => {
e.stopPropagation();
showDeleteConfirm = action.id;
}}
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 bg-[var(--bg-primary)] rounded transition-colors"
title="Delete action"
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,475 @@
<script lang="ts">
import { onMount } from "svelte";
import { sessionsStore, type SessionListItem, type SavedSession } from "$lib/stores/sessions";
import { conversationsStore } from "$lib/stores/conversations";
import type { TerminalLine } from "$lib/types/messages";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
let searchInput = $state("");
let selectedSession = $state<SavedSession | null>(null);
let showDeleteConfirm = $state<string | null>(null);
let showExportMenu = $state<string | null>(null);
let isImporting = $state(false);
const sessions = $derived(sessionsStore.sessions);
const isLoading = $derived(sessionsStore.isLoading);
onMount(() => {
sessionsStore.loadSessions();
});
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return `Today at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
} else if (diffDays === 1) {
return `Yesterday at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else {
return date.toLocaleDateString([], {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
}
async function handleSearch(): Promise<void> {
await sessionsStore.searchSessions(searchInput);
}
async function handleViewSession(session: SessionListItem): Promise<void> {
const fullSession = await sessionsStore.loadSession(session.id);
selectedSession = fullSession;
}
async function handleResumeSession(session: SessionListItem): Promise<void> {
const fullSession = await sessionsStore.loadSession(session.id);
if (!fullSession) return;
const newConvId = conversationsStore.createConversation(fullSession.name);
for (const msg of fullSession.messages) {
const line: TerminalLine = {
id: msg.id,
type: msg.type as TerminalLine["type"],
content: msg.content,
timestamp: new Date(msg.timestamp),
toolName: msg.tool_name,
};
conversationsStore.addLineToConversation(newConvId, line.type, line.content, line.toolName);
}
if (fullSession.working_directory) {
conversationsStore.setWorkingDirectoryForConversation(
newConvId,
fullSession.working_directory
);
}
onClose();
}
async function handleDeleteSession(sessionId: string): Promise<void> {
await sessionsStore.deleteSession(sessionId);
showDeleteConfirm = null;
if (selectedSession?.id === sessionId) {
selectedSession = null;
}
}
function handleBackToList(): void {
selectedSession = null;
}
async function handleExportJson(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsJson(sessionId);
}
async function handleExportMarkdown(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsMarkdown(sessionId);
}
async function handleExportHtml(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsHtml(sessionId);
}
async function handleExportPdf(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsPdf(sessionId);
}
async function handleImport(): Promise<void> {
isImporting = true;
try {
await sessionsStore.importSession();
} finally {
isImporting = false;
}
}
function toggleExportMenu(sessionId: string): void {
if (showExportMenu === sessionId) {
showExportMenu = null;
} else {
showExportMenu = sessionId;
showDeleteConfirm = null;
}
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-3xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="session-history-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
{#if selectedSession}
<button
onclick={handleBackToList}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Back to list"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
<h2 id="session-history-title" class="text-xl font-semibold text-[var(--text-primary)]">
{selectedSession ? selectedSession.name : "Session History"}
</h2>
</div>
<div class="flex items-center gap-2">
{#if !selectedSession}
<button
onclick={handleImport}
disabled={isImporting}
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title="Import session"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
{isImporting ? "Importing..." : "Import"}
</button>
{/if}
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{#if selectedSession}
<div class="overflow-y-auto flex-1 p-6">
<div class="text-sm text-[var(--text-tertiary)] mb-4">
<span>{formatDate(selectedSession.created_at)}</span>
<span class="mx-2">•</span>
<span>{selectedSession.message_count} messages</span>
{#if selectedSession.working_directory}
<span class="mx-2">•</span>
<span class="font-mono text-xs">{selectedSession.working_directory}</span>
{/if}
</div>
<div class="space-y-3">
{#each selectedSession.messages as message (message.id)}
<div
class="p-3 rounded-lg {message.type === 'user'
? 'bg-[var(--accent-primary)]/10 border border-[var(--accent-primary)]/20'
: message.type === 'assistant'
? 'bg-[var(--bg-secondary)]'
: message.type === 'error'
? 'bg-red-500/10 border border-red-500/20'
: 'bg-[var(--bg-tertiary)]'}"
>
<div class="flex items-center gap-2 mb-1">
<span
class="text-xs font-medium {message.type === 'user'
? 'text-[var(--accent-primary)]'
: message.type === 'assistant'
? 'text-[var(--text-primary)]'
: message.type === 'error'
? 'text-red-400'
: 'text-[var(--text-tertiary)]'}"
>
{message.type === "user"
? "You"
: message.type === "assistant"
? "Hikari"
: message.type === "tool"
? message.tool_name || "Tool"
: message.type}
</span>
</div>
<p class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap break-words">
{message.content.length > 500
? message.content.slice(0, 500) + "..."
: message.content}
</p>
</div>
{/each}
</div>
</div>
{:else}
<div class="p-4 border-b border-[var(--border-color)]">
<div class="relative">
<input
type="text"
placeholder="Search sessions..."
bind:value={searchInput}
oninput={handleSearch}
class="w-full px-4 py-2 pl-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
<div class="overflow-y-auto flex-1">
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
</div>
{:else if $sessions.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<p class="text-[var(--text-secondary)]">No saved sessions yet</p>
<p class="text-sm text-[var(--text-tertiary)] mt-1">
Your conversations will appear here once saved
</p>
</div>
{:else}
<div class="divide-y divide-[var(--border-color)]">
{#each $sessions as session (session.id)}
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4">
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
<div class="flex items-center gap-2 mb-1">
<h3 class="font-medium text-[var(--text-primary)]">{session.name}</h3>
<span class="text-xs text-[var(--text-tertiary)]">
{session.message_count} messages
</span>
</div>
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 mb-2">
{session.preview}
</p>
<div class="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<span>{formatDate(session.last_activity_at)}</span>
{#if session.working_directory}
<span>•</span>
<span class="font-mono truncate max-w-[200px]">
{session.working_directory}
</span>
{/if}
</div>
</button>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={() => handleResumeSession(session)}
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
title="Resume this session"
>
Resume
</button>
<div class="relative">
<button
onclick={() => toggleExportMenu(session.id)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
title="Export session"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
{#if showExportMenu === session.id}
<div
class="absolute right-0 top-full mt-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-lg py-1 z-10 min-w-[140px]"
>
<button
onclick={() => handleExportJson(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as JSON
</button>
<button
onclick={() => handleExportMarkdown(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as Markdown
</button>
<button
onclick={() => handleExportHtml(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as HTML
</button>
<button
onclick={() => handleExportPdf(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as PDF
</button>
</div>
{/if}
</div>
{#if showDeleteConfirm === session.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDeleteSession(session.id)}
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Confirm
</button>
<button
onclick={() => (showDeleteConfirm = null)}
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
>
Cancel
</button>
</div>
{:else}
<button
onclick={() => (showDeleteConfirm = session.id)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
title="Delete session"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,466 @@
<script lang="ts">
import { onMount } from "svelte";
import { snippetsStore, type Snippet } from "$lib/stores/snippets";
interface Props {
onClose: () => void;
onInsert: (content: string) => void;
}
const { onClose, onInsert }: Props = $props();
let editingSnippet = $state<Snippet | null>(null);
let isCreating = $state(false);
let showDeleteConfirm = $state<string | null>(null);
let editName = $state("");
let editContent = $state("");
let editCategory = $state("");
let newCategoryInput = $state("");
let showNewCategoryInput = $state(false);
const snippets = $derived(snippetsStore.filteredSnippets);
const categories = $derived(snippetsStore.categories);
const selectedCategory = $derived(snippetsStore.selectedCategory);
const isLoading = $derived(snippetsStore.isLoading);
onMount(() => {
snippetsStore.loadSnippets();
});
function handleSelectCategory(category: string | null): void {
snippetsStore.setSelectedCategory(category);
}
function handleInsert(snippet: Snippet): void {
onInsert(snippet.content);
onClose();
}
function handleStartCreate(): void {
isCreating = true;
editingSnippet = null;
editName = "";
editContent = "";
editCategory = $categories.length > 0 ? $categories[0] : "Custom";
showNewCategoryInput = false;
newCategoryInput = "";
}
function handleStartEdit(snippet: Snippet): void {
editingSnippet = snippet;
isCreating = false;
editName = snippet.name;
editContent = snippet.content;
editCategory = snippet.category;
showNewCategoryInput = false;
newCategoryInput = "";
}
function handleCancelEdit(): void {
editingSnippet = null;
isCreating = false;
showNewCategoryInput = false;
newCategoryInput = "";
}
async function handleSave(): Promise<void> {
const finalCategory =
showNewCategoryInput && newCategoryInput.trim() ? newCategoryInput.trim() : editCategory;
if (!editName.trim() || !editContent.trim() || !finalCategory) {
return;
}
if (isCreating) {
await snippetsStore.createSnippet(editName.trim(), editContent.trim(), finalCategory);
} else if (editingSnippet) {
await snippetsStore.updateSnippet(
editingSnippet.id,
editName.trim(),
editContent.trim(),
finalCategory
);
}
handleCancelEdit();
}
async function handleDelete(snippetId: string): Promise<void> {
await snippetsStore.deleteSnippet(snippetId);
showDeleteConfirm = null;
}
function toggleNewCategory(): void {
showNewCategoryInput = !showNewCategoryInput;
if (showNewCategoryInput) {
newCategoryInput = "";
}
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="snippet-library-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
{#if editingSnippet || isCreating}
<button
onclick={handleCancelEdit}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Back to list"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
<h2 id="snippet-library-title" class="text-xl font-semibold text-[var(--text-primary)]">
{#if isCreating}
Create Snippet
{:else if editingSnippet}
Edit Snippet
{:else}
Snippet Library
{/if}
</h2>
</div>
<div class="flex items-center gap-2">
{#if !editingSnippet && !isCreating}
<button
onclick={handleStartCreate}
class="btn-trans-gradient px-3 py-1.5 text-sm font-medium rounded flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
New Snippet
</button>
{/if}
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{#if editingSnippet || isCreating}
<div class="p-6 flex-1 overflow-y-auto">
<div class="space-y-4">
<div>
<label
for="snippet-name"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Name
</label>
<input
id="snippet-name"
type="text"
bind:value={editName}
placeholder="Enter snippet name..."
class="w-full px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label
for="snippet-category"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Category
</label>
<div class="flex items-center gap-2">
{#if showNewCategoryInput}
<input
type="text"
bind:value={newCategoryInput}
placeholder="Enter new category..."
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
{:else}
<select
id="snippet-category"
bind:value={editCategory}
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
>
{#each $categories as category (category)}
<option value={category}>{category}</option>
{/each}
<option value="Custom">Custom</option>
</select>
{/if}
<button
onclick={toggleNewCategory}
class="px-3 py-2 text-sm font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
{showNewCategoryInput ? "Use Existing" : "New Category"}
</button>
</div>
</div>
<div>
<label
for="snippet-content"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Content
</label>
<textarea
id="snippet-content"
bind:value={editContent}
placeholder="Enter your prompt snippet..."
rows="8"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none font-mono text-sm"
></textarea>
</div>
<div class="flex justify-end gap-2 pt-4">
<button
onclick={handleCancelEdit}
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
>
Cancel
</button>
<button
onclick={handleSave}
disabled={!editName.trim() || !editContent.trim()}
class="btn-trans-gradient px-4 py-2 text-sm font-medium rounded-lg"
>
{isCreating ? "Create" : "Save"}
</button>
</div>
</div>
</div>
{:else}
<div class="flex flex-1 overflow-hidden">
<div class="w-48 border-r border-[var(--border-color)] p-4 overflow-y-auto">
<h3
class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider mb-3"
>
Categories
</h3>
<div class="space-y-1">
<button
onclick={() => handleSelectCategory(null)}
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
null
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
>
All Snippets
</button>
{#each $categories as category (category)}
<button
onclick={() => handleSelectCategory(category)}
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
category
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
>
{category}
</button>
{/each}
</div>
</div>
<div class="flex-1 overflow-y-auto">
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading snippets...</div>
</div>
{:else if $snippets.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-[var(--text-secondary)]">No snippets in this category</p>
<button
onclick={handleStartCreate}
class="btn-trans-gradient mt-4 px-4 py-2 text-sm font-medium rounded-lg"
>
Create your first snippet
</button>
</div>
{:else}
<div class="divide-y divide-[var(--border-color)]">
{#each $snippets as snippet (snippet.id)}
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-medium text-[var(--text-primary)]">{snippet.name}</h3>
{#if snippet.is_default}
<span
class="px-2 py-0.5 text-xs font-medium bg-[var(--accent-primary)]/10 text-[var(--accent-primary)] rounded"
>
Default
</span>
{/if}
<span class="text-xs text-[var(--text-tertiary)]">{snippet.category}</span>
</div>
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 font-mono">
{snippet.content}
</p>
</div>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={() => handleInsert(snippet)}
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
title="Insert this snippet"
>
Insert
</button>
<button
onclick={() => handleStartEdit(snippet)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
title="Edit snippet"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{#if !snippet.is_default}
{#if showDeleteConfirm === snippet.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDelete(snippet.id)}
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Confirm
</button>
<button
onclick={() => (showDeleteConfirm = null)}
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
>
Cancel
</button>
</div>
{:else}
<button
onclick={() => (showDeleteConfirm = snippet.id)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
title="Delete snippet"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+48 -10
View File
@@ -19,6 +19,8 @@
import HelpPanel from "./HelpPanel.svelte";
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
import { achievementProgress } from "$lib/stores/achievements";
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
import GitPanel from "./GitPanel.svelte";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -33,6 +35,8 @@
let showAbout = $state(false);
let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
let showSessionHistory = $state(false);
let showGitPanel = $state(false);
const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({
model: null,
@@ -205,7 +209,7 @@
<div class="flex items-center gap-3">
<button
onclick={toggleAchievements}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
class="p-1 text-gray-500 icon-trans-hover relative"
title="Achievements"
>
<span class="text-lg">🏆</span>
@@ -217,11 +221,37 @@
</span>
{/if}
</button>
<button
onclick={() => (showSessionHistory = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Session History"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button
onclick={() => (showGitPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Git Panel"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
? 'text-[var(--accent-primary)]'
: ''}"
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">
@@ -235,7 +265,7 @@
</button>
<button
onclick={configStore.openSidebar}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Settings"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -255,7 +285,7 @@
</button>
<button
onclick={() => openUrl(DONATE_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Support our work"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
@@ -266,7 +296,7 @@
</button>
<button
onclick={() => (showAbout = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="About Hikari Desktop"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -280,7 +310,7 @@
</button>
<button
onclick={() => (showKeyboardShortcuts = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Keyboard Shortcuts"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -300,7 +330,7 @@
</button>
<button
onclick={() => (showHelp = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Help"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -314,7 +344,7 @@
</button>
<button
onclick={() => openUrl(DISCORD_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Join our Discord"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
@@ -371,3 +401,11 @@
{#if showKeyboardShortcuts}
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
{/if}
{#if showSessionHistory}
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
{/if}
{#if showGitPanel}
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
{/if}
+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
+1 -4
View File
@@ -53,10 +53,7 @@
Current version: {updateInfo.current_version}
</p>
<div class="flex gap-2">
<button
onclick={openRelease}
class="px-3 py-1.5 bg-[var(--accent-primary)] text-white rounded text-sm hover:brightness-110 transition-all"
>
<button onclick={openRelease} class="btn-trans-gradient px-3 py-1.5 rounded text-sm">
View Release
</button>
<button
+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,
};
+8
View File
@@ -9,6 +9,7 @@ import type {
import type { CharacterState } from "$lib/types/states";
import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character";
import { sessionsStore } from "$lib/stores/sessions";
export interface Conversation {
id: string;
@@ -287,6 +288,9 @@ function createConversationsStore() {
return false;
}
// Cancel any pending auto-save for this conversation
sessionsStore.cancelAutoSave(id);
// Clean up tracking for this conversation (including temp files)
await cleanupConversationTracking(id);
@@ -434,6 +438,8 @@ function createConversationsStore() {
if (conv) {
conv.terminalLines.push(line);
conv.lastActivityAt = new Date();
// Schedule auto-save for this conversation
sessionsStore.scheduleAutoSave(conv);
}
return convs;
});
@@ -462,6 +468,8 @@ function createConversationsStore() {
if (conv) {
conv.terminalLines.push(line);
conv.lastActivityAt = new Date();
// Schedule auto-save for this conversation
sessionsStore.scheduleAutoSave(conv);
}
return convs;
});
+114
View File
@@ -0,0 +1,114 @@
import { writable } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export interface QuickAction {
id: string;
name: string;
prompt: string;
icon: string;
is_default: boolean;
created_at: string;
updated_at: string;
}
function createQuickActionsStore() {
const actions = writable<QuickAction[]>([]);
const isLoading = writable(false);
async function loadQuickActions(): Promise<void> {
isLoading.set(true);
try {
const actionList = await invoke<QuickAction[]>("list_quick_actions");
actions.set(actionList);
} catch (error) {
console.error("Failed to load quick actions:", error);
} finally {
isLoading.set(false);
}
}
async function saveQuickAction(action: QuickAction): Promise<boolean> {
try {
await invoke("save_quick_action", { action });
await loadQuickActions();
return true;
} catch (error) {
console.error("Failed to save quick action:", error);
return false;
}
}
async function createQuickAction(name: string, prompt: string, icon: string): Promise<boolean> {
const now = new Date().toISOString();
const action: QuickAction = {
id: `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
name,
prompt,
icon,
is_default: false,
created_at: now,
updated_at: now,
};
return saveQuickAction(action);
}
async function updateQuickAction(
id: string,
name: string,
prompt: string,
icon: string
): Promise<boolean> {
const currentActions = await invoke<QuickAction[]>("list_quick_actions");
const existing = currentActions.find((a) => a.id === id);
if (!existing) {
console.error("Quick action not found for update");
return false;
}
const updated: QuickAction = {
...existing,
name,
prompt,
icon,
updated_at: new Date().toISOString(),
};
return saveQuickAction(updated);
}
async function deleteQuickAction(actionId: string): Promise<boolean> {
try {
await invoke("delete_quick_action", { actionId });
await loadQuickActions();
return true;
} catch (error) {
console.error("Failed to delete quick action:", error);
return false;
}
}
async function resetDefaults(): Promise<boolean> {
try {
await invoke("reset_default_quick_actions");
await loadQuickActions();
return true;
} catch (error) {
console.error("Failed to reset default quick actions:", error);
return false;
}
}
return {
actions: { subscribe: actions.subscribe },
isLoading: { subscribe: isLoading.subscribe },
loadQuickActions,
saveQuickAction,
createQuickAction,
updateQuickAction,
deleteQuickAction,
resetDefaults,
};
}
export const quickActionsStore = createQuickActionsStore();
+708
View File
@@ -0,0 +1,708 @@
import { writable } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { save, open } from "@tauri-apps/plugin-dialog";
import type { Conversation } from "./conversations";
import { writeTextFile, readTextFile } from "@tauri-apps/plugin-fs";
import { openPath } from "@tauri-apps/plugin-opener";
// Debounce delay for auto-save (in milliseconds)
const AUTO_SAVE_DEBOUNCE_MS = 2000;
export interface SavedMessage {
id: string;
type: string;
content: string;
timestamp: string;
tool_name?: string;
}
export interface SavedSession {
id: string;
name: string;
created_at: string;
last_activity_at: string;
working_directory: string;
message_count: number;
preview: string;
messages: SavedMessage[];
}
export interface SessionListItem {
id: string;
name: string;
created_at: string;
last_activity_at: string;
working_directory: string;
message_count: number;
preview: string;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function generateHtmlExport(session: SavedSession, includeMetadata: boolean): string {
const styles = `
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-code: #1e1e2e;
--accent-primary: #e94560;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--border-color: #2a2a4a;
--terminal-user: #22d3ee;
--terminal-tool: #c084fc;
--terminal-error: #f87171;
--hljs-keyword: #f472b6;
--hljs-string: #a3e635;
--hljs-number: #fbbf24;
--hljs-comment: #6b7280;
--hljs-function: #c084fc;
--hljs-type: #22d3ee;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
padding: 2rem;
max-width: 900px;
margin: 0 auto;
}
h1 { color: var(--accent-primary); margin-bottom: 1rem; }
.metadata {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
border: 1px solid var(--border-color);
}
.metadata p { color: var(--text-secondary); margin: 0.25rem 0; }
.metadata strong { color: var(--text-primary); }
hr { border: none; border-top: 1px solid var(--border-color); margin: 1.5rem 0; }
.message {
background: var(--bg-secondary);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
border-left: 4px solid var(--border-color);
}
.message.user { border-left-color: var(--terminal-user); }
.message.assistant { border-left-color: var(--accent-primary); }
.message.tool_use { border-left-color: var(--terminal-tool); }
.message.tool_result { border-left-color: var(--hljs-string); }
.message.error { border-left-color: var(--terminal-error); }
.message.system { border-left-color: var(--text-secondary); }
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.message-type {
font-weight: bold;
text-transform: capitalize;
}
.message-type.user { color: var(--terminal-user); }
.message-type.assistant { color: var(--accent-primary); }
.message-type.tool_use { color: var(--terminal-tool); }
.message-type.tool_result { color: var(--hljs-string); }
.message-type.error { color: var(--terminal-error); }
.message-type.system { color: var(--text-secondary); }
.timestamp { color: var(--text-secondary); font-size: 0.85rem; }
.tool-name { color: var(--terminal-tool); font-size: 0.9rem; }
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9rem;
}
pre {
background: var(--bg-code);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0.5rem 0;
}
code {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
}
.footer {
text-align: center;
color: var(--text-secondary);
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #f8f9fa;
--bg-secondary: #ffffff;
--bg-code: #f5f5f5;
--text-primary: #1a1a2e;
--text-secondary: #5a5a7a;
--border-color: #d0d0e0;
--terminal-user: #0891b2;
--terminal-tool: #7c3aed;
--terminal-error: #dc2626;
--hljs-keyword: #d946ef;
--hljs-string: #16a34a;
}
}
`;
let messagesHtml = "";
for (const message of session.messages) {
const timestamp = new Date(message.timestamp).toLocaleString();
const typeLabel =
message.type === "tool_use"
? "Tool Use"
: message.type === "tool_result"
? "Tool Result"
: message.type.charAt(0).toUpperCase() + message.type.slice(1);
messagesHtml += `
<div class="message ${message.type}">
<div class="message-header">
<span class="message-type ${message.type}">${typeLabel}</span>
<span class="timestamp">${timestamp}</span>
</div>
${message.tool_name ? `<div class="tool-name">đź”§ ${escapeHtml(message.tool_name)}</div>` : ""}
<div class="message-content">${escapeHtml(message.content)}</div>
</div>
`;
}
const metadataHtml = includeMetadata
? `
<div class="metadata">
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
<p><strong>Messages:</strong> ${session.message_count}</p>
</div>
`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(session.name)} - Hikari Desktop Export</title>
<style>${styles}</style>
</head>
<body>
<h1>đź’¬ ${escapeHtml(session.name)}</h1>
${metadataHtml}
<hr>
${messagesHtml}
<div class="footer">
<p>✨ Exported from Hikari Desktop</p>
<p>Export date: ${new Date().toLocaleString()}</p>
</div>
</body>
</html>`;
}
function generatePrintableHtml(session: SavedSession, includeMetadata: boolean): string {
const printStyles = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 11pt;
line-height: 1.5;
padding: 0.5in;
max-width: 8.5in;
margin: 0 auto;
color: #000;
background: #fff;
}
h1 {
font-size: 18pt;
margin-bottom: 0.25in;
border-bottom: 2px solid #333;
padding-bottom: 0.1in;
}
.metadata {
background: #f5f5f5;
padding: 0.15in;
margin-bottom: 0.25in;
border: 1px solid #ddd;
font-size: 9pt;
}
.metadata p { margin: 2pt 0; }
hr { border: none; border-top: 1px solid #ccc; margin: 0.2in 0; }
.message {
margin-bottom: 0.15in;
padding: 0.1in;
border-left: 3px solid #ccc;
page-break-inside: avoid;
}
.message.user { border-left-color: #0088cc; }
.message.assistant { border-left-color: #cc0044; }
.message.tool_use { border-left-color: #8844cc; }
.message.tool_result { border-left-color: #44cc44; }
.message.error { border-left-color: #cc4444; }
.message.system { border-left-color: #888; }
.message-header {
display: flex;
justify-content: space-between;
font-size: 9pt;
color: #666;
margin-bottom: 0.05in;
border-bottom: 1px dotted #ddd;
padding-bottom: 0.03in;
}
.message-type { font-weight: bold; text-transform: uppercase; }
.message-type.user { color: #0066aa; }
.message-type.assistant { color: #aa0033; }
.message-type.tool_use { color: #6633aa; }
.message-type.tool_result { color: #33aa33; }
.message-type.error { color: #aa3333; }
.tool-name { color: #666; font-size: 9pt; font-style: italic; }
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 9pt;
background: #fafafa;
padding: 0.05in;
}
.footer {
text-align: center;
font-size: 8pt;
color: #999;
margin-top: 0.25in;
padding-top: 0.1in;
border-top: 1px solid #ddd;
}
.print-instructions {
background: #ffffcc;
border: 1px solid #cccc00;
padding: 0.15in;
margin-bottom: 0.25in;
font-size: 10pt;
}
.print-instructions h2 { font-size: 12pt; margin-bottom: 0.1in; }
.print-instructions ol { margin-left: 0.2in; }
.print-instructions li { margin: 0.05in 0; }
@media print {
.print-instructions { display: none; }
body { padding: 0; }
}
@page { margin: 0.5in; }
`;
let messagesHtml = "";
for (const message of session.messages) {
const timestamp = new Date(message.timestamp).toLocaleString();
const typeLabel =
message.type === "tool_use"
? "Tool"
: message.type === "tool_result"
? "Result"
: message.type.charAt(0).toUpperCase() + message.type.slice(1);
messagesHtml += `
<div class="message ${message.type}">
<div class="message-header">
<span class="message-type ${message.type}">${typeLabel}</span>
<span class="timestamp">${timestamp}</span>
</div>
${message.tool_name ? `<div class="tool-name">${escapeHtml(message.tool_name)}</div>` : ""}
<div class="message-content">${escapeHtml(message.content)}</div>
</div>
`;
}
const metadataHtml = includeMetadata
? `
<div class="metadata">
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
<p><strong>Messages:</strong> ${session.message_count}</p>
</div>
`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(session.name)} - Print to PDF</title>
<style>${printStyles}</style>
</head>
<body>
<div class="print-instructions">
<h2>Print to PDF Instructions</h2>
<ol>
<li>Press <strong>Ctrl+P</strong> (or <strong>Cmd+P</strong> on Mac) to open the print dialog</li>
<li>Select <strong>"Save as PDF"</strong> or <strong>"Microsoft Print to PDF"</strong> as the printer</li>
<li>Click <strong>Print</strong> to save your PDF</li>
</ol>
<p><em>This instruction box will not appear in your PDF.</em></p>
</div>
<h1>${escapeHtml(session.name)}</h1>
${metadataHtml}
<hr>
${messagesHtml}
<div class="footer">
<p>Exported from Hikari Desktop | ${new Date().toLocaleString()}</p>
</div>
</body>
</html>`;
}
function createSessionsStore() {
const sessions = writable<SessionListItem[]>([]);
const isLoading = writable(false);
const searchQuery = writable("");
// Track pending auto-save timeouts per conversation
const pendingAutoSaves = new Map<string, ReturnType<typeof setTimeout>>();
async function loadSessions(): Promise<void> {
isLoading.set(true);
try {
const result = await invoke<SessionListItem[]>("list_sessions");
sessions.set(result);
} catch (error) {
console.error("Failed to load sessions:", error);
} finally {
isLoading.set(false);
}
}
async function saveConversation(conversation: Conversation): Promise<void> {
const messages: SavedMessage[] = conversation.terminalLines.map((line) => ({
id: line.id,
type: line.type,
content: line.content,
timestamp: line.timestamp.toISOString(),
tool_name: line.toolName,
}));
const userAndAssistantMessages = conversation.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
const previewContent =
userAndAssistantMessages
.slice(0, 3)
.map((m) => m.content)
.join(" ")
.slice(0, 150) + (userAndAssistantMessages.length > 3 ? "..." : "");
const session: SavedSession = {
id: conversation.id,
name: conversation.name,
created_at: conversation.createdAt.toISOString(),
last_activity_at: conversation.lastActivityAt.toISOString(),
working_directory: conversation.workingDirectory,
message_count: conversation.terminalLines.length,
preview: previewContent || "Empty conversation",
messages,
};
try {
await invoke("save_session", { session });
await loadSessions();
} catch (error) {
console.error("Failed to save session:", error);
}
}
async function loadSession(sessionId: string): Promise<SavedSession | null> {
try {
const session = await invoke<SavedSession | null>("load_session", {
sessionId,
});
return session;
} catch (error) {
console.error("Failed to load session:", error);
return null;
}
}
async function deleteSession(sessionId: string): Promise<void> {
try {
await invoke("delete_session", { sessionId });
await loadSessions();
} catch (error) {
console.error("Failed to delete session:", error);
}
}
async function searchSessions(query: string): Promise<void> {
searchQuery.set(query);
if (!query.trim()) {
await loadSessions();
return;
}
isLoading.set(true);
try {
const result = await invoke<SessionListItem[]>("search_sessions", {
query,
});
sessions.set(result);
} catch (error) {
console.error("Failed to search sessions:", error);
} finally {
isLoading.set(false);
}
}
async function clearAllSessions(): Promise<void> {
try {
await invoke("clear_all_sessions");
sessions.set([]);
} catch (error) {
console.error("Failed to clear sessions:", error);
}
}
function scheduleAutoSave(conversation: Conversation): void {
// Don't auto-save empty conversations
if (conversation.terminalLines.length === 0) {
return;
}
// Clear any pending auto-save for this conversation
const existingTimeout = pendingAutoSaves.get(conversation.id);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
// Schedule a new auto-save
const timeout = setTimeout(async () => {
pendingAutoSaves.delete(conversation.id);
try {
await saveConversation(conversation);
} catch (error) {
console.error("Auto-save failed:", error);
}
}, AUTO_SAVE_DEBOUNCE_MS);
pendingAutoSaves.set(conversation.id, timeout);
}
function cancelAutoSave(conversationId: string): void {
const existingTimeout = pendingAutoSaves.get(conversationId);
if (existingTimeout) {
clearTimeout(existingTimeout);
pendingAutoSaves.delete(conversationId);
}
}
async function exportSessionAsJson(sessionId: string): Promise<boolean> {
try {
const session = await loadSession(sessionId);
if (!session) {
console.error("Session not found for export");
return false;
}
const filePath = await save({
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.json`,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!filePath) {
return false;
}
const exportData = {
version: 1,
exported_at: new Date().toISOString(),
session,
};
await writeTextFile(filePath, JSON.stringify(exportData, null, 2));
return true;
} catch (error) {
console.error("Failed to export session as JSON:", error);
return false;
}
}
async function exportSessionAsMarkdown(sessionId: string): Promise<boolean> {
try {
const session = await loadSession(sessionId);
if (!session) {
console.error("Session not found for export");
return false;
}
const filePath = await save({
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.md`,
filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (!filePath) {
return false;
}
let markdown = `# ${session.name}\n\n`;
markdown += `**Created:** ${new Date(session.created_at).toLocaleString()}\n`;
markdown += `**Last Activity:** ${new Date(session.last_activity_at).toLocaleString()}\n`;
markdown += `**Working Directory:** ${session.working_directory || "Not set"}\n`;
markdown += `**Messages:** ${session.message_count}\n\n`;
markdown += `---\n\n`;
for (const message of session.messages) {
const timestamp = new Date(message.timestamp).toLocaleTimeString();
if (message.type === "user") {
markdown += `## 👤 User (${timestamp})\n\n${message.content}\n\n`;
} else if (message.type === "assistant") {
markdown += `## 🤖 Assistant (${timestamp})\n\n${message.content}\n\n`;
} else if (message.type === "tool_use") {
markdown += `### đź”§ Tool: ${message.tool_name || "Unknown"} (${timestamp})\n\n\`\`\`\n${message.content}\n\`\`\`\n\n`;
} else if (message.type === "tool_result") {
markdown += `### đź“‹ Result (${timestamp})\n\n\`\`\`\n${message.content}\n\`\`\`\n\n`;
} else if (message.type === "system") {
markdown += `> **System:** ${message.content}\n\n`;
} else if (message.type === "error") {
markdown += `> ⚠️ **Error:** ${message.content}\n\n`;
}
}
markdown += `---\n\n*Exported from Hikari Desktop*\n`;
await writeTextFile(filePath, markdown);
return true;
} catch (error) {
console.error("Failed to export session as Markdown:", error);
return false;
}
}
async function exportSessionAsHtml(
sessionId: string,
includeMetadata: boolean = true
): Promise<boolean> {
try {
const session = await loadSession(sessionId);
if (!session) {
console.error("Session not found for export");
return false;
}
const filePath = await save({
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.html`,
filters: [{ name: "HTML", extensions: ["html"] }],
});
if (!filePath) {
return false;
}
const html = generateHtmlExport(session, includeMetadata);
await writeTextFile(filePath, html);
return true;
} catch (error) {
console.error("Failed to export session as HTML:", error);
return false;
}
}
async function exportSessionAsPdf(
sessionId: string,
includeMetadata: boolean = true
): Promise<boolean> {
try {
const session = await loadSession(sessionId);
if (!session) {
console.error("Session not found for export");
return false;
}
const filePath = await save({
defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}-print.html`,
filters: [{ name: "HTML (for PDF printing)", extensions: ["html"] }],
});
if (!filePath) {
return false;
}
const html = generatePrintableHtml(session, includeMetadata);
await writeTextFile(filePath, html);
// Open the file in the default browser for print-to-PDF
await openPath(filePath);
return true;
} catch (error) {
console.error("Failed to export session for PDF:", error);
return false;
}
}
async function importSession(): Promise<boolean> {
try {
const filePath = await open({
filters: [{ name: "JSON", extensions: ["json"] }],
multiple: false,
});
if (!filePath) {
return false;
}
const content = await readTextFile(filePath as string);
const importData = JSON.parse(content);
if (!importData.session || !importData.session.id) {
console.error("Invalid session file format");
return false;
}
const session: SavedSession = importData.session;
// Generate a new ID to avoid conflicts with existing sessions
session.id = `imported-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
session.name = `${session.name} (Imported)`;
await invoke("save_session", { session });
await loadSessions();
return true;
} catch (error) {
console.error("Failed to import session:", error);
return false;
}
}
return {
sessions: { subscribe: sessions.subscribe },
isLoading: { subscribe: isLoading.subscribe },
searchQuery: { subscribe: searchQuery.subscribe },
loadSessions,
saveConversation,
loadSession,
deleteSession,
searchSessions,
clearAllSessions,
scheduleAutoSave,
cancelAutoSave,
exportSessionAsJson,
exportSessionAsMarkdown,
exportSessionAsHtml,
exportSessionAsPdf,
importSession,
};
}
export const sessionsStore = createSessionsStore();
+138
View File
@@ -0,0 +1,138 @@
import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export interface Snippet {
id: string;
name: string;
content: string;
category: string;
is_default: boolean;
created_at: string;
updated_at: string;
}
function createSnippetsStore() {
const snippets = writable<Snippet[]>([]);
const categories = writable<string[]>([]);
const isLoading = writable(false);
const selectedCategory = writable<string | null>(null);
const filteredSnippets = derived(
[snippets, selectedCategory],
([$snippets, $selectedCategory]) => {
if (!$selectedCategory) {
return $snippets;
}
return $snippets.filter((s) => s.category === $selectedCategory);
}
);
async function loadSnippets(): Promise<void> {
isLoading.set(true);
try {
const [snippetList, categoryList] = await Promise.all([
invoke<Snippet[]>("list_snippets"),
invoke<string[]>("get_snippet_categories"),
]);
snippets.set(snippetList);
categories.set(categoryList);
} catch (error) {
console.error("Failed to load snippets:", error);
} finally {
isLoading.set(false);
}
}
async function saveSnippet(snippet: Snippet): Promise<boolean> {
try {
await invoke("save_snippet", { snippet });
await loadSnippets();
return true;
} catch (error) {
console.error("Failed to save snippet:", error);
return false;
}
}
async function createSnippet(name: string, content: string, category: string): Promise<boolean> {
const now = new Date().toISOString();
const snippet: Snippet = {
id: `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
name,
content,
category,
is_default: false,
created_at: now,
updated_at: now,
};
return saveSnippet(snippet);
}
async function updateSnippet(
id: string,
name: string,
content: string,
category: string
): Promise<boolean> {
const currentSnippets = await invoke<Snippet[]>("list_snippets");
const existing = currentSnippets.find((s) => s.id === id);
if (!existing) {
console.error("Snippet not found for update");
return false;
}
const updated: Snippet = {
...existing,
name,
content,
category,
updated_at: new Date().toISOString(),
};
return saveSnippet(updated);
}
async function deleteSnippet(snippetId: string): Promise<boolean> {
try {
await invoke("delete_snippet", { snippetId });
await loadSnippets();
return true;
} catch (error) {
console.error("Failed to delete snippet:", error);
return false;
}
}
async function resetDefaults(): Promise<boolean> {
try {
await invoke("reset_default_snippets");
await loadSnippets();
return true;
} catch (error) {
console.error("Failed to reset default snippets:", error);
return false;
}
}
function setSelectedCategory(category: string | null): void {
selectedCategory.set(category);
}
return {
snippets: { subscribe: snippets.subscribe },
categories: { subscribe: categories.subscribe },
filteredSnippets: { subscribe: filteredSnippets.subscribe },
isLoading: { subscribe: isLoading.subscribe },
selectedCategory: { subscribe: selectedCategory.subscribe },
loadSnippets,
saveSnippet,
createSnippet,
updateSnippet,
deleteSnippet,
resetDefaults,
setSelectedCategory,
};
}
export const snippetsStore = createSnippetsStore();
+181 -1
View File
@@ -13,6 +13,8 @@
import InputBar from "$lib/components/InputBar.svelte";
import StatusBar from "$lib/components/StatusBar.svelte";
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
import { characterState } from "$lib/stores/character";
import type { CharacterState } from "$lib/types/states";
import PermissionModal from "$lib/components/PermissionModal.svelte";
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
@@ -23,6 +25,32 @@
let initialized = false;
let updateNotification: UpdateNotification;
let achievementPanelOpen = $state(false);
let currentCharacterState: CharacterState = $state("idle");
characterState.subscribe((state) => {
currentCharacterState = state;
});
function getPanelGlowClass(): string {
switch (currentCharacterState) {
case "thinking":
return "panel-glow-thinking";
case "typing":
return "panel-glow-typing";
case "searching":
return "panel-glow-searching";
case "coding":
return "panel-glow-coding";
case "mcp":
return "panel-glow-mcp";
case "success":
return "panel-glow-success";
case "error":
return "panel-glow-error";
default:
return "";
}
}
// Resizable panel state
let panelWidth = $state(320); // Default width in pixels
@@ -190,7 +218,7 @@
<main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display -->
<div
class="character-panel flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
>
<AnimeGirl />
@@ -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,