generated from nhcarrigan/template
feat: add multiple productivity features and UI enhancements (#68)
## Summary This PR adds a collection of productivity features and UI enhancements to improve the Hikari Desktop experience: ### New Features - **Clipboard History** (#25) - Track and manage copied code snippets with language detection, search, filtering, and pinning - **Quick Actions Panel** (#15) - Buttons for common quick actions like "Review PR", "Run tests", "Explain file", with customizable actions - **Git Integration Panel** (#24) - View current branch, changed/staged files, quick git actions (commit, push, pull), and branch management - **Session Import/Export** (#8) - Export conversations to JSON and import previously saved sessions - **Snippet Library** (#22) - Save and reuse common prompts with categories and quick insert - **Session History** (#14) - Auto-save conversations with browsable history and search - **High Contrast Mode** (#20) - Accessibility theme with improved visibility - **Minimize to System Tray** (#11) - System tray support with right-click menu ### UI Enhancements - Trans-pride gradient theme applied across UI elements - Copy button added to code blocks - Linter formatting and eslint-disable comments for cleaner code ## Closes Closes #8 Closes #11 Closes #14 Closes #15 Closes #20 Closes #22 Closes #24 Closes #25 Closes #34 Closes #35 Closes #36 Closes #37 Closes #69 Closes #70 ## Test Plan - [ ] Verify clipboard history captures code from code block copy buttons - [ ] Verify clipboard history captures manually selected text from terminal - [ ] Test snippet library CRUD operations and insertion - [ ] Test quick actions panel with default and custom actions - [ ] Test git panel shows correct status, branch, and performs git operations - [ ] Test session history auto-save and restore - [ ] Test session import/export roundtrip - [ ] Verify high contrast mode provides adequate contrast - [ ] Test minimize to tray functionality and tray menu - [ ] Verify trans-pride gradient theme displays correctly in all themes --- *✨ This PR was created with help from Hikari~ 🌸* Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #68 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #68.
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-notification": "^2",
|
"@tauri-apps/plugin-notification": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-os": "^2",
|
"@tauri-apps/plugin-os": "^2",
|
||||||
|
|||||||
Generated
+10
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@tauri-apps/plugin-dialog':
|
'@tauri-apps/plugin-dialog':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
|
'@tauri-apps/plugin-fs':
|
||||||
|
specifier: ^2.4.5
|
||||||
|
version: 2.4.5
|
||||||
'@tauri-apps/plugin-notification':
|
'@tauri-apps/plugin-notification':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.3.3
|
version: 2.3.3
|
||||||
@@ -744,6 +747,9 @@ packages:
|
|||||||
'@tauri-apps/plugin-dialog@2.6.0':
|
'@tauri-apps/plugin-dialog@2.6.0':
|
||||||
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
|
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':
|
'@tauri-apps/plugin-notification@2.3.3':
|
||||||
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
|
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
|
||||||
|
|
||||||
@@ -2300,6 +2306,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.9.1
|
'@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':
|
'@tauri-apps/plugin-notification@2.3.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.9.1
|
'@tauri-apps/api': 2.9.1
|
||||||
|
|||||||
Generated
+2
@@ -1613,6 +1613,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-clipboard-manager",
|
"tauri-plugin-clipboard-manager",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
@@ -4180,6 +4181,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
@@ -27,6 +27,7 @@ tauri-plugin-notification = "2"
|
|||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
tauri-plugin-clipboard-manager = "2"
|
tauri-plugin-clipboard-manager = "2"
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
semver = "1"
|
semver = "1"
|
||||||
chrono = { version = "0.4.43", features = ["serde"] }
|
chrono = { version = "0.4.43", features = ["serde"] }
|
||||||
|
|||||||
@@ -15,6 +15,21 @@
|
|||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
"clipboard-manager:default",
|
"clipboard-manager:default",
|
||||||
"clipboard-manager:allow-read-image"
|
"clipboard-manager:allow-read-image",
|
||||||
|
"core:tray:default",
|
||||||
|
"fs:default",
|
||||||
|
"fs:allow-read-text-file",
|
||||||
|
"fs:allow-write-text-file",
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-file",
|
||||||
|
"allow": [{ "path": "**" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-write-file",
|
||||||
|
"allow": [{ "path": "**" }]
|
||||||
|
},
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-always-on-top",
|
||||||
|
"core:window:allow-inner-size"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1485
-2
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
@@ -102,6 +102,19 @@ pub async fn get_usage_stats(
|
|||||||
manager.get_usage_stats(&conversation_id)
|
manager.get_usage_stats(&conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load persisted lifetime stats from store (no bridge required)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_persisted_stats(app: AppHandle) -> Result<UsageStats, String> {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
|
||||||
|
// Load persisted stats if available
|
||||||
|
if let Some(persisted) = crate::stats::load_stats(&app).await {
|
||||||
|
stats.apply_persisted(persisted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn validate_directory(
|
pub async fn validate_directory(
|
||||||
path: String,
|
path: String,
|
||||||
|
|||||||
@@ -70,6 +70,32 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default = "default_font_size")]
|
#[serde(default = "default_font_size")]
|
||||||
pub font_size: u32,
|
pub font_size: u32,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub minimize_to_tray: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub streamer_mode: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub streamer_hide_paths: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub compact_mode: bool,
|
||||||
|
|
||||||
|
// Profile fields
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_name: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_avatar_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_bio: Option<String>,
|
||||||
|
|
||||||
|
// Custom theme colors
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_theme_colors: CustomThemeColors,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -89,6 +115,14 @@ impl Default for HikariConfig {
|
|||||||
update_checks_enabled: true,
|
update_checks_enabled: true,
|
||||||
character_panel_width: None,
|
character_panel_width: None,
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
|
minimize_to_tray: false,
|
||||||
|
streamer_mode: false,
|
||||||
|
streamer_hide_paths: false,
|
||||||
|
compact_mode: false,
|
||||||
|
profile_name: None,
|
||||||
|
profile_avatar_path: None,
|
||||||
|
profile_bio: None,
|
||||||
|
custom_theme_colors: CustomThemeColors::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +153,29 @@ pub enum Theme {
|
|||||||
#[default]
|
#[default]
|
||||||
Dark,
|
Dark,
|
||||||
Light,
|
Light,
|
||||||
|
#[serde(rename = "high-contrast")]
|
||||||
|
HighContrast,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct CustomThemeColors {
|
||||||
|
#[serde(default)]
|
||||||
|
pub bg_primary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bg_secondary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bg_terminal: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accent_primary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accent_secondary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub text_primary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub text_secondary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub border_color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -140,6 +197,14 @@ mod tests {
|
|||||||
assert!(config.update_checks_enabled);
|
assert!(config.update_checks_enabled);
|
||||||
assert!(config.character_panel_width.is_none());
|
assert!(config.character_panel_width.is_none());
|
||||||
assert_eq!(config.font_size, 14);
|
assert_eq!(config.font_size, 14);
|
||||||
|
assert!(!config.minimize_to_tray);
|
||||||
|
assert!(!config.streamer_mode);
|
||||||
|
assert!(!config.streamer_hide_paths);
|
||||||
|
assert!(!config.compact_mode);
|
||||||
|
assert!(config.profile_name.is_none());
|
||||||
|
assert!(config.profile_avatar_path.is_none());
|
||||||
|
assert!(config.profile_bio.is_none());
|
||||||
|
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -159,6 +224,14 @@ mod tests {
|
|||||||
update_checks_enabled: true,
|
update_checks_enabled: true,
|
||||||
character_panel_width: Some(400),
|
character_panel_width: Some(400),
|
||||||
font_size: 16,
|
font_size: 16,
|
||||||
|
minimize_to_tray: true,
|
||||||
|
streamer_mode: false,
|
||||||
|
streamer_hide_paths: false,
|
||||||
|
compact_mode: false,
|
||||||
|
profile_name: Some("Test User".to_string()),
|
||||||
|
profile_avatar_path: None,
|
||||||
|
profile_bio: Some("A test bio".to_string()),
|
||||||
|
custom_theme_colors: CustomThemeColors::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
@@ -179,8 +252,16 @@ mod tests {
|
|||||||
fn test_theme_serialization() {
|
fn test_theme_serialization() {
|
||||||
let dark = Theme::Dark;
|
let dark = Theme::Dark;
|
||||||
let light = Theme::Light;
|
let light = Theme::Light;
|
||||||
|
let high_contrast = Theme::HighContrast;
|
||||||
|
|
||||||
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
||||||
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&high_contrast).unwrap(),
|
||||||
|
"\"high-contrast\""
|
||||||
|
);
|
||||||
|
|
||||||
|
let custom = Theme::Custom;
|
||||||
|
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
mod achievements;
|
mod achievements;
|
||||||
mod bridge_manager;
|
mod bridge_manager;
|
||||||
|
mod clipboard;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod git;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
|
mod quick_actions;
|
||||||
|
mod sessions;
|
||||||
|
mod snippets;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod temp_manager;
|
mod temp_manager;
|
||||||
|
mod tray;
|
||||||
mod types;
|
mod types;
|
||||||
mod vbs_notification;
|
mod vbs_notification;
|
||||||
mod windows_toast;
|
mod windows_toast;
|
||||||
@@ -12,10 +18,17 @@ mod wsl_bridge;
|
|||||||
mod wsl_notifications;
|
mod wsl_notifications;
|
||||||
|
|
||||||
use bridge_manager::create_shared_bridge_manager;
|
use bridge_manager::create_shared_bridge_manager;
|
||||||
|
use clipboard::*;
|
||||||
use commands::load_saved_achievements;
|
use commands::load_saved_achievements;
|
||||||
use commands::*;
|
use commands::*;
|
||||||
|
use git::*;
|
||||||
use notifications::*;
|
use notifications::*;
|
||||||
|
use quick_actions::*;
|
||||||
|
use sessions::*;
|
||||||
|
use snippets::*;
|
||||||
|
use tauri::Manager;
|
||||||
use temp_manager::create_shared_temp_manager;
|
use temp_manager::create_shared_temp_manager;
|
||||||
|
use tray::{setup_tray, should_minimize_to_tray};
|
||||||
use vbs_notification::*;
|
use vbs_notification::*;
|
||||||
use windows_toast::*;
|
use windows_toast::*;
|
||||||
use wsl_notifications::*;
|
use wsl_notifications::*;
|
||||||
@@ -34,6 +47,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
.manage(bridge_manager.clone())
|
.manage(bridge_manager.clone())
|
||||||
.manage(temp_manager.clone())
|
.manage(temp_manager.clone())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
@@ -47,6 +61,27 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up system tray
|
||||||
|
if let Err(e) = setup_tray(app.handle()) {
|
||||||
|
eprintln!("Failed to set up system tray: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle window close event for minimize to tray
|
||||||
|
let main_window = app.get_webview_window("main").unwrap();
|
||||||
|
main_window.on_window_event({
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
move |event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
if should_minimize_to_tray(&app_handle) {
|
||||||
|
api.prevent_close();
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -60,6 +95,7 @@ pub fn run() {
|
|||||||
get_config,
|
get_config,
|
||||||
save_config,
|
save_config,
|
||||||
get_usage_stats,
|
get_usage_stats,
|
||||||
|
get_persisted_stats,
|
||||||
load_saved_achievements,
|
load_saved_achievements,
|
||||||
answer_question,
|
answer_question,
|
||||||
send_windows_notification,
|
send_windows_notification,
|
||||||
@@ -78,6 +114,43 @@ pub fn run() {
|
|||||||
cleanup_all_temp_files,
|
cleanup_all_temp_files,
|
||||||
cleanup_orphaned_temp_files,
|
cleanup_orphaned_temp_files,
|
||||||
get_file_size,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::achievements::{check_achievements, AchievementProgress};
|
use crate::achievements::{check_achievements, AchievementProgress};
|
||||||
|
use chrono::{Local, Timelike};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct UsageStats {
|
pub struct UsageStats {
|
||||||
@@ -28,6 +30,14 @@ pub struct UsageStats {
|
|||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub session_start: Option<Instant>,
|
pub session_start: Option<Instant>,
|
||||||
|
|
||||||
|
// Extended tracking for achievements
|
||||||
|
pub sessions_started: u64,
|
||||||
|
pub consecutive_days: u64,
|
||||||
|
pub total_days_used: u64,
|
||||||
|
pub morning_sessions: u64, // Sessions started before 9 AM
|
||||||
|
pub night_sessions: u64, // Sessions started after 10 PM
|
||||||
|
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
||||||
|
|
||||||
// Achievement tracking
|
// Achievement tracking
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub achievements: AchievementProgress,
|
pub achievements: AchievementProgress,
|
||||||
@@ -65,6 +75,47 @@ impl UsageStats {
|
|||||||
self.session_duration_seconds = 0;
|
self.session_duration_seconds = 0;
|
||||||
self.session_start = Some(Instant::now());
|
self.session_start = Some(Instant::now());
|
||||||
self.achievements.start_session();
|
self.achievements.start_session();
|
||||||
|
|
||||||
|
// Track session start for achievements
|
||||||
|
self.track_session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_session_start(&mut self) {
|
||||||
|
let now = Local::now();
|
||||||
|
let today = now.format("%Y-%m-%d").to_string();
|
||||||
|
let hour = now.hour();
|
||||||
|
|
||||||
|
// Increment session count
|
||||||
|
self.sessions_started += 1;
|
||||||
|
|
||||||
|
// Track morning/night sessions
|
||||||
|
if hour < 9 {
|
||||||
|
self.morning_sessions += 1;
|
||||||
|
}
|
||||||
|
if hour >= 22 {
|
||||||
|
self.night_sessions += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track consecutive days and total days
|
||||||
|
if let Some(last_date) = &self.last_session_date {
|
||||||
|
if last_date != &today {
|
||||||
|
// Check if it's the next day (consecutive)
|
||||||
|
if is_consecutive_day(last_date, &today) {
|
||||||
|
self.consecutive_days += 1;
|
||||||
|
} else {
|
||||||
|
// Streak broken
|
||||||
|
self.consecutive_days = 1;
|
||||||
|
}
|
||||||
|
self.total_days_used += 1;
|
||||||
|
self.last_session_date = Some(today);
|
||||||
|
}
|
||||||
|
// Same day - don't increment anything
|
||||||
|
} else {
|
||||||
|
// First session ever
|
||||||
|
self.consecutive_days = 1;
|
||||||
|
self.total_days_used = 1;
|
||||||
|
self.last_session_date = Some(today);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn increment_messages(&mut self) {
|
pub fn increment_messages(&mut self) {
|
||||||
@@ -127,12 +178,34 @@ impl UsageStats {
|
|||||||
session_tools_usage: self.session_tools_usage.clone(),
|
session_tools_usage: self.session_tools_usage.clone(),
|
||||||
session_duration_seconds: self.session_duration_seconds,
|
session_duration_seconds: self.session_duration_seconds,
|
||||||
session_start: self.session_start,
|
session_start: self.session_start,
|
||||||
|
sessions_started: self.sessions_started,
|
||||||
|
consecutive_days: self.consecutive_days,
|
||||||
|
total_days_used: self.total_days_used,
|
||||||
|
morning_sessions: self.morning_sessions,
|
||||||
|
night_sessions: self.night_sessions,
|
||||||
|
last_session_date: self.last_session_date.clone(),
|
||||||
achievements: AchievementProgress::new(), // Dummy for copy
|
achievements: AchievementProgress::new(), // Dummy for copy
|
||||||
};
|
};
|
||||||
check_achievements(&stats_copy, &mut self.achievements)
|
check_achievements(&stats_copy, &mut self.achievements)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to check if two dates are consecutive
|
||||||
|
fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
let prev = NaiveDate::parse_from_str(prev_date, "%Y-%m-%d").ok();
|
||||||
|
let current = NaiveDate::parse_from_str(current_date, "%Y-%m-%d").ok();
|
||||||
|
|
||||||
|
match (prev, current) {
|
||||||
|
(Some(p), Some(c)) => {
|
||||||
|
let diff = c.signed_duration_since(p).num_days();
|
||||||
|
diff == 1
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pricing as of January 2025
|
// Pricing as of January 2025
|
||||||
// https://www.anthropic.com/pricing
|
// https://www.anthropic.com/pricing
|
||||||
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
||||||
@@ -169,6 +242,111 @@ pub struct StatsUpdateEvent {
|
|||||||
pub stats: UsageStats,
|
pub stats: UsageStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serializable struct for persisting only lifetime (total) stats
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PersistedStats {
|
||||||
|
pub total_input_tokens: u64,
|
||||||
|
pub total_output_tokens: u64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub messages_exchanged: u64,
|
||||||
|
pub code_blocks_generated: u64,
|
||||||
|
pub files_edited: u64,
|
||||||
|
pub files_created: u64,
|
||||||
|
pub tools_usage: HashMap<String, u64>,
|
||||||
|
pub sessions_started: u64,
|
||||||
|
pub consecutive_days: u64,
|
||||||
|
pub total_days_used: u64,
|
||||||
|
pub morning_sessions: u64,
|
||||||
|
pub night_sessions: u64,
|
||||||
|
pub last_session_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&UsageStats> for PersistedStats {
|
||||||
|
fn from(stats: &UsageStats) -> Self {
|
||||||
|
PersistedStats {
|
||||||
|
total_input_tokens: stats.total_input_tokens,
|
||||||
|
total_output_tokens: stats.total_output_tokens,
|
||||||
|
total_cost_usd: stats.total_cost_usd,
|
||||||
|
messages_exchanged: stats.messages_exchanged,
|
||||||
|
code_blocks_generated: stats.code_blocks_generated,
|
||||||
|
files_edited: stats.files_edited,
|
||||||
|
files_created: stats.files_created,
|
||||||
|
tools_usage: stats.tools_usage.clone(),
|
||||||
|
sessions_started: stats.sessions_started,
|
||||||
|
consecutive_days: stats.consecutive_days,
|
||||||
|
total_days_used: stats.total_days_used,
|
||||||
|
morning_sessions: stats.morning_sessions,
|
||||||
|
night_sessions: stats.night_sessions,
|
||||||
|
last_session_date: stats.last_session_date.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageStats {
|
||||||
|
/// Apply persisted stats to restore lifetime totals
|
||||||
|
pub fn apply_persisted(&mut self, persisted: PersistedStats) {
|
||||||
|
self.total_input_tokens = persisted.total_input_tokens;
|
||||||
|
self.total_output_tokens = persisted.total_output_tokens;
|
||||||
|
self.total_cost_usd = persisted.total_cost_usd;
|
||||||
|
self.messages_exchanged = persisted.messages_exchanged;
|
||||||
|
self.code_blocks_generated = persisted.code_blocks_generated;
|
||||||
|
self.files_edited = persisted.files_edited;
|
||||||
|
self.files_created = persisted.files_created;
|
||||||
|
self.tools_usage = persisted.tools_usage;
|
||||||
|
self.sessions_started = persisted.sessions_started;
|
||||||
|
self.consecutive_days = persisted.consecutive_days;
|
||||||
|
self.total_days_used = persisted.total_days_used;
|
||||||
|
self.morning_sessions = persisted.morning_sessions;
|
||||||
|
self.night_sessions = persisted.night_sessions;
|
||||||
|
self.last_session_date = persisted.last_session_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save lifetime stats to persistent store
|
||||||
|
pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<(), String> {
|
||||||
|
let store = app.store("stats.json").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let persisted = PersistedStats::from(stats);
|
||||||
|
|
||||||
|
println!("Saving stats: {:?}", persisted);
|
||||||
|
|
||||||
|
store.set(
|
||||||
|
"lifetime_stats",
|
||||||
|
serde_json::to_value(persisted).map_err(|e| e.to_string())?,
|
||||||
|
);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
println!("Stats saved successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load lifetime stats from persistent store
|
||||||
|
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
|
||||||
|
println!("Loading stats from store...");
|
||||||
|
|
||||||
|
let store = match app.store("stats.json") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
println!("Failed to open stats store: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(stats_value) = store.get("lifetime_stats") {
|
||||||
|
println!("Found lifetime stats in store: {:?}", stats_value);
|
||||||
|
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
|
||||||
|
println!("Loaded lifetime stats successfully");
|
||||||
|
return Some(persisted);
|
||||||
|
} else {
|
||||||
|
println!("Failed to parse lifetime stats");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("No lifetime stats found in store");
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
AppHandle, Manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::config::HikariConfig;
|
||||||
|
|
||||||
|
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||||
|
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
|
||||||
|
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||||
|
|
||||||
|
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
|
||||||
|
|
||||||
|
let _tray = TrayIconBuilder::with_id("main")
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.menu(&menu)
|
||||||
|
.tooltip("Hikari - Claude Code Assistant")
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"show" => {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
let app = tray.app_handle();
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
|
||||||
|
let config_path = app
|
||||||
|
.path()
|
||||||
|
.app_config_dir()
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.join("hikari-config.json"));
|
||||||
|
|
||||||
|
if let Some(path) = config_path {
|
||||||
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||||
|
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
|
||||||
|
return config.minimize_to_tray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -112,7 +112,7 @@ impl WslBridge {
|
|||||||
return Err("Process already running".to_string());
|
return Err("Process already running".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load saved achievements when starting a new session
|
// Load saved achievements and stats when starting a new session
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let stats = self.stats.clone();
|
let stats = self.stats.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
@@ -122,7 +122,17 @@ impl WslBridge {
|
|||||||
"Loaded {} unlocked achievements",
|
"Loaded {} unlocked achievements",
|
||||||
achievements.unlocked.len()
|
achievements.unlocked.len()
|
||||||
);
|
);
|
||||||
stats.write().achievements = achievements;
|
|
||||||
|
println!("Loading saved stats...");
|
||||||
|
let persisted_stats = crate::stats::load_stats(&app_clone).await;
|
||||||
|
|
||||||
|
let mut stats_guard = stats.write();
|
||||||
|
stats_guard.achievements = achievements;
|
||||||
|
|
||||||
|
if let Some(persisted) = persisted_stats {
|
||||||
|
println!("Applying persisted lifetime stats");
|
||||||
|
stats_guard.apply_persisted(persisted);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let working_dir = &options.working_dir;
|
let working_dir = &options.working_dir;
|
||||||
@@ -440,6 +450,18 @@ impl WslBridge {
|
|||||||
self.session_id = None;
|
self.session_id = None;
|
||||||
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
||||||
|
|
||||||
|
// Save lifetime stats before resetting session
|
||||||
|
let stats_snapshot = self.stats.read().clone();
|
||||||
|
let app_clone = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
println!("Saving stats on session stop...");
|
||||||
|
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
|
||||||
|
eprintln!("Failed to save stats: {}", e);
|
||||||
|
} else {
|
||||||
|
println!("Stats saved successfully on session stop");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Reset session stats on explicit disconnect
|
// Reset session stats on explicit disconnect
|
||||||
self.stats.write().reset_session();
|
self.stats.write().reset_session();
|
||||||
|
|
||||||
@@ -733,10 +755,23 @@ fn process_json_line(
|
|||||||
|
|
||||||
let current_stats = stats.read().clone();
|
let current_stats = stats.read().clone();
|
||||||
let stats_event = StatsUpdateEvent {
|
let stats_event = StatsUpdateEvent {
|
||||||
stats: current_stats,
|
stats: current_stats.clone(),
|
||||||
};
|
};
|
||||||
let _ = app.emit("claude:stats", stats_event);
|
let _ = app.emit("claude:stats", stats_event);
|
||||||
|
|
||||||
|
// Save stats periodically (every 10 messages to avoid excessive disk writes)
|
||||||
|
if current_stats.session_messages_exchanged.is_multiple_of(10)
|
||||||
|
&& current_stats.session_messages_exchanged > 0
|
||||||
|
{
|
||||||
|
let app_handle = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
println!("Periodic stats save (every 10 messages)...");
|
||||||
|
if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await {
|
||||||
|
eprintln!("Failed to save stats: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Only emit error results - success content is already sent via Assistant message
|
// Only emit error results - success content is already sent via Assistant message
|
||||||
if subtype != "success" {
|
if subtype != "success" {
|
||||||
if let Some(text) = result {
|
if let Some(text) = result {
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
|
},
|
||||||
|
"trayIcon": {
|
||||||
|
"id": "main",
|
||||||
|
"iconPath": "icons/32x32.png",
|
||||||
|
"iconAsTemplate": false,
|
||||||
|
"tooltip": "Hikari - Claude Code Assistant"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
+136
@@ -14,6 +14,25 @@
|
|||||||
--text-tertiary: #6b7280;
|
--text-tertiary: #6b7280;
|
||||||
--border-color: #2a2a4a;
|
--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 specific colors */
|
||||||
--terminal-user: #22d3ee;
|
--terminal-user: #22d3ee;
|
||||||
--terminal-tool: #c084fc;
|
--terminal-tool: #c084fc;
|
||||||
@@ -44,6 +63,25 @@
|
|||||||
--text-tertiary: #9ca3af;
|
--text-tertiary: #9ca3af;
|
||||||
--border-color: #d0d0e0;
|
--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 specific colors */
|
||||||
--terminal-user: #0891b2;
|
--terminal-user: #0891b2;
|
||||||
--terminal-tool: #7c3aed;
|
--terminal-tool: #7c3aed;
|
||||||
@@ -61,6 +99,55 @@
|
|||||||
--hljs-meta: #64748b;
|
--hljs-meta: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="high-contrast"] {
|
||||||
|
--bg-primary: #000000;
|
||||||
|
--bg-secondary: #0a0a0a;
|
||||||
|
--bg-terminal: #000000;
|
||||||
|
--bg-hover: #1a1a1a;
|
||||||
|
--bg-code: #0a0a0a;
|
||||||
|
--accent-primary: #ff4d6d;
|
||||||
|
--accent-secondary: #ff85a1;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #e0e0e0;
|
||||||
|
--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;
|
||||||
|
--terminal-tool-name: #ffaaff;
|
||||||
|
--terminal-error: #ff5555;
|
||||||
|
|
||||||
|
/* Syntax highlighting colors (high contrast) */
|
||||||
|
--hljs-keyword: #ff66ff;
|
||||||
|
--hljs-string: #66ff66;
|
||||||
|
--hljs-number: #ffff00;
|
||||||
|
--hljs-comment: #aaaaaa;
|
||||||
|
--hljs-function: #ff99ff;
|
||||||
|
--hljs-type: #00ffff;
|
||||||
|
--hljs-variable: #ffaa00;
|
||||||
|
--hljs-meta: #cccccc;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -101,3 +188,52 @@ body {
|
|||||||
background: var(--accent-primary);
|
background: var(--accent-primary);
|
||||||
color: var(--text-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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,35 +34,12 @@
|
|||||||
return "animate-idle";
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
|
class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
|
||||||
>
|
>
|
||||||
<div
|
<div class="character-frame relative flex-1 flex items-center justify-center min-h-0">
|
||||||
class="character-frame relative {getBackgroundGlow()} flex-1 flex items-center justify-center min-h-0"
|
|
||||||
>
|
|
||||||
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
|
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src="/sprites/{info.spriteFile}"
|
src="/sprites/{info.spriteFile}"
|
||||||
@@ -97,37 +74,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.anime-girl-container {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.character-frame {
|
.character-frame {
|
||||||
border-radius: 50%;
|
transition: all 0.3s ease;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes idle-bob {
|
@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>
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||||
|
import { characterState, characterInfo } from "$lib/stores/character";
|
||||||
|
import { isStreamerMode } from "$lib/stores/config";
|
||||||
|
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||||
|
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onExpand: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onExpand }: Props = $props();
|
||||||
|
|
||||||
|
let inputValue = $state("");
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
let isConnected = $state(false);
|
||||||
|
let isProcessing = $state(false);
|
||||||
|
let streamerModeActive = $state(false);
|
||||||
|
let currentState: CharacterState = $state("idle");
|
||||||
|
let info: CharacterStateInfo = $state({
|
||||||
|
state: "idle",
|
||||||
|
label: "Ready",
|
||||||
|
description: "Waiting for your command~",
|
||||||
|
spriteFile: "idle.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recent messages for compact display
|
||||||
|
let recentMessages = $state<Array<{ type: string; content: string }>>([]);
|
||||||
|
const MAX_RECENT_MESSAGES = 3;
|
||||||
|
|
||||||
|
claudeStore.connectionStatus.subscribe((status) => {
|
||||||
|
isConnected = status === "connected";
|
||||||
|
});
|
||||||
|
|
||||||
|
isClaudeProcessing.subscribe((processing) => {
|
||||||
|
isProcessing = processing;
|
||||||
|
});
|
||||||
|
|
||||||
|
isStreamerMode.subscribe((value) => {
|
||||||
|
streamerModeActive = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
characterState.subscribe((state) => {
|
||||||
|
currentState = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
characterInfo.subscribe((i) => {
|
||||||
|
info = i;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track recent terminal output
|
||||||
|
claudeStore.terminalLines.subscribe((lines) => {
|
||||||
|
const recent = lines.slice(-MAX_RECENT_MESSAGES).map((line) => ({
|
||||||
|
type: line.type,
|
||||||
|
content: line.content.substring(0, 100) + (line.content.length > 100 ? "..." : ""),
|
||||||
|
}));
|
||||||
|
recentMessages = recent;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getAnimationClass(): string {
|
||||||
|
switch (currentState) {
|
||||||
|
case "thinking":
|
||||||
|
return "animate-thinking";
|
||||||
|
case "typing":
|
||||||
|
return "animate-typing";
|
||||||
|
case "searching":
|
||||||
|
return "animate-searching";
|
||||||
|
case "success":
|
||||||
|
return "animate-celebrate";
|
||||||
|
case "error":
|
||||||
|
return "animate-shake";
|
||||||
|
default:
|
||||||
|
return "animate-idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateGlow(): string {
|
||||||
|
switch (currentState) {
|
||||||
|
case "thinking":
|
||||||
|
return "glow-thinking";
|
||||||
|
case "typing":
|
||||||
|
return "glow-typing";
|
||||||
|
case "success":
|
||||||
|
return "glow-success";
|
||||||
|
case "error":
|
||||||
|
return "glow-error";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const message = inputValue.trim();
|
||||||
|
if (!message || isSubmitting || !isConnected) return;
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
inputValue = "";
|
||||||
|
|
||||||
|
handleNewUserMessage();
|
||||||
|
claudeStore.addLine("user", message);
|
||||||
|
characterState.setState("thinking");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) {
|
||||||
|
throw new Error("No active conversation");
|
||||||
|
}
|
||||||
|
await invoke("send_prompt", {
|
||||||
|
conversationId,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send prompt:", error);
|
||||||
|
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||||
|
characterState.setTemporaryState("error", 3000);
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInterrupt() {
|
||||||
|
try {
|
||||||
|
const conversationId = get(claudeStore.activeConversationId);
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
await invoke("interrupt_claude", { conversationId });
|
||||||
|
claudeStore.addLine("system", "Interrupted");
|
||||||
|
characterState.setState("idle");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to interrupt:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
handleSubmit(event);
|
||||||
|
}
|
||||||
|
// Escape expands to full mode
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onExpand();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="compact-container {getStateGlow()}">
|
||||||
|
<!-- Character sprite (smaller) -->
|
||||||
|
<div class="compact-character">
|
||||||
|
<div class="sprite-wrapper {getAnimationClass()}">
|
||||||
|
<img
|
||||||
|
src="/sprites/{info.spriteFile}"
|
||||||
|
alt="Hikari - {info.label}"
|
||||||
|
class="compact-sprite"
|
||||||
|
onerror={(e) => {
|
||||||
|
const target = e.currentTarget as HTMLImageElement;
|
||||||
|
target.src = "/sprites/placeholder.svg";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="state-badge">{info.label}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent message preview -->
|
||||||
|
<div class="message-preview">
|
||||||
|
{#if recentMessages.length > 0}
|
||||||
|
{#each recentMessages.slice(-1) as msg (msg.content)}
|
||||||
|
<div class="preview-message {msg.type}">
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="preview-message system">Ask me anything~</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compact input -->
|
||||||
|
<form onsubmit={handleSubmit} class="compact-input-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={inputValue}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
placeholder={isConnected ? "Quick message..." : "Not connected"}
|
||||||
|
disabled={isSubmitting || !isConnected}
|
||||||
|
class="compact-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="compact-buttons">
|
||||||
|
{#if isProcessing}
|
||||||
|
<button type="button" onclick={handleInterrupt} class="compact-btn stop-btn" title="Stop">
|
||||||
|
■
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isConnected || isSubmitting || !inputValue.trim()}
|
||||||
|
class="compact-btn send-btn"
|
||||||
|
title="Send"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="button" onclick={onExpand} class="compact-btn expand-btn" title="Expand (Esc)">
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="15 3 21 3 21 9"></polyline>
|
||||||
|
<polyline points="9 21 3 21 3 15"></polyline>
|
||||||
|
<line x1="21" y1="3" x2="14" y2="10"></line>
|
||||||
|
<line x1="3" y1="21" x2="10" y2="14"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Streamer mode indicator -->
|
||||||
|
{#if streamerModeActive}
|
||||||
|
<div class="compact-live-indicator" title="Streamer mode active"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.compact-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
|
||||||
|
padding: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-container::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 2px;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#fff 0 0) content-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
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;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-thinking {
|
||||||
|
box-shadow: inset 0 0 30px rgba(147, 51, 234, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-thinking::before {
|
||||||
|
background: linear-gradient(180deg, #9333ea, var(--trans-blue));
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-typing {
|
||||||
|
box-shadow: inset 0 0 30px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-typing::before {
|
||||||
|
background: linear-gradient(180deg, #3b82f6, var(--trans-pink));
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-success {
|
||||||
|
box-shadow: inset 0 0 30px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-success::before {
|
||||||
|
background: linear-gradient(180deg, #10b981, var(--trans-blue));
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-error {
|
||||||
|
box-shadow: inset 0 0 30px rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-error::before {
|
||||||
|
background: linear-gradient(180deg, #ef4444, var(--trans-pink));
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-character {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprite-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-sprite {
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-preview {
|
||||||
|
min-height: 24px;
|
||||||
|
max-height: 48px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-message {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-message.user {
|
||||||
|
color: var(--trans-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-message.assistant {
|
||||||
|
color: var(--trans-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-message.error {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-input-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-input:focus {
|
||||||
|
border-color: var(--trans-blue);
|
||||||
|
box-shadow: 0 0 0 2px rgba(91, 206, 250, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:not(:disabled) {
|
||||||
|
background: var(--trans-gradient-vibrant);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: rgb(239, 68, 68);
|
||||||
|
color: rgb(248, 113, 113);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-live-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(239, 68, 68);
|
||||||
|
animation: pulse-live 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-live {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Character animations (smaller scale for compact) */
|
||||||
|
@keyframes idle-bob {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes thinking-sway {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(-1deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(1deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typing-bounce {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-2px) scale(1.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes searching-look {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateX(-3px);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes celebrate {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05) rotate(3deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
25%,
|
||||||
|
75% {
|
||||||
|
transform: translateX(-3px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-idle {
|
||||||
|
animation: idle-bob 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-thinking {
|
||||||
|
animation: thinking-sway 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-typing {
|
||||||
|
animation: typing-bounce 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-searching {
|
||||||
|
animation: searching-look 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-celebrate {
|
||||||
|
animation: celebrate 0.8s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shake {
|
||||||
|
animation: shake 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
configStore,
|
configStore,
|
||||||
type HikariConfig,
|
type HikariConfig,
|
||||||
type Theme,
|
type Theme,
|
||||||
|
type CustomThemeColors,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
|
applyCustomThemeColors,
|
||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
@@ -23,11 +25,30 @@
|
|||||||
notifications_enabled: true,
|
notifications_enabled: true,
|
||||||
notification_volume: 0.7,
|
notification_volume: 0.7,
|
||||||
always_on_top: false,
|
always_on_top: false,
|
||||||
|
minimize_to_tray: false,
|
||||||
update_checks_enabled: true,
|
update_checks_enabled: true,
|
||||||
character_panel_width: null,
|
character_panel_width: null,
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
|
streamer_mode: false,
|
||||||
|
streamer_hide_paths: false,
|
||||||
|
compact_mode: false,
|
||||||
|
profile_name: null,
|
||||||
|
profile_avatar_path: null,
|
||||||
|
profile_bio: null,
|
||||||
|
custom_theme_colors: {
|
||||||
|
bg_primary: null,
|
||||||
|
bg_secondary: null,
|
||||||
|
bg_terminal: null,
|
||||||
|
accent_primary: null,
|
||||||
|
accent_secondary: null,
|
||||||
|
text_primary: null,
|
||||||
|
text_secondary: null,
|
||||||
|
border_color: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let showCustomThemeEditor = $state(false);
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
let saveError: string | null = $state(null);
|
let saveError: string | null = $state(null);
|
||||||
@@ -84,9 +105,33 @@
|
|||||||
|
|
||||||
async function handleThemeChange(theme: Theme) {
|
async function handleThemeChange(theme: Theme) {
|
||||||
config.theme = theme;
|
config.theme = theme;
|
||||||
await configStore.setTheme(theme);
|
showCustomThemeEditor = theme === "custom";
|
||||||
|
await configStore.setTheme(theme, config.custom_theme_colors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCustomColorChange(key: keyof CustomThemeColors, value: string) {
|
||||||
|
config.custom_theme_colors = {
|
||||||
|
...config.custom_theme_colors,
|
||||||
|
[key]: value || null,
|
||||||
|
};
|
||||||
|
// Live preview
|
||||||
|
if (config.theme === "custom") {
|
||||||
|
applyCustomThemeColors(config.custom_theme_colors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default dark theme colors for color picker defaults
|
||||||
|
const defaultDarkColors: Required<Record<keyof CustomThemeColors, string>> = {
|
||||||
|
bg_primary: "#1a1a2e",
|
||||||
|
bg_secondary: "#16213e",
|
||||||
|
bg_terminal: "#0f0f1a",
|
||||||
|
accent_primary: "#e94560",
|
||||||
|
accent_secondary: "#ff6b9d",
|
||||||
|
text_primary: "#ffffff",
|
||||||
|
text_secondary: "#a0a0a0",
|
||||||
|
border_color: "#2a2a4a",
|
||||||
|
};
|
||||||
|
|
||||||
function toggleTool(tool: string) {
|
function toggleTool(tool: string) {
|
||||||
if (config.auto_granted_tools.includes(tool)) {
|
if (config.auto_granted_tools.includes(tool)) {
|
||||||
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
|
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
|
||||||
@@ -186,47 +231,60 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
|
<label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
|
||||||
API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
|
API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
|
||||||
|
{#if config.streamer_mode}
|
||||||
|
<span class="text-yellow-500 text-xs ml-2">🔒 Hidden (Streamer Mode)</span>
|
||||||
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
{#if config.streamer_mode}
|
||||||
id="api-key"
|
<input
|
||||||
type={showApiKey ? "text" : "password"}
|
id="api-key"
|
||||||
bind:value={config.api_key}
|
type="password"
|
||||||
placeholder="Falls back to ~/.claude settings"
|
value="••••••••••••••••••••••••"
|
||||||
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
disabled
|
||||||
/>
|
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-tertiary)] focus:outline-none cursor-not-allowed"
|
||||||
<button
|
/>
|
||||||
type="button"
|
{:else}
|
||||||
onclick={() => (showApiKey = !showApiKey)}
|
<input
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
id="api-key"
|
||||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
type={showApiKey ? "text" : "password"}
|
||||||
>
|
bind:value={config.api_key}
|
||||||
{#if showApiKey}
|
placeholder="Falls back to ~/.claude settings"
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
<path
|
/>
|
||||||
stroke-linecap="round"
|
<button
|
||||||
stroke-linejoin="round"
|
type="button"
|
||||||
stroke-width="2"
|
onclick={() => (showApiKey = !showApiKey)}
|
||||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||||
/>
|
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||||
</svg>
|
>
|
||||||
{:else}
|
{#if showApiKey}
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
/>
|
/>
|
||||||
<path
|
</svg>
|
||||||
stroke-linecap="round"
|
{:else}
|
||||||
stroke-linejoin="round"
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
stroke-width="2"
|
<path
|
||||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
stroke-linecap="round"
|
||||||
/>
|
stroke-linejoin="round"
|
||||||
</svg>
|
stroke-width="2"
|
||||||
{/if}
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
</button>
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -385,7 +443,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={addCustomTool}
|
onclick={addCustomTool}
|
||||||
disabled={!newToolName.trim()}
|
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
|
Add
|
||||||
</button>
|
</button>
|
||||||
@@ -400,11 +458,12 @@
|
|||||||
|
|
||||||
<!-- Theme Selection -->
|
<!-- Theme Selection -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label>
|
<span class="block text-sm text-[var(--text-secondary)] mb-2">Theme</span>
|
||||||
<div class="flex gap-2">
|
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
|
||||||
<button
|
<button
|
||||||
onclick={() => handleThemeChange("dark")}
|
onclick={() => handleThemeChange("dark")}
|
||||||
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
|
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||||
|
'dark'
|
||||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
>
|
>
|
||||||
@@ -412,15 +471,187 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleThemeChange("light")}
|
onclick={() => handleThemeChange("light")}
|
||||||
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
|
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||||
|
'light'
|
||||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
>
|
>
|
||||||
Light
|
Light
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("high-contrast")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||||
|
'high-contrast'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="High contrast mode for improved accessibility"
|
||||||
|
>
|
||||||
|
Contrast
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("custom")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
||||||
|
'custom'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Create your own custom theme"
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Theme Editor -->
|
||||||
|
{#if config.theme === "custom" || showCustomThemeEditor}
|
||||||
|
<div class="mb-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
|
||||||
|
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">Custom Theme Colors</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="color-input-group">
|
||||||
|
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-primary"
|
||||||
|
>Background</label
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
id="color-bg-primary"
|
||||||
|
type="color"
|
||||||
|
value={config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
|
||||||
|
oninput={(e) => handleCustomColorChange("bg_primary", e.currentTarget.value)}
|
||||||
|
class="color-picker"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||||
|
{config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-input-group">
|
||||||
|
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-secondary"
|
||||||
|
>Secondary BG</label
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
id="color-bg-secondary"
|
||||||
|
type="color"
|
||||||
|
value={config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
|
||||||
|
oninput={(e) => handleCustomColorChange("bg_secondary", e.currentTarget.value)}
|
||||||
|
class="color-picker"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||||
|
{config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-input-group">
|
||||||
|
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-terminal"
|
||||||
|
>Terminal BG</label
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
id="color-bg-terminal"
|
||||||
|
type="color"
|
||||||
|
value={config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
|
||||||
|
oninput={(e) => handleCustomColorChange("bg_terminal", e.currentTarget.value)}
|
||||||
|
class="color-picker"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||||
|
{config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-input-group">
|
||||||
|
<label class="text-xs text-[var(--text-secondary)]" for="color-border">Border</label>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
id="color-border"
|
||||||
|
type="color"
|
||||||
|
value={config.custom_theme_colors.border_color || defaultDarkColors.border_color}
|
||||||
|
oninput={(e) => handleCustomColorChange("border_color", e.currentTarget.value)}
|
||||||
|
class="color-picker"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||||
|
{config.custom_theme_colors.border_color || defaultDarkColors.border_color}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-input-group">
|
||||||
|
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-primary"
|
||||||
|
>Accent Primary</label
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
id="color-accent-primary"
|
||||||
|
type="color"
|
||||||
|
value={config.custom_theme_colors.accent_primary ||
|
||||||
|
defaultDarkColors.accent_primary}
|
||||||
|
oninput={(e) => handleCustomColorChange("accent_primary", e.currentTarget.value)}
|
||||||
|
class="color-picker"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||||
|
{config.custom_theme_colors.accent_primary || defaultDarkColors.accent_primary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-input-group">
|
||||||
|
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-secondary"
|
||||||
|
>Accent Secondary</label
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
id="color-accent-secondary"
|
||||||
|
type="color"
|
||||||
|
value={config.custom_theme_colors.accent_secondary ||
|
||||||
|
defaultDarkColors.accent_secondary}
|
||||||
|
oninput={(e) =>
|
||||||
|
handleCustomColorChange("accent_secondary", e.currentTarget.value)}
|
||||||
|
class="color-picker"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||||
|
{config.custom_theme_colors.accent_secondary ||
|
||||||
|
defaultDarkColors.accent_secondary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-input-group">
|
||||||
|
<label class="text-xs text-[var(--text-secondary)]" for="color-text-primary"
|
||||||
|
>Text Primary</label
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
id="color-text-primary"
|
||||||
|
type="color"
|
||||||
|
value={config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
|
||||||
|
oninput={(e) => handleCustomColorChange("text_primary", e.currentTarget.value)}
|
||||||
|
class="color-picker"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||||
|
{config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="color-input-group">
|
||||||
|
<label class="text-xs text-[var(--text-secondary)]" for="color-text-secondary"
|
||||||
|
>Text Secondary</label
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
id="color-text-secondary"
|
||||||
|
type="color"
|
||||||
|
value={config.custom_theme_colors.text_secondary ||
|
||||||
|
defaultDarkColors.text_secondary}
|
||||||
|
oninput={(e) => handleCustomColorChange("text_secondary", e.currentTarget.value)}
|
||||||
|
class="color-picker"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||||
|
{config.custom_theme_colors.text_secondary || defaultDarkColors.text_secondary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-3">
|
||||||
|
Changes preview live. Click Save Settings to persist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Font Size -->
|
<!-- Font Size -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
|
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||||
@@ -477,6 +708,21 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Minimize to Tray Toggle -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.minimize_to_tray}
|
||||||
|
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Minimize to system tray</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Hide to tray instead of closing when you click the X button
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Update Checks Toggle -->
|
<!-- Update Checks Toggle -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
@@ -493,6 +739,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Privacy / Streamer Mode Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
|
Privacy / Streamer Mode
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Streamer Mode Toggle -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.streamer_mode}
|
||||||
|
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Enable streamer mode</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Hide sensitive information like API keys when streaming (Ctrl+Shift+S to toggle)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hide Paths Toggle -->
|
||||||
|
{#if config.streamer_mode}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.streamer_hide_paths}
|
||||||
|
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Also hide file paths</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Mask directory paths (e.g., /home/user → /home/****)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Notifications Section -->
|
<!-- Notifications Section -->
|
||||||
<section class="mb-6">
|
<section class="mb-6">
|
||||||
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
|
||||||
@@ -543,7 +828,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={handleSave}
|
onclick={handleSave}
|
||||||
disabled={isSaving}
|
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"}
|
{isSaving ? "Saving..." : "Save Settings"}
|
||||||
</button>
|
</button>
|
||||||
@@ -580,4 +865,39 @@
|
|||||||
background: var(--text-tertiary);
|
background: var(--text-tertiary);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Color picker styling */
|
||||||
|
.color-input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker::-webkit-color-swatch {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker::-moz-color-swatch {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -236,28 +236,32 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $conversations.size > 1}
|
<div
|
||||||
<button
|
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"
|
||||||
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"
|
{#if $conversations.size > 1}
|
||||||
title="Close tab"
|
<button
|
||||||
>
|
onclick={(e) => deleteTab(id, e)}
|
||||||
<svg
|
class="w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)]"
|
||||||
class="w-3 h-3"
|
title="Close tab"
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
stroke-linecap="round"
|
class="w-3 h-3"
|
||||||
stroke-linejoin="round"
|
fill="none"
|
||||||
stroke-width="2"
|
stroke="currentColor"
|
||||||
d="M6 18L18 6M6 6l12 12"
|
viewBox="0 0 24 24"
|
||||||
/>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</svg>
|
>
|
||||||
</button>
|
<path
|
||||||
{/if}
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
|||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||||
import { setSkipNextGreeting } from "$lib/tauri";
|
import { setSkipNextGreeting } from "$lib/tauri";
|
||||||
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
import {
|
import {
|
||||||
setShouldRestoreHistory,
|
setShouldRestoreHistory,
|
||||||
setSavedHistory,
|
setSavedHistory,
|
||||||
@@ -24,7 +25,11 @@
|
|||||||
isSlashCommand,
|
isSlashCommand,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
} from "$lib/commands/slashCommands";
|
} from "$lib/commands/slashCommands";
|
||||||
|
import { configStore, isStreamerMode } from "$lib/stores/config";
|
||||||
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
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";
|
import type { Attachment } from "$lib/types/messages";
|
||||||
|
|
||||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||||
@@ -39,6 +44,14 @@
|
|||||||
let selectedCommandIndex = $state(0);
|
let selectedCommandIndex = $state(0);
|
||||||
let attachments = $state<Attachment[]>([]);
|
let attachments = $state<Attachment[]>([]);
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
|
let showSnippetLibrary = $state(false);
|
||||||
|
let showQuickActions = $state(false);
|
||||||
|
let showClipboardHistory = $state(false);
|
||||||
|
let streamerModeActive = $state(false);
|
||||||
|
|
||||||
|
isStreamerMode.subscribe((value) => {
|
||||||
|
streamerModeActive = value;
|
||||||
|
});
|
||||||
|
|
||||||
// Input history state
|
// Input history state
|
||||||
let inputHistory = $state<string[]>([]);
|
let inputHistory = $state<string[]>([]);
|
||||||
@@ -500,6 +513,15 @@ User: ${formattedMessage}`;
|
|||||||
const items = event.clipboardData?.items;
|
const items = event.clipboardData?.items;
|
||||||
let handledFile = false;
|
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) {
|
if (items && items.length > 0) {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.kind === "file") {
|
if (item.kind === "file") {
|
||||||
@@ -617,6 +639,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) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
// Handle command menu navigation
|
// Handle command menu navigation
|
||||||
if (showCommandMenu && matchingCommands.length > 0) {
|
if (showCommandMenu && matchingCommands.length > 0) {
|
||||||
@@ -693,6 +771,99 @@ User: ${formattedMessage}`;
|
|||||||
|
|
||||||
<div class="input-controls flex gap-2 mb-2">
|
<div class="input-controls flex gap-2 mb-2">
|
||||||
<MessageModeSelector />
|
<MessageModeSelector />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => configStore.toggleStreamerMode()}
|
||||||
|
class="control-button streamer-toggle"
|
||||||
|
class:streamer-active={streamerModeActive}
|
||||||
|
title="Toggle Streamer Mode (Ctrl+Shift+S)"
|
||||||
|
>
|
||||||
|
{#if streamerModeActive}
|
||||||
|
<div class="live-indicator"></div>
|
||||||
|
<span>LIVE</span>
|
||||||
|
{:else}
|
||||||
|
<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
</svg>
|
||||||
|
<span>Stream</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
@@ -717,8 +888,7 @@ User: ${formattedMessage}`;
|
|||||||
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
|
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)]
|
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
|
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)]
|
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -744,7 +914,7 @@ User: ${formattedMessage}`;
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleInterrupt}
|
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)"
|
title="Interrupt the current response (Ctrl+C)"
|
||||||
>
|
>
|
||||||
<span class="font-bold">■</span> Stop
|
<span class="font-bold">■</span> Stop
|
||||||
@@ -755,7 +925,7 @@ User: ${formattedMessage}`;
|
|||||||
disabled={!isConnected ||
|
disabled={!isConnected ||
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
(!inputValue.trim() && attachments.length === 0)}
|
(!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"
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{#if isSubmitting}
|
{#if isSubmitting}
|
||||||
@@ -769,6 +939,25 @@ User: ${formattedMessage}`;
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
<style>
|
||||||
.input-bar {
|
.input-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -811,6 +1000,72 @@ User: ${formattedMessage}`;
|
|||||||
gap: 8px;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-toggle.streamer-active {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: rgb(239, 68, 68);
|
||||||
|
color: rgb(248, 113, 113);
|
||||||
|
animation: pulse-red 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamer-toggle.streamer-active:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.3);
|
||||||
|
border-color: rgb(248, 113, 113);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(239, 68, 68);
|
||||||
|
animation: pulse-dot 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-red {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.input-row {
|
.input-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -899,4 +1154,19 @@ User: ${formattedMessage}`;
|
|||||||
.send-button:active:not(:disabled) {
|
.send-button:active:not(:disabled) {
|
||||||
transform: scale(0.95);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
{ keys: ["Escape"], description: "Close modals and panels" },
|
{ keys: ["Escape"], description: "Close modals and panels" },
|
||||||
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
|
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
|
||||||
{ keys: ["Ctrl", ","], description: "Open settings" },
|
{ keys: ["Ctrl", ","], description: "Open settings" },
|
||||||
|
{ keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" },
|
||||||
|
{ keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -17,7 +18,20 @@
|
|||||||
renderer.code = ({ text, lang }) => {
|
renderer.code = ({ text, lang }) => {
|
||||||
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
|
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
|
||||||
const highlighted = hljs.highlight(text, { language }).value;
|
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, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
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 }) => {
|
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(/"/g, '"')
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/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(() => {
|
onMount(() => {
|
||||||
if (containerElement) {
|
if (containerElement) {
|
||||||
containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => {
|
containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => {
|
||||||
@@ -138,6 +180,7 @@
|
|||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
handleSpoilerClick(e);
|
handleSpoilerClick(e);
|
||||||
handleLinkClick(e);
|
handleLinkClick(e);
|
||||||
|
handleCopyClick(e);
|
||||||
}}
|
}}
|
||||||
onkeydown={handleSpoilerKeydown}
|
onkeydown={handleSpoilerKeydown}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
@@ -163,13 +206,59 @@
|
|||||||
margin-bottom: 0;
|
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) {
|
.markdown-content :global(.hljs-code-block) {
|
||||||
background: var(--bg-code, #1e1e2e);
|
background: var(--bg-code, #1e1e2e);
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
margin: 0.75em 0;
|
margin: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid var(--border-color);
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :global(.hljs-code-block code) {
|
.markdown-content :global(.hljs-code-block code) {
|
||||||
|
|||||||
@@ -0,0 +1,929 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { configStore, type HikariConfig } from "$lib/stores/config";
|
||||||
|
import { formattedStats } from "$lib/stores/stats";
|
||||||
|
import { achievementsStore } from "$lib/stores/achievements";
|
||||||
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { writeFile, readFile } from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
let config: HikariConfig;
|
||||||
|
configStore.config.subscribe((c) => (config = c));
|
||||||
|
|
||||||
|
let editingName = false;
|
||||||
|
let editingBio = false;
|
||||||
|
let nameInput = "";
|
||||||
|
let bioInput = "";
|
||||||
|
let avatarDataUrl: string | null = null;
|
||||||
|
|
||||||
|
// Initialize inputs when config is loaded
|
||||||
|
$: if (config) {
|
||||||
|
if (!editingName) nameInput = config.profile_name || "";
|
||||||
|
if (!editingBio) bioInput = config.profile_bio || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load avatar on mount and when path changes
|
||||||
|
let lastLoadedPath: string | null = null;
|
||||||
|
|
||||||
|
async function updateAvatarDisplay(path: string | null) {
|
||||||
|
if (path === lastLoadedPath) return;
|
||||||
|
lastLoadedPath = path;
|
||||||
|
if (path) {
|
||||||
|
avatarDataUrl = await loadAvatarAsDataUrl(path);
|
||||||
|
} else {
|
||||||
|
avatarDataUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: updateAvatarDisplay(config?.profile_avatar_path ?? null);
|
||||||
|
|
||||||
|
let isGeneratingImage = false;
|
||||||
|
|
||||||
|
$: unlockedCount = Object.values($achievementsStore.achievements).filter(
|
||||||
|
(a) => a.unlocked
|
||||||
|
).length;
|
||||||
|
$: totalAchievements = Object.values($achievementsStore.achievements).length;
|
||||||
|
$: achievementPercentage =
|
||||||
|
totalAchievements > 0 ? Math.round((unlockedCount / totalAchievements) * 100) : 0;
|
||||||
|
|
||||||
|
async function selectAvatar() {
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "Images",
|
||||||
|
extensions: ["png", "jpg", "jpeg", "gif", "webp"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === "string") {
|
||||||
|
await configStore.updateConfig({ profile_avatar_path: selected });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to select avatar:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAvatar() {
|
||||||
|
await configStore.updateConfig({ profile_avatar_path: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveName() {
|
||||||
|
await configStore.updateConfig({ profile_name: nameInput || null });
|
||||||
|
editingName = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBio() {
|
||||||
|
await configStore.updateConfig({ profile_bio: bioInput || null });
|
||||||
|
editingBio = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvatarAsDataUrl(path: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const data = await readFile(path);
|
||||||
|
const extension = path.split(".").pop()?.toLowerCase() || "png";
|
||||||
|
const mimeType =
|
||||||
|
extension === "jpg" || extension === "jpeg"
|
||||||
|
? "image/jpeg"
|
||||||
|
: extension === "gif"
|
||||||
|
? "image/gif"
|
||||||
|
: extension === "webp"
|
||||||
|
? "image/webp"
|
||||||
|
: "image/png";
|
||||||
|
// Convert Uint8Array to base64 in chunks to avoid stack overflow
|
||||||
|
const blob = new Blob([data], { type: mimeType });
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => resolve(null);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load avatar:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateShareImage(): Promise<Blob> {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
// Card dimensions (1080p for sharing)
|
||||||
|
const width = 1920;
|
||||||
|
const height = 1080;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// Background gradient (dark theme)
|
||||||
|
const bgGradient = ctx.createLinearGradient(0, 0, width, height);
|
||||||
|
bgGradient.addColorStop(0, "#1a1a2e");
|
||||||
|
bgGradient.addColorStop(1, "#16213e");
|
||||||
|
ctx.fillStyle = bgGradient;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Trans flag stripe accent at top
|
||||||
|
const stripeHeight = 25;
|
||||||
|
const stripeColors = ["#5bcefa", "#f5a9b8", "#ffffff", "#f5a9b8", "#5bcefa"];
|
||||||
|
stripeColors.forEach((color, i) => {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(0, i * (stripeHeight / 5) * 2, width, (stripeHeight / 5) * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = "#5bcefa";
|
||||||
|
ctx.lineWidth = 6;
|
||||||
|
ctx.strokeRect(3, 3, width - 6, height - 6);
|
||||||
|
|
||||||
|
// Avatar circle
|
||||||
|
const avatarX = 200;
|
||||||
|
const avatarY = 220;
|
||||||
|
const avatarRadius = 140;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(avatarX, avatarY, avatarRadius, 0, Math.PI * 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
// Draw avatar if available, otherwise gradient placeholder
|
||||||
|
let avatarLoaded = false;
|
||||||
|
if (config.profile_avatar_path) {
|
||||||
|
try {
|
||||||
|
const dataUrl = await loadAvatarAsDataUrl(config.profile_avatar_path);
|
||||||
|
if (dataUrl) {
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject();
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
avatarX - avatarRadius,
|
||||||
|
avatarY - avatarRadius,
|
||||||
|
avatarRadius * 2,
|
||||||
|
avatarRadius * 2
|
||||||
|
);
|
||||||
|
avatarLoaded = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Will use fallback gradient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!avatarLoaded) {
|
||||||
|
const avatarGradient = ctx.createLinearGradient(
|
||||||
|
avatarX - avatarRadius,
|
||||||
|
avatarY - avatarRadius,
|
||||||
|
avatarX + avatarRadius,
|
||||||
|
avatarY + avatarRadius
|
||||||
|
);
|
||||||
|
avatarGradient.addColorStop(0, "#5bcefa");
|
||||||
|
avatarGradient.addColorStop(0.5, "#f5a9b8");
|
||||||
|
avatarGradient.addColorStop(1, "#5bcefa");
|
||||||
|
ctx.fillStyle = avatarGradient;
|
||||||
|
ctx.fillRect(
|
||||||
|
avatarX - avatarRadius,
|
||||||
|
avatarY - avatarRadius,
|
||||||
|
avatarRadius * 2,
|
||||||
|
avatarRadius * 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Avatar border
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(avatarX, avatarY, avatarRadius + 6, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = "#f5a9b8";
|
||||||
|
ctx.lineWidth = 8;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Name
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "bold 72px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText(config.profile_name || "Hikari User", 400, 180);
|
||||||
|
|
||||||
|
// Bio (truncated)
|
||||||
|
ctx.fillStyle = "#9ca3af";
|
||||||
|
ctx.font = "36px system-ui, -apple-system, sans-serif";
|
||||||
|
const bio = config.profile_bio || "A Hikari Desktop user";
|
||||||
|
const truncatedBio = bio.length > 100 ? bio.substring(0, 97) + "..." : bio;
|
||||||
|
ctx.fillText(truncatedBio, 400, 260);
|
||||||
|
|
||||||
|
// Stats section
|
||||||
|
const statsY = 420;
|
||||||
|
ctx.fillStyle = "#6b7280";
|
||||||
|
ctx.font = "bold 32px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText("LIFETIME STATS", 80, statsY);
|
||||||
|
|
||||||
|
// Stats grid
|
||||||
|
const stats = [
|
||||||
|
{ label: "Messages", value: $formattedStats.messagesTotal },
|
||||||
|
{ label: "Tokens", value: $formattedStats.totalTokens },
|
||||||
|
{ label: "Code Blocks", value: $formattedStats.codeBlocksTotal },
|
||||||
|
{ label: "Files Edited", value: $formattedStats.filesEditedTotal },
|
||||||
|
{ label: "Files Created", value: $formattedStats.filesCreatedTotal },
|
||||||
|
{ label: "Total Cost", value: $formattedStats.totalCost },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statBoxWidth = 540;
|
||||||
|
const statBoxHeight = 160;
|
||||||
|
const statsPerRow = 3;
|
||||||
|
const startX = 80;
|
||||||
|
const startY = statsY + 40;
|
||||||
|
|
||||||
|
stats.forEach((stat, i) => {
|
||||||
|
const row = Math.floor(i / statsPerRow);
|
||||||
|
const col = i % statsPerRow;
|
||||||
|
const x = startX + col * (statBoxWidth + 50);
|
||||||
|
const y = startY + row * (statBoxHeight + 30);
|
||||||
|
|
||||||
|
// Stat box background
|
||||||
|
ctx.fillStyle = "rgba(255, 255, 255, 0.05)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(x, y, statBoxWidth, statBoxHeight, 20);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Stat value
|
||||||
|
ctx.fillStyle = "#5bcefa";
|
||||||
|
ctx.font = "bold 56px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText(stat.value, x + 32, y + 75);
|
||||||
|
|
||||||
|
// Stat label
|
||||||
|
ctx.fillStyle = "#9ca3af";
|
||||||
|
ctx.font = "28px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText(stat.label.toUpperCase(), x + 32, y + 128);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Achievement progress
|
||||||
|
const achieveY = 870;
|
||||||
|
ctx.fillStyle = "#6b7280";
|
||||||
|
ctx.font = "bold 32px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText("ACHIEVEMENTS", 80, achieveY);
|
||||||
|
|
||||||
|
// Progress bar background
|
||||||
|
const progressX = 80;
|
||||||
|
const progressY = achieveY + 30;
|
||||||
|
const progressWidth = 1200;
|
||||||
|
const progressHeight = 44;
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(255, 255, 255, 0.1)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(progressX, progressY, progressWidth, progressHeight, 22);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Progress bar fill
|
||||||
|
const progressGradient = ctx.createLinearGradient(progressX, 0, progressX + progressWidth, 0);
|
||||||
|
progressGradient.addColorStop(0, "#5bcefa");
|
||||||
|
progressGradient.addColorStop(0.5, "#f5a9b8");
|
||||||
|
progressGradient.addColorStop(1, "#5bcefa");
|
||||||
|
ctx.fillStyle = progressGradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(
|
||||||
|
progressX,
|
||||||
|
progressY,
|
||||||
|
progressWidth * (achievementPercentage / 100),
|
||||||
|
progressHeight,
|
||||||
|
22
|
||||||
|
);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Progress text
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "bold 36px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText(
|
||||||
|
`${unlockedCount} / ${totalAchievements} (${achievementPercentage}%)`,
|
||||||
|
progressX + progressWidth + 40,
|
||||||
|
progressY + 34
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hikari branding
|
||||||
|
ctx.fillStyle = "#f5a9b8";
|
||||||
|
ctx.font = "bold 42px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText("✨ Hikari Desktop", 80, height - 100);
|
||||||
|
|
||||||
|
// Discord promo
|
||||||
|
ctx.fillStyle = "#9ca3af";
|
||||||
|
ctx.font = "34px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText("Join our community: chat.nhcarrigan.com", 80, height - 45);
|
||||||
|
|
||||||
|
// Convert canvas to blob
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
canvas.toBlob((blob) => resolve(blob!), "image/png");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareProfile() {
|
||||||
|
isGeneratingImage = true;
|
||||||
|
try {
|
||||||
|
const blob = await generateShareImage();
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
const filePath = await save({
|
||||||
|
filters: [{ name: "PNG Image", extensions: ["png"] }],
|
||||||
|
defaultPath: `hikari-profile-${config.profile_name?.replace(/\s+/g, "-").toLowerCase() || "user"}.png`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
await writeFile(filePath, uint8Array);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate share image:", error);
|
||||||
|
} finally {
|
||||||
|
isGeneratingImage = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="profile-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div class="profile-panel">
|
||||||
|
<div class="profile-header">
|
||||||
|
<h2>Profile</h2>
|
||||||
|
<button class="close-btn" on:click={onClose} aria-label="Close profile">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-content">
|
||||||
|
<!-- Avatar Section -->
|
||||||
|
<div class="avatar-section">
|
||||||
|
<div
|
||||||
|
class="avatar-container"
|
||||||
|
on:click={selectAvatar}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
on:keydown={(e) => e.key === "Enter" && selectAvatar()}
|
||||||
|
>
|
||||||
|
{#if avatarDataUrl}
|
||||||
|
<img src={avatarDataUrl} alt="Profile avatar" class="avatar-image" />
|
||||||
|
<div class="avatar-overlay">
|
||||||
|
<span>Change</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="avatar-placeholder">
|
||||||
|
<svg
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span>Add Photo</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if config.profile_avatar_path}
|
||||||
|
<button class="remove-avatar-btn" on:click={removeAvatar}>Remove Photo</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name Section -->
|
||||||
|
<div class="name-section">
|
||||||
|
{#if editingName}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={nameInput}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
class="name-input"
|
||||||
|
on:keydown={(e) => e.key === "Enter" && saveName()}
|
||||||
|
on:blur={saveName}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="name-display"
|
||||||
|
on:click={() => {
|
||||||
|
editingName = true;
|
||||||
|
nameInput = config.profile_name || "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="name-text">{config.profile_name || "Click to add name"}</span>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bio Section -->
|
||||||
|
<div class="bio-section">
|
||||||
|
<h3>Bio</h3>
|
||||||
|
{#if editingBio}
|
||||||
|
<textarea
|
||||||
|
bind:value={bioInput}
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
class="bio-input"
|
||||||
|
rows="3"
|
||||||
|
on:blur={saveBio}
|
||||||
|
></textarea>
|
||||||
|
<button class="save-bio-btn btn-trans-gradient" on:click={saveBio}>Save</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="bio-display"
|
||||||
|
on:click={() => {
|
||||||
|
editingBio = true;
|
||||||
|
bioInput = config.profile_bio || "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="bio-text">{config.profile_bio || "Click to add a bio..."}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<div class="stats-section">
|
||||||
|
<h3>Lifetime Stats</h3>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.messagesTotal}</span>
|
||||||
|
<span class="stat-label">Messages</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.totalTokens}</span>
|
||||||
|
<span class="stat-label">Tokens</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.codeBlocksTotal}</span>
|
||||||
|
<span class="stat-label">Code Blocks</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.filesEditedTotal}</span>
|
||||||
|
<span class="stat-label">Files Edited</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.filesCreatedTotal}</span>
|
||||||
|
<span class="stat-label">Files Created</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.totalCost}</span>
|
||||||
|
<span class="stat-label">Total Cost</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Achievements Section -->
|
||||||
|
<div class="achievements-section">
|
||||||
|
<h3>Achievements</h3>
|
||||||
|
<div class="achievement-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {achievementPercentage}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text"
|
||||||
|
>{unlockedCount} / {totalAchievements} ({achievementPercentage}%)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Share Section -->
|
||||||
|
<div class="share-section">
|
||||||
|
<button
|
||||||
|
class="share-btn btn-trans-gradient"
|
||||||
|
on:click={shareProfile}
|
||||||
|
disabled={isGeneratingImage}
|
||||||
|
>
|
||||||
|
{#if isGeneratingImage}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
Generating...
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||||
|
<polyline points="16 6 12 2 8 6" />
|
||||||
|
<line x1="12" y1="2" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
Share Profile
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<p class="share-hint">Generate a shareable image of your profile</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.profile-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-content {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.avatar-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 3px solid var(--trans-pink);
|
||||||
|
box-shadow: 0 0 20px rgba(245, 169, 184, 0.3);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container:hover {
|
||||||
|
border-color: var(--trans-blue);
|
||||||
|
box-shadow: 0 0 30px rgba(91, 206, 250, 0.4);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container:hover .avatar-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-avatar-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-avatar-btn:hover {
|
||||||
|
color: var(--trans-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Name */
|
||||||
|
.name-section {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-display {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-display:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-display svg {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-display:hover svg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-text {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 2px solid var(--trans-pink);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input:focus {
|
||||||
|
border-color: var(--trans-blue);
|
||||||
|
box-shadow: 0 0 10px rgba(91, 206, 250, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bio */
|
||||||
|
.bio-section h3,
|
||||||
|
.stats-section h3,
|
||||||
|
.achievements-section h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-display {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-display:hover {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 2px solid var(--trans-pink);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-input:focus {
|
||||||
|
border-color: var(--trans-blue);
|
||||||
|
box-shadow: 0 0 10px rgba(91, 206, 250, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bio-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: linear-gradient(135deg, var(--trans-blue), var(--trans-pink));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Achievements */
|
||||||
|
.achievement-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--trans-blue),
|
||||||
|
var(--trans-pink),
|
||||||
|
var(--trans-white),
|
||||||
|
var(--trans-pink),
|
||||||
|
var(--trans-blue)
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 3s linear infinite;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Share */
|
||||||
|
.share-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
<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;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
<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;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
<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;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<span class="stat-label">Messages:</span>
|
<span class="stat-label">Messages:</span>
|
||||||
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
||||||
<span class="stat-secondary">/ {$formattedStats.messagesTotal}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
@@ -32,11 +31,6 @@
|
|||||||
<span class="stat-label">Output:</span>
|
<span class="stat-label">Output:</span>
|
||||||
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
|
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row stat-highlight">
|
|
||||||
<span class="stat-label">Total:</span>
|
|
||||||
<span class="stat-value">{$formattedStats.totalTokens}</span>
|
|
||||||
<span class="stat-cost">{$formattedStats.totalCost}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
@@ -44,17 +38,14 @@
|
|||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Code blocks:</span>
|
<span class="stat-label">Code blocks:</span>
|
||||||
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
|
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
|
||||||
<span class="stat-secondary">/ {$formattedStats.codeBlocksTotal}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Files edited:</span>
|
<span class="stat-label">Files edited:</span>
|
||||||
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
|
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
|
||||||
<span class="stat-secondary">/ {$formattedStats.filesEditedTotal}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Files created:</span>
|
<span class="stat-label">Files created:</span>
|
||||||
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
|
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
|
||||||
<span class="stat-secondary">/ {$formattedStats.filesCreatedTotal}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,14 +119,6 @@
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-highlight {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent-primary);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
padding-top: 0.25rem;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
color: var(--text-secondary, #9ca3af);
|
color: var(--text-secondary, #9ca3af);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
onToggleAchievements?: () => void;
|
onToggleAchievements?: () => void;
|
||||||
|
onToggleCompact?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { onToggleAchievements = () => {} }: Props = $props();
|
const { onToggleAchievements = () => {}, onToggleCompact = () => {} }: Props = $props();
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { configStore, type HikariConfig } from "$lib/stores/config";
|
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
|
||||||
import type { ConnectionStatus } from "$lib/types/messages";
|
import type { ConnectionStatus } from "$lib/types/messages";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import StatsDisplay from "./StatsDisplay.svelte";
|
import StatsDisplay from "./StatsDisplay.svelte";
|
||||||
@@ -19,6 +20,9 @@
|
|||||||
import HelpPanel from "./HelpPanel.svelte";
|
import HelpPanel from "./HelpPanel.svelte";
|
||||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||||
import { achievementProgress } from "$lib/stores/achievements";
|
import { achievementProgress } from "$lib/stores/achievements";
|
||||||
|
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||||
|
import GitPanel from "./GitPanel.svelte";
|
||||||
|
import ProfilePanel from "./ProfilePanel.svelte";
|
||||||
|
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||||
@@ -33,6 +37,9 @@
|
|||||||
let showAbout = $state(false);
|
let showAbout = $state(false);
|
||||||
let showHelp = $state(false);
|
let showHelp = $state(false);
|
||||||
let showKeyboardShortcuts = $state(false);
|
let showKeyboardShortcuts = $state(false);
|
||||||
|
let showSessionHistory = $state(false);
|
||||||
|
let showGitPanel = $state(false);
|
||||||
|
let showProfile = $state(false);
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
let currentConfig: HikariConfig = $state({
|
let currentConfig: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
@@ -49,6 +56,28 @@
|
|||||||
update_checks_enabled: true,
|
update_checks_enabled: true,
|
||||||
character_panel_width: null,
|
character_panel_width: null,
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
|
minimize_to_tray: false,
|
||||||
|
streamer_mode: false,
|
||||||
|
streamer_hide_paths: false,
|
||||||
|
compact_mode: false,
|
||||||
|
profile_name: null,
|
||||||
|
profile_avatar_path: null,
|
||||||
|
profile_bio: null,
|
||||||
|
custom_theme_colors: {
|
||||||
|
bg_primary: null,
|
||||||
|
bg_secondary: null,
|
||||||
|
bg_terminal: null,
|
||||||
|
accent_primary: null,
|
||||||
|
accent_secondary: null,
|
||||||
|
text_primary: null,
|
||||||
|
text_secondary: null,
|
||||||
|
border_color: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let streamerModeActive = $state(false);
|
||||||
|
isStreamerMode.subscribe((value) => {
|
||||||
|
streamerModeActive = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -202,9 +231,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
{#if streamerModeActive}
|
||||||
|
<div
|
||||||
|
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
|
||||||
|
title="Streamer mode active (Ctrl+Shift+S to toggle)"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={() => (showProfile = true)}
|
||||||
|
class="p-1 text-gray-500 icon-trans-hover"
|
||||||
|
title="Profile"
|
||||||
|
>
|
||||||
|
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={onToggleCompact}
|
||||||
|
class="p-1 text-gray-500 icon-trans-hover"
|
||||||
|
title="Compact Mode (Ctrl+Shift+M)"
|
||||||
|
>
|
||||||
|
<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="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={toggleAchievements}
|
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"
|
title="Achievements"
|
||||||
>
|
>
|
||||||
<span class="text-lg">🏆</span>
|
<span class="text-lg">🏆</span>
|
||||||
@@ -216,11 +279,37 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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
|
<button
|
||||||
onclick={() => (showStats = !showStats)}
|
onclick={() => (showStats = !showStats)}
|
||||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
|
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||||
? 'text-[var(--accent-primary)]'
|
|
||||||
: ''}"
|
|
||||||
title="Usage Stats"
|
title="Usage Stats"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -234,7 +323,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={configStore.openSidebar}
|
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"
|
title="Settings"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -254,7 +343,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => openUrl(DONATE_URL)}
|
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"
|
title="Support our work"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -265,7 +354,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (showAbout = true)}
|
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"
|
title="About Hikari Desktop"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -279,7 +368,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (showKeyboardShortcuts = true)}
|
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"
|
title="Keyboard Shortcuts"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -299,7 +388,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (showHelp = true)}
|
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"
|
title="Help"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -313,7 +402,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => openUrl(DISCORD_URL)}
|
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"
|
title="Join our Discord"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -370,3 +459,15 @@
|
|||||||
{#if showKeyboardShortcuts}
|
{#if showKeyboardShortcuts}
|
||||||
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
|
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showSessionHistory}
|
||||||
|
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showGitPanel}
|
||||||
|
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showProfile}
|
||||||
|
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
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 ConversationTabs from "./ConversationTabs.svelte";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
import HighlightedText from "./HighlightedText.svelte";
|
import HighlightedText from "./HighlightedText.svelte";
|
||||||
import { searchState, searchQuery } from "$lib/stores/search";
|
import { searchState, searchQuery } from "$lib/stores/search";
|
||||||
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
|
import { shouldHidePaths, maskPaths } from "$lib/stores/config";
|
||||||
|
|
||||||
let terminalElement: HTMLDivElement;
|
let terminalElement: HTMLDivElement;
|
||||||
let shouldAutoScroll = true;
|
let shouldAutoScroll = true;
|
||||||
@@ -17,6 +19,11 @@
|
|||||||
currentSearchQuery = value;
|
currentSearchQuery = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let hidePaths = false;
|
||||||
|
shouldHidePaths.subscribe((value) => {
|
||||||
|
hidePaths = value;
|
||||||
|
});
|
||||||
|
|
||||||
claudeStore.terminalLines.subscribe((value) => {
|
claudeStore.terminalLines.subscribe((value) => {
|
||||||
lines = value;
|
lines = value;
|
||||||
});
|
});
|
||||||
@@ -122,6 +129,32 @@
|
|||||||
searchState.setMatchCount(0);
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -161,9 +194,15 @@
|
|||||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if line.type === "assistant"}
|
{#if line.type === "assistant"}
|
||||||
<Markdown content={line.content} searchQuery={currentSearchQuery} />
|
<Markdown
|
||||||
|
content={maskPaths(line.content, hidePaths)}
|
||||||
|
searchQuery={currentSearchQuery}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<HighlightedText content={line.content} searchQuery={currentSearchQuery} />
|
<HighlightedText
|
||||||
|
content={maskPaths(line.content, hidePaths)}
|
||||||
|
searchQuery={currentSearchQuery}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -53,10 +53,7 @@
|
|||||||
Current version: {updateInfo.current_version}
|
Current version: {updateInfo.current_version}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button onclick={openRelease} class="btn-trans-gradient px-3 py-1.5 rounded text-sm">
|
||||||
onclick={openRelease}
|
|
||||||
class="px-3 py-1.5 bg-[var(--accent-primary)] text-white rounded text-sm hover:brightness-110 transition-all"
|
|
||||||
>
|
|
||||||
View Release
|
View Release
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
hasQuestionPending.subscribe((pending) => {
|
hasQuestionPending.subscribe((pending) => {
|
||||||
isVisible = pending;
|
isVisible = pending;
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
selectedOptions = new SvelteSet();
|
selectedOptions.clear();
|
||||||
customAnswer = "";
|
customAnswer = "";
|
||||||
showCustomInput = false;
|
showCustomInput = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+1207
-83
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
};
|
||||||
+137
-6
@@ -1,7 +1,18 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
export type Theme = "dark" | "light";
|
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
||||||
|
|
||||||
|
export interface CustomThemeColors {
|
||||||
|
bg_primary: string | null;
|
||||||
|
bg_secondary: string | null;
|
||||||
|
bg_terminal: string | null;
|
||||||
|
accent_primary: string | null;
|
||||||
|
accent_secondary: string | null;
|
||||||
|
text_primary: string | null;
|
||||||
|
text_secondary: string | null;
|
||||||
|
border_color: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HikariConfig {
|
export interface HikariConfig {
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@@ -15,9 +26,17 @@ export interface HikariConfig {
|
|||||||
notifications_enabled: boolean;
|
notifications_enabled: boolean;
|
||||||
notification_volume: number;
|
notification_volume: number;
|
||||||
always_on_top: boolean;
|
always_on_top: boolean;
|
||||||
|
minimize_to_tray: boolean;
|
||||||
update_checks_enabled: boolean;
|
update_checks_enabled: boolean;
|
||||||
character_panel_width: number | null;
|
character_panel_width: number | null;
|
||||||
font_size: number;
|
font_size: number;
|
||||||
|
streamer_mode: boolean;
|
||||||
|
streamer_hide_paths: boolean;
|
||||||
|
compact_mode: boolean;
|
||||||
|
profile_name: string | null;
|
||||||
|
profile_avatar_path: string | null;
|
||||||
|
profile_bio: string | null;
|
||||||
|
custom_theme_colors: CustomThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -32,9 +51,26 @@ const defaultConfig: HikariConfig = {
|
|||||||
notifications_enabled: true,
|
notifications_enabled: true,
|
||||||
notification_volume: 0.7,
|
notification_volume: 0.7,
|
||||||
always_on_top: false,
|
always_on_top: false,
|
||||||
|
minimize_to_tray: false,
|
||||||
update_checks_enabled: true,
|
update_checks_enabled: true,
|
||||||
character_panel_width: null,
|
character_panel_width: null,
|
||||||
font_size: 14,
|
font_size: 14,
|
||||||
|
streamer_mode: false,
|
||||||
|
streamer_hide_paths: false,
|
||||||
|
compact_mode: false,
|
||||||
|
profile_name: null,
|
||||||
|
profile_avatar_path: null,
|
||||||
|
profile_bio: null,
|
||||||
|
custom_theme_colors: {
|
||||||
|
bg_primary: null,
|
||||||
|
bg_secondary: null,
|
||||||
|
bg_terminal: null,
|
||||||
|
accent_primary: null,
|
||||||
|
accent_secondary: null,
|
||||||
|
text_primary: null,
|
||||||
|
text_secondary: null,
|
||||||
|
border_color: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
@@ -90,9 +126,24 @@ function createConfigStore() {
|
|||||||
closeSidebar: () => isSidebarOpen.set(false),
|
closeSidebar: () => isSidebarOpen.set(false),
|
||||||
toggleSidebar: () => isSidebarOpen.update((open) => !open),
|
toggleSidebar: () => isSidebarOpen.update((open) => !open),
|
||||||
|
|
||||||
setTheme: async (theme: Theme) => {
|
setTheme: async (theme: Theme, customColors?: CustomThemeColors) => {
|
||||||
await updateConfig({ theme });
|
const updates: Partial<HikariConfig> = { theme };
|
||||||
applyTheme(theme);
|
if (customColors) {
|
||||||
|
updates.custom_theme_colors = customColors;
|
||||||
|
}
|
||||||
|
await updateConfig(updates);
|
||||||
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
|
applyTheme(theme, currentConfig.custom_theme_colors);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCustomThemeColors: async (colors: CustomThemeColors) => {
|
||||||
|
await updateConfig({ custom_theme_colors: colors });
|
||||||
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
|
if (currentConfig.theme === "custom") {
|
||||||
|
applyCustomThemeColors(colors);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setFontSize: async (size: number) => {
|
setFontSize: async (size: number) => {
|
||||||
@@ -143,15 +194,72 @@ function createConfigStore() {
|
|||||||
config.subscribe((c) => (currentConfig = c))();
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
return currentConfig;
|
return currentConfig;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleStreamerMode: async () => {
|
||||||
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
|
await updateConfig({ streamer_mode: !currentConfig.streamer_mode });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleCompactMode: async () => {
|
||||||
|
let currentConfig: HikariConfig = defaultConfig;
|
||||||
|
config.subscribe((c) => (currentConfig = c))();
|
||||||
|
await updateConfig({ compact_mode: !currentConfig.compact_mode });
|
||||||
|
},
|
||||||
|
|
||||||
|
setCompactMode: async (enabled: boolean) => {
|
||||||
|
await updateConfig({ compact_mode: enabled });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyTheme(theme: Theme) {
|
export function applyTheme(theme: Theme, customColors?: CustomThemeColors) {
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
// For custom theme, we use dark as the base and override with custom colors
|
||||||
|
document.documentElement.setAttribute("data-theme", theme === "custom" ? "dark" : theme);
|
||||||
|
|
||||||
|
// Clear any previously applied custom colors
|
||||||
|
clearCustomThemeColors();
|
||||||
|
|
||||||
|
// Apply custom colors if theme is custom
|
||||||
|
if (theme === "custom" && customColors) {
|
||||||
|
applyCustomThemeColors(customColors);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyCustomThemeColors(colors: CustomThemeColors) {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (colors.bg_primary) root.style.setProperty("--bg-primary", colors.bg_primary);
|
||||||
|
if (colors.bg_secondary) root.style.setProperty("--bg-secondary", colors.bg_secondary);
|
||||||
|
if (colors.bg_terminal) root.style.setProperty("--bg-terminal", colors.bg_terminal);
|
||||||
|
if (colors.accent_primary) root.style.setProperty("--accent-primary", colors.accent_primary);
|
||||||
|
if (colors.accent_secondary)
|
||||||
|
root.style.setProperty("--accent-secondary", colors.accent_secondary);
|
||||||
|
if (colors.text_primary) root.style.setProperty("--text-primary", colors.text_primary);
|
||||||
|
if (colors.text_secondary) root.style.setProperty("--text-secondary", colors.text_secondary);
|
||||||
|
if (colors.border_color) root.style.setProperty("--border-color", colors.border_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCustomThemeColors() {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const customProperties = [
|
||||||
|
"--bg-primary",
|
||||||
|
"--bg-secondary",
|
||||||
|
"--bg-terminal",
|
||||||
|
"--accent-primary",
|
||||||
|
"--accent-secondary",
|
||||||
|
"--text-primary",
|
||||||
|
"--text-secondary",
|
||||||
|
"--border-color",
|
||||||
|
];
|
||||||
|
customProperties.forEach((prop) => root.style.removeProperty(prop));
|
||||||
|
}
|
||||||
|
|
||||||
const MIN_FONT_SIZE = 10;
|
const MIN_FONT_SIZE = 10;
|
||||||
const MAX_FONT_SIZE = 24;
|
const MAX_FONT_SIZE = 24;
|
||||||
const DEFAULT_FONT_SIZE = 14;
|
const DEFAULT_FONT_SIZE = 14;
|
||||||
@@ -172,3 +280,26 @@ export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
|||||||
export const configStore = createConfigStore();
|
export const configStore = createConfigStore();
|
||||||
|
|
||||||
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
|
export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark");
|
||||||
|
|
||||||
|
export const isStreamerMode = derived(configStore.config, ($config) => $config.streamer_mode);
|
||||||
|
export const isCompactMode = derived(configStore.config, ($config) => $config.compact_mode);
|
||||||
|
export const shouldHidePaths = derived(
|
||||||
|
configStore.config,
|
||||||
|
($config) => $config.streamer_mode && $config.streamer_hide_paths
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Masks file paths in text when streamer mode with hide paths is enabled.
|
||||||
|
* Replaces username portion of paths with asterisks.
|
||||||
|
*/
|
||||||
|
export function maskPaths(text: string, hidePaths: boolean): string {
|
||||||
|
if (!hidePaths) return text;
|
||||||
|
|
||||||
|
// Match Unix paths like /home/username/... or /Users/username/...
|
||||||
|
// and Windows paths like C:\Users\username\...
|
||||||
|
return text
|
||||||
|
.replace(/\/home\/([^/\s]+)/g, "/home/****")
|
||||||
|
.replace(/\/Users\/([^/\s]+)/g, "/Users/****")
|
||||||
|
.replace(/C:\\Users\\([^\\\s]+)/gi, "C:\\Users\\****")
|
||||||
|
.replace(/~\//g, "****/");
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
import type { CharacterState } from "$lib/types/states";
|
import type { CharacterState } from "$lib/types/states";
|
||||||
import { cleanupConversationTracking } from "$lib/tauri";
|
import { cleanupConversationTracking } from "$lib/tauri";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import { sessionsStore } from "$lib/stores/sessions";
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -287,6 +288,9 @@ function createConversationsStore() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel any pending auto-save for this conversation
|
||||||
|
sessionsStore.cancelAutoSave(id);
|
||||||
|
|
||||||
// Clean up tracking for this conversation (including temp files)
|
// Clean up tracking for this conversation (including temp files)
|
||||||
await cleanupConversationTracking(id);
|
await cleanupConversationTracking(id);
|
||||||
|
|
||||||
@@ -434,6 +438,8 @@ function createConversationsStore() {
|
|||||||
if (conv) {
|
if (conv) {
|
||||||
conv.terminalLines.push(line);
|
conv.terminalLines.push(line);
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
|
// Schedule auto-save for this conversation
|
||||||
|
sessionsStore.scheduleAutoSave(conv);
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
});
|
});
|
||||||
@@ -462,6 +468,8 @@ function createConversationsStore() {
|
|||||||
if (conv) {
|
if (conv) {
|
||||||
conv.terminalLines.push(line);
|
conv.terminalLines.push(line);
|
||||||
conv.lastActivityAt = new Date();
|
conv.lastActivityAt = new Date();
|
||||||
|
// Schedule auto-save for this conversation
|
||||||
|
sessionsStore.scheduleAutoSave(conv);
|
||||||
}
|
}
|
||||||
return convs;
|
return convs;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -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();
|
||||||
@@ -104,10 +104,11 @@ export async function initStatsListener() {
|
|||||||
stats.set(newStats);
|
stats.set(newStats);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load initial stats from backend
|
// Load initial persisted stats from backend (no bridge required)
|
||||||
try {
|
try {
|
||||||
const initialStats = await invoke<UsageStats>("get_usage_stats");
|
const initialStats = await invoke<UsageStats>("get_persisted_stats");
|
||||||
stats.set(initialStats);
|
stats.set(initialStats);
|
||||||
|
console.log("Loaded persisted stats:", initialStats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load initial stats:", error);
|
console.error("Failed to load initial stats:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-18
@@ -14,34 +14,52 @@ export type AchievementId =
|
|||||||
| "GrowingStrong" // 10,000 tokens
|
| "GrowingStrong" // 10,000 tokens
|
||||||
| "BlossomingCoder" // 100,000 tokens
|
| "BlossomingCoder" // 100,000 tokens
|
||||||
| "TokenMaster" // 1,000,000 tokens
|
| "TokenMaster" // 1,000,000 tokens
|
||||||
|
| "TokenBillionaire" // 10,000,000 tokens
|
||||||
|
| "TokenTreasure" // 50,000,000 tokens
|
||||||
|
|
||||||
// Code Generation
|
// Code Generation
|
||||||
| "HelloWorld" // First code block
|
| "HelloWorld" // First code block
|
||||||
| "CodeWizard" // 100 code blocks
|
| "CodeWizard" // 100 code blocks
|
||||||
| "ThousandBlocks" // 1,000 code blocks
|
| "ThousandBlocks" // 1,000 code blocks
|
||||||
|
| "CodeFactory" // 5,000 code blocks
|
||||||
|
| "CodeEmpire" // 10,000 code blocks
|
||||||
|
|
||||||
// File Operations
|
// File Operations
|
||||||
| "FileManipulator" // 10 files edited
|
| "FileManipulator" // 10 files edited
|
||||||
| "FileArchitect" // 100 files edited
|
| "FileArchitect" // 100 files edited
|
||||||
|
| "FileEngineer" // 500 files edited
|
||||||
|
| "FileLegend" // 1,000 files edited
|
||||||
|
|
||||||
// Conversation milestones
|
// Conversation milestones
|
||||||
| "ConversationStarter" // 10 messages
|
| "ConversationStarter" // 10 messages
|
||||||
| "ChattyKathy" // 100 messages
|
| "ChattyKathy" // 100 messages
|
||||||
| "Conversationalist" // 1,000 messages
|
| "Conversationalist" // 1,000 messages
|
||||||
|
| "ChatMarathon" // 5,000 messages
|
||||||
|
| "ChatLegend" // 10,000 messages
|
||||||
|
|
||||||
// Tool usage
|
// Tool usage
|
||||||
| "Toolsmith" // 5 different tools
|
| "Toolsmith" // 5 different tools
|
||||||
| "ToolMaster" // 10 different tools
|
| "ToolMaster" // 10 different tools
|
||||||
|
|
||||||
// Time-based achievements
|
// Time-based achievements
|
||||||
| "EarlyBird" // Started session 5-7 AM
|
| "EarlyBird" // Started session 5-7 AM
|
||||||
| "NightOwl" // Coding after midnight
|
| "NightOwl" // Coding after midnight
|
||||||
| "AllNighter" // Worked 2-5 AM
|
| "AllNighter" // Worked 2-5 AM
|
||||||
| "WeekendWarrior" // Coding on weekend
|
| "WeekendWarrior" // Coding on weekend
|
||||||
| "DedicatedDeveloper" // 30 days in a row
|
| "DedicatedDeveloper" // 30 days in a row
|
||||||
|
|
||||||
// Search and exploration
|
// Search and exploration
|
||||||
| "Explorer" // 50 searches
|
| "Explorer" // 50 searches
|
||||||
| "MasterSearcher" // 500 searches
|
| "MasterSearcher" // 500 searches
|
||||||
|
|
||||||
// Session achievements
|
// Session achievements
|
||||||
| "QuickSession" // Productive session < 5 min
|
| "QuickSession" // Productive session < 5 min
|
||||||
| "FocusedWork" // 30 min session
|
| "FocusedWork" // 30 min session
|
||||||
| "DeepDive" // 2 hour session
|
| "DeepDive" // 2 hour session
|
||||||
| "MarathonSession" // 5+ hour session
|
| "MarathonSession" // 5+ hour session
|
||||||
|
| "UltraMarathon" // 8 hour session
|
||||||
|
| "CodingRetreat" // 12 hour session
|
||||||
|
|
||||||
// Special achievements
|
// Special achievements
|
||||||
| "FirstMessage" // First message sent
|
| "FirstMessage" // First message sent
|
||||||
| "FirstTool" // First tool used
|
| "FirstTool" // First tool used
|
||||||
@@ -51,28 +69,165 @@ export type AchievementId =
|
|||||||
| "SpeedCoder" // 10 code blocks in 10 minutes
|
| "SpeedCoder" // 10 code blocks in 10 minutes
|
||||||
| "ClaudeConnoisseur" // Used all Claude models
|
| "ClaudeConnoisseur" // Used all Claude models
|
||||||
| "MarathonCoder" // 10k tokens in one session
|
| "MarathonCoder" // 10k tokens in one session
|
||||||
|
|
||||||
// Relationship & Greetings
|
// Relationship & Greetings
|
||||||
| "GoodMorning" // Said good morning
|
| "GoodMorning" // Say "good morning"
|
||||||
| "GoodNight" // Said good night
|
| "GoodNight" // Say "good night" or "goodnight"
|
||||||
| "ThankYou" // Said thank you
|
| "ThankYou" // Say "thank you" or "thanks"
|
||||||
| "LoveYou" // Said love you
|
| "LoveYou" // Say "love you" or "ily"
|
||||||
|
| "HelloHikari" // Say "hello hikari" or "hi hikari"
|
||||||
|
| "HowAreYou" // Ask "how are you"
|
||||||
|
| "MissedYou" // Say "missed you"
|
||||||
|
| "BackAgain" // Say "i'm back" or "back again"
|
||||||
|
|
||||||
// Personality & Fun
|
// Personality & Fun
|
||||||
| "EmojiUser" // Used 20+ emojis
|
| "EmojiUser" // Use an emoji in a message
|
||||||
| "CapsLock" // ALL CAPS MESSAGE
|
| "QuestionMaster" // Use "?" in 20 messages
|
||||||
| "QuestionMaster" // Asked 50 questions
|
| "CapsLock" // Send a message in ALL CAPS
|
||||||
| "PleaseAndThankYou" // Polite user
|
| "PleaseAndThankYou" // Use "please" in messages
|
||||||
|
|
||||||
|
// Emotional
|
||||||
|
| "Frustrated" // Say "frustrated" or "ugh" or "argh"
|
||||||
|
| "Excited" // Say "excited" or "yay" or "woohoo"
|
||||||
|
| "Confused" // Say "confused" or "don't understand"
|
||||||
|
| "Curious" // Ask "why" or "how does"
|
||||||
|
| "Impressed" // Say "wow" or "amazing" or "incredible"
|
||||||
|
|
||||||
// Git & Development
|
// Git & Development
|
||||||
| "CommitMaster" // 100 commits
|
| "GitGuru" // Use git commands 10 times
|
||||||
| "PRO" // Created 10 PRs
|
| "TestWriter" // Create test files
|
||||||
| "Reviewer" // Reviewed 10 PRs
|
| "Debugger" // Fix bugs (messages with "fix", "bug", "error")
|
||||||
| "IssueTracker" // Created 25 issues
|
| "CommitKing" // 50 commits
|
||||||
| "GitGuru" // Used git commands
|
| "CommitLegend" // 200 commits
|
||||||
|
| "BranchMaster" // Create 10 branches
|
||||||
|
| "MergeExpert" // Merge 20 PRs
|
||||||
|
| "ConflictResolver" // Resolve merge conflicts
|
||||||
|
|
||||||
// Tool Mastery
|
// Tool Mastery
|
||||||
| "BashMaster" // Used bash 100 times
|
| "BashMaster" // Use Bash tool 50 times
|
||||||
| "FileExplorer" // Searched files 100 times
|
| "FileExplorer" // Use Read tool 100 times
|
||||||
| "SearchExpert" // Advanced searches
|
| "SearchExpert" // Use Grep tool 50 times
|
||||||
| "AgentCommander" // Used task agents
|
| "EditMaster" // Use Edit tool 100 times
|
||||||
| "MCPMaster"; // Used MCP tools
|
| "WriteMaster" // Use Write tool 50 times
|
||||||
|
| "GlobMaster" // Use Glob tool 100 times
|
||||||
|
| "TaskMaster" // Use Task tool 50 times
|
||||||
|
| "WebFetcher" // Use WebFetch tool 20 times
|
||||||
|
| "McpExplorer" // Use MCP tools 50 times
|
||||||
|
|
||||||
|
// Daily Streaks
|
||||||
|
| "WeekStreak" // 7 days in a row
|
||||||
|
| "TwoWeekStreak" // 14 days in a row
|
||||||
|
| "MonthStreak" // 30 days in a row
|
||||||
|
| "QuarterStreak" // 90 days in a row
|
||||||
|
|
||||||
|
// Time Challenges
|
||||||
|
| "MorningPerson" // 10 sessions started before 9 AM
|
||||||
|
| "NightCoder" // 10 sessions after 10 PM
|
||||||
|
| "LunchBreakCoder" // Session during 12-1 PM
|
||||||
|
| "CoffeeTime" // Session during 3-4 PM
|
||||||
|
|
||||||
|
// Day-specific
|
||||||
|
| "MondayMotivation" // Coding on Monday
|
||||||
|
| "FridayFinisher" // Coding on Friday
|
||||||
|
| "HumpDay" // Coding on Wednesday
|
||||||
|
|
||||||
|
// Seasonal/Special Times
|
||||||
|
| "NewYearCoder" // Coding on January 1st
|
||||||
|
| "ValentinesDev" // Coding on February 14th
|
||||||
|
| "SpookyCode" // Coding on October 31st
|
||||||
|
| "HolidayCoder" // Coding on December 25th
|
||||||
|
| "LeapDayCoder" // Coding on February 29th
|
||||||
|
|
||||||
|
// Message Content
|
||||||
|
| "LongMessage" // Send a message over 500 characters
|
||||||
|
| "NovelWriter" // Send a message over 2000 characters
|
||||||
|
| "ShortAndSweet" // Complete a task with messages under 50 chars each
|
||||||
|
| "CodeInMessage" // Include code block in user message
|
||||||
|
| "MarkdownMaster" // Use markdown formatting in message
|
||||||
|
|
||||||
|
// Programming Languages
|
||||||
|
| "RustDeveloper" // Generate Rust code
|
||||||
|
| "PythonDeveloper" // Generate Python code
|
||||||
|
| "JavaScriptDev" // Generate JavaScript code
|
||||||
|
| "TypeScriptDev" // Generate TypeScript code
|
||||||
|
| "GoDeveloper" // Generate Go code
|
||||||
|
| "CppDeveloper" // Generate C++ code
|
||||||
|
| "JavaDeveloper" // Generate Java code
|
||||||
|
| "HtmlCssDev" // Generate HTML/CSS code
|
||||||
|
| "SqlDeveloper" // Generate SQL code
|
||||||
|
| "ShellScripter" // Generate shell/bash scripts
|
||||||
|
| "FullStackDev" // Generate code in 10+ languages
|
||||||
|
|
||||||
|
// Project Types
|
||||||
|
| "FrontendDev" // Work on frontend files
|
||||||
|
| "BackendDev" // Work on backend files
|
||||||
|
| "ConfigEditor" // Edit config files
|
||||||
|
| "DocWriter" // Edit documentation
|
||||||
|
|
||||||
|
// Error Handling
|
||||||
|
| "ErrorHunter" // Fix 10 errors
|
||||||
|
| "ExceptionSlayer" // Fix 50 errors
|
||||||
|
| "BugExterminator" // Fix 100 bugs
|
||||||
|
|
||||||
|
// Refactoring
|
||||||
|
| "CleanCoder" // Refactor code
|
||||||
|
| "Optimizer" // Optimize performance
|
||||||
|
| "Simplifier" // Simplify complex code
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
| "TestNovice" // Write 10 tests
|
||||||
|
| "TestEnthusiast" // Write 50 tests
|
||||||
|
| "TestMaster" // Write 100 tests
|
||||||
|
| "CoverageKing" // Achieve test coverage mentions
|
||||||
|
|
||||||
|
// Documentation
|
||||||
|
| "Documenter" // Write documentation
|
||||||
|
| "CommentWriter" // Add comments to code
|
||||||
|
| "ReadmeHero" // Create/edit README files
|
||||||
|
|
||||||
|
// API & Integration
|
||||||
|
| "ApiExplorer" // Work with APIs
|
||||||
|
| "DatabaseDev" // Work with databases
|
||||||
|
| "CloudCoder" // Work with cloud services
|
||||||
|
|
||||||
|
// Special Milestones
|
||||||
|
| "CenturyClub" // 100 sessions
|
||||||
|
| "ThousandSessions" // 1000 sessions
|
||||||
|
| "Veteran" // Used Hikari for 30+ days total
|
||||||
|
| "OldTimer" // Used Hikari for 90+ days total
|
||||||
|
| "Loyalist" // Used Hikari for 365+ days total
|
||||||
|
|
||||||
|
// Fun & Easter Eggs
|
||||||
|
| "Perfectionist" // Redo something 5 times
|
||||||
|
| "Persistent" // Ask same question 3 times
|
||||||
|
| "Patient" // Wait for long response
|
||||||
|
| "Speedy" // Send 10 messages in 1 minute
|
||||||
|
|
||||||
|
// UI Exploration
|
||||||
|
| "MultiTasker" // Multiple conversations
|
||||||
|
| "Minimalist" // Use compact mode
|
||||||
|
| "PrivacyFirst" // Use streamer mode
|
||||||
|
| "ThemeChanger" // Change themes
|
||||||
|
| "SettingsTweaker" // Adjust settings
|
||||||
|
|
||||||
|
// Achievement Meta
|
||||||
|
| "AchievementHunter" // Unlock 25 achievements
|
||||||
|
| "Completionist" // Unlock 50 achievements
|
||||||
|
| "MasterUnlocker" // Unlock 100 achievements
|
||||||
|
| "PlatinumStatus" // Unlock all achievements
|
||||||
|
|
||||||
|
// Clipboard & Snippets
|
||||||
|
| "ClipboardCollector" // Use clipboard history
|
||||||
|
| "SnippetCreator" // Create a snippet
|
||||||
|
| "SnippetMaster" // Create 20 snippets
|
||||||
|
|
||||||
|
// Other Features
|
||||||
|
| "QuickActionUser" // Use quick actions
|
||||||
|
| "HistoryBuff" // Browse history
|
||||||
|
| "Archivist" // Export sessions
|
||||||
|
| "SessionExporter" // Export a session
|
||||||
|
| "GitPanelUser" // Use Git panel
|
||||||
|
| "FeatureExplorer"; // Use 10+ features
|
||||||
|
|
||||||
export interface Achievement {
|
export interface Achievement {
|
||||||
id: AchievementId;
|
id: AchievementId;
|
||||||
|
|||||||
+319
-37
@@ -3,16 +3,19 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
|
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
|
||||||
import { configStore, applyTheme, applyFontSize } from "$lib/stores/config";
|
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
||||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
|
||||||
import "$lib/notifications/testNotifications";
|
import "$lib/notifications/testNotifications";
|
||||||
import Terminal from "$lib/components/Terminal.svelte";
|
import Terminal from "$lib/components/Terminal.svelte";
|
||||||
import InputBar from "$lib/components/InputBar.svelte";
|
import InputBar from "$lib/components/InputBar.svelte";
|
||||||
import StatusBar from "$lib/components/StatusBar.svelte";
|
import StatusBar from "$lib/components/StatusBar.svelte";
|
||||||
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
|
import AnimeGirl from "$lib/components/AnimeGirl.svelte";
|
||||||
|
import CompactMode from "$lib/components/CompactMode.svelte";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import type { CharacterState } from "$lib/types/states";
|
||||||
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
import PermissionModal from "$lib/components/PermissionModal.svelte";
|
||||||
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
|
import UserQuestionModal from "$lib/components/UserQuestionModal.svelte";
|
||||||
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
import ConfigSidebar from "$lib/components/ConfigSidebar.svelte";
|
||||||
@@ -21,8 +24,46 @@
|
|||||||
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
|
import UpdateNotification from "$lib/components/UpdateNotification.svelte";
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let updateNotification: UpdateNotification;
|
let updateNotification: UpdateNotification | undefined = $state(undefined);
|
||||||
let achievementPanelOpen = $state(false);
|
let achievementPanelOpen = $state(false);
|
||||||
|
let currentCharacterState: CharacterState = $state("idle");
|
||||||
|
let compactModeActive = $state(false);
|
||||||
|
|
||||||
|
// Window size constants
|
||||||
|
const COMPACT_WIDTH = 280;
|
||||||
|
const COMPACT_HEIGHT = 400;
|
||||||
|
|
||||||
|
// Store the previous window size to restore when exiting compact mode
|
||||||
|
let previousWindowSize: { width: number; height: number } | null = null;
|
||||||
|
|
||||||
|
characterState.subscribe((state) => {
|
||||||
|
currentCharacterState = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
isCompactMode.subscribe((value) => {
|
||||||
|
compactModeActive = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
// Resizable panel state
|
||||||
let panelWidth = $state(320); // Default width in pixels
|
let panelWidth = $state(320); // Default width in pixels
|
||||||
@@ -121,6 +162,20 @@
|
|||||||
configStore.resetFontSize();
|
configStore.resetFontSize();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+S - Toggle streamer mode
|
||||||
|
if (event.ctrlKey && event.shiftKey && event.key === "S") {
|
||||||
|
event.preventDefault();
|
||||||
|
configStore.toggleStreamerMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+M - Toggle compact mode
|
||||||
|
if (event.ctrlKey && event.shiftKey && event.key === "M") {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleCompactMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleInterrupt() {
|
async function handleInterrupt() {
|
||||||
@@ -135,6 +190,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enterCompactMode() {
|
||||||
|
try {
|
||||||
|
const window = getCurrentWindow();
|
||||||
|
const currentSize = await window.innerSize();
|
||||||
|
const scaleFactor = await window.scaleFactor();
|
||||||
|
|
||||||
|
// Save current window size (convert from physical to logical pixels)
|
||||||
|
// innerSize() returns physical pixels, but setSize() with LogicalSize expects logical
|
||||||
|
previousWindowSize = {
|
||||||
|
width: Math.round(currentSize.width / scaleFactor),
|
||||||
|
height: Math.round(currentSize.height / scaleFactor),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resize to compact dimensions
|
||||||
|
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
|
||||||
|
|
||||||
|
// Enable compact mode in config
|
||||||
|
await configStore.setCompactMode(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to enter compact mode:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exitCompactMode() {
|
||||||
|
try {
|
||||||
|
const window = getCurrentWindow();
|
||||||
|
|
||||||
|
// Only resize if we have a saved previous size
|
||||||
|
// (i.e., user entered compact mode during this session)
|
||||||
|
// Otherwise, just expand to a reasonable default
|
||||||
|
if (previousWindowSize) {
|
||||||
|
await window.setSize(new LogicalSize(previousWindowSize.width, previousWindowSize.height));
|
||||||
|
previousWindowSize = null;
|
||||||
|
} else {
|
||||||
|
// No saved size (e.g., app started in compact mode) - use modest default
|
||||||
|
await window.setSize(new LogicalSize(900, 650));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable compact mode in config
|
||||||
|
await configStore.setCompactMode(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to exit compact mode:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCompactMode() {
|
||||||
|
if (compactModeActive) {
|
||||||
|
await exitCompactMode();
|
||||||
|
} else {
|
||||||
|
await enterCompactMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
initialized = true;
|
initialized = true;
|
||||||
@@ -147,7 +255,7 @@
|
|||||||
|
|
||||||
// Apply saved settings on startup
|
// Apply saved settings on startup
|
||||||
const config = configStore.getConfig();
|
const config = configStore.getConfig();
|
||||||
applyTheme(config.theme);
|
applyTheme(config.theme, config.custom_theme_colors);
|
||||||
applyFontSize(config.font_size);
|
applyFontSize(config.font_size);
|
||||||
|
|
||||||
// Apply always-on-top setting
|
// Apply always-on-top setting
|
||||||
@@ -171,6 +279,12 @@
|
|||||||
if (config.update_checks_enabled) {
|
if (config.update_checks_enabled) {
|
||||||
updateNotification?.checkForUpdates();
|
updateNotification?.checkForUpdates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply compact mode if saved (resize window)
|
||||||
|
if (config.compact_mode) {
|
||||||
|
const window = getCurrentWindow();
|
||||||
|
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,43 +298,56 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
|
{#if compactModeActive}
|
||||||
<StatusBar onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)} />
|
<!-- Compact mode: minimal widget interface -->
|
||||||
|
<div
|
||||||
|
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
|
||||||
|
>
|
||||||
|
<CompactMode onExpand={exitCompactMode} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Full mode: standard interface -->
|
||||||
|
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
|
||||||
|
<StatusBar
|
||||||
|
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
|
||||||
|
onToggleCompact={enterCompactMode}
|
||||||
|
/>
|
||||||
|
|
||||||
<main class="flex-1 flex overflow-hidden">
|
<main class="flex-1 flex overflow-hidden">
|
||||||
<!-- Left panel: Character display -->
|
<!-- Left panel: Character display -->
|
||||||
<div
|
<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;"
|
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;"
|
||||||
>
|
>
|
||||||
<AnimeGirl />
|
<AnimeGirl />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resize handle -->
|
<!-- Resize handle -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
|
class="resize-handle w-1 cursor-col-resize bg-[var(--border-color)] hover:bg-[var(--accent-primary)] transition-colors flex-shrink-0"
|
||||||
class:bg-[var(--accent-primary)]={isResizing}
|
class:bg-[var(--accent-primary)]={isResizing}
|
||||||
onmousedown={startResize}
|
onmousedown={startResize}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Right panel: Terminal and input -->
|
<!-- Right panel: Terminal and input -->
|
||||||
<div class="terminal-panel flex-1 flex flex-col min-w-0">
|
<div class="terminal-panel flex-1 flex flex-col min-w-0">
|
||||||
<Terminal />
|
<Terminal />
|
||||||
<InputBar />
|
<InputBar />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<PermissionModal />
|
<PermissionModal />
|
||||||
<UserQuestionModal />
|
<UserQuestionModal />
|
||||||
<ConfigSidebar />
|
<ConfigSidebar />
|
||||||
<AchievementNotification />
|
<AchievementNotification />
|
||||||
<AchievementsPanel
|
<AchievementsPanel
|
||||||
bind:isOpen={achievementPanelOpen}
|
bind:isOpen={achievementPanelOpen}
|
||||||
onClose={() => (achievementPanelOpen = false)}
|
onClose={() => (achievementPanelOpen = false)}
|
||||||
/>
|
/>
|
||||||
<UpdateNotification bind:this={updateNotification} />
|
<UpdateNotification bind:this={updateNotification} />
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.app-container {
|
.app-container {
|
||||||
@@ -235,6 +362,161 @@
|
|||||||
|
|
||||||
.character-panel {
|
.character-panel {
|
||||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
|
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);
|
||||||
|
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,
|
.resize-handle:hover,
|
||||||
|
|||||||
Reference in New Issue
Block a user