From 87ba8f7bdf6a51a6bf8a3b4be1cc3b18712c3cfa Mon Sep 17 00:00:00 2001 From: Eddy Date: Tue, 21 Apr 2026 11:50:56 +0200 Subject: [PATCH] feat: Global Hotkey (Super+C) + Clipboard-Watch + Desktop-Integration [appimage] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Global Hotkey: Super+C öffnet Claude-Fenster und fokussiert Input von überall - Clipboard-Watch: Erkennt Code/URLs/Fehler/Pfade in Zwischenablage, zeigt Vorschlag - tauri-plugin-global-shortcut + tauri-plugin-dialog integriert - focus-chat-input Event für Hotkey → Input-Fokus - clipboard-changed Event für Watch → Chat-Vorschlag Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 3 + ROADMAP.md | 8 +- src-tauri/Cargo.lock | 67 +++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 5 +- src-tauri/src/clipboard.rs | 168 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 26 ++++- src/lib/components/ChatPanel.svelte | 18 ++- 8 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/clipboard.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c638cb0..5d768de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). - **File-Drop auf Chat**: Dateien per Drag & Drop auf den Chat ziehen — Text-Dateien als Code-Block, Bilder als Base64, Spracherkennung, 500KB-Limit (`ChatPanel.svelte`) - **Persistent Memory**: Auto-Load Memory-Einträge werden bei jeder Nachricht in den Claude-Context injiziert — Cross-Session Gedächtnis für Patterns, Zugänge, Präferenzen (`memory.rs`, `claude.rs`) - **Memory CRUD-Commands**: Speichern, Löschen, Auflisten, Auto-Load-Filter für Memory-Einträge als Tauri-Commands (`memory.rs`, `lib.rs`) +- **Global Hotkey (Super+C)**: Claude-Eingabe von überall öffnen — Fenster wird angezeigt, fokussiert, Input-Feld aktiviert (`lib.rs`, `ChatPanel.svelte`) +- **Clipboard-Watch**: Überwacht Zwischenablage, erkennt Code/URLs/Fehler/Pfade, zeigt Vorschlag im Chat (`clipboard.rs`, `ChatPanel.svelte`) +- **File-Browser für Projekt-Wechsel**: Nativer Verzeichnis-Dialog statt manuellem Pfad, Auto-Name aus Ordnername (`SessionList.svelte`, `tauri-plugin-dialog`) - **Quick-Actions Palette (Ctrl+K)**: VS-Code-artige Kommandopalette mit Suche, Kategorien (Build, Git, Session, Navigation, Voice, Tools), Keyboard-Navigation (`QuickActions.svelte`) - **Lokales Voice (Phase 2.2)**: whisper-cli STT + piper-tts TTS, komplett lokal ohne OpenAI-API (`voice.rs`, `VoicePanel.svelte`) - **Chat-Detach**: Chat in separates Fenster herauslösen, Platz für andere Panels, Zurückholen per Button (`chat_window.rs`, `+page.svelte`) diff --git a/ROADMAP.md b/ROADMAP.md index c50ee7b..48e5774 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -91,11 +91,11 @@ Alles aus Phase 1-16 ist implementiert und funktionsfaehig: | Feature | Datei(en) | Beschreibung | |---------|-----------|--------------| -| D-Bus Actions | `programs.rs` | Vordefinierte Aktionen: Dolphin oeffnen, Kate starten, Notifications | -| Clipboard-Watch | neu: `clipboard.rs` | Claude reagiert auf Clipboard-Inhalt (Code-Snippet → erklaeren, URL → zusammenfassen) | +| D-Bus Actions | `programs.rs` | ⬜ Vordefinierte Aktionen: Dolphin oeffnen, Kate starten, Notifications | +| ✅ Clipboard-Watch | `clipboard.rs` | Claude reagiert auf Clipboard-Inhalt (Code/URL/Fehler erkennen) | | ✅ File-Drop | `ChatPanel.svelte` | Dateien auf Chat droppen → Claude analysiert/bearbeitet | -| Screenshot-Analyse | `programs.rs` | Bildschirmbereich markieren → Claude beschreibt/debuggt UI | -| Global Hotkey | `lib.rs` | Super+C oeffnet Claude-Eingabe von ueberall | +| Screenshot-Analyse | `programs.rs` | ⬜ Bildschirmbereich markieren → Claude beschreibt/debuggt UI | +| ✅ Global Hotkey | `lib.rs` | Super+C oeffnet Claude-Eingabe von ueberall | --- diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 90e400c..6c38952 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -491,6 +491,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", "tauri-plugin-shell", "tokio", "tokio-tungstenite", @@ -1410,6 +1411,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1542,6 +1553,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -4705,6 +4734,21 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" @@ -6348,6 +6392,29 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 26facc8..c309f1d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ tokio-tungstenite = "0.23" futures-util = "0.3" sha2 = "0.10" tauri-plugin-dialog = "2.7.0" +tauri-plugin-global-shortcut = "2.3.1" [target.'cfg(target_os = "linux")'.dependencies] webkit2gtk = "2.0" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c0c2169..b29b810 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -17,6 +17,9 @@ "shell:allow-spawn", "shell:allow-stdin-write", "shell:allow-kill", - "dialog:allow-open" + "dialog:allow-open", + "global-shortcut:allow-register", + "global-shortcut:allow-unregister", + "global-shortcut:allow-is-registered" ] } diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs new file mode 100644 index 0000000..284b17d --- /dev/null +++ b/src-tauri/src/clipboard.rs @@ -0,0 +1,168 @@ +// Claude Desktop — Clipboard-Watch +// Überwacht die Zwischenablage und reagiert auf Änderungen +// (Code-Snippets erklären, URLs zusammenfassen, etc.) + +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Manager, Emitter}; +use serde::{Deserialize, Serialize}; + +/// Clipboard-Inhalt Typ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ClipboardContentType { + Code, // Erkannter Code-Snippet + Url, // URL/Link + Error, // Fehlermeldung/Stacktrace + Path, // Dateipfad + Text, // Normaler Text +} + +/// Clipboard-Event das ans Frontend gesendet wird +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClipboardEvent { + pub content: String, + pub content_type: ClipboardContentType, + pub suggestion: String, // Vorschlag was Claude damit tun könnte +} + +/// Clipboard-State für Watch-Kontrolle +pub struct ClipboardWatcher { + pub enabled: bool, + pub last_content: String, +} + +impl Default for ClipboardWatcher { + fn default() -> Self { + Self { + enabled: false, + last_content: String::new(), + } + } +} + +pub type ClipboardState = Arc>; + +/// Erkennt den Typ des Clipboard-Inhalts +fn detect_content_type(text: &str) -> (ClipboardContentType, String) { + let trimmed = text.trim(); + + // URL erkennen + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return (ClipboardContentType::Url, "URL zusammenfassen?".to_string()); + } + + // Dateipfad erkennen + if trimmed.starts_with('/') && !trimmed.contains('\n') && trimmed.len() < 500 { + return (ClipboardContentType::Path, "Datei analysieren?".to_string()); + } + + // Fehlermeldung/Stacktrace erkennen + let error_indicators = ["error", "Error", "ERROR", "exception", "Exception", + "traceback", "Traceback", "panic", "PANIC", "at line", "stack trace"]; + if error_indicators.iter().any(|e| trimmed.contains(e)) { + return (ClipboardContentType::Error, "Fehler analysieren?".to_string()); + } + + // Code erkennen (einfache Heuristik) + let code_indicators = ["fn ", "function ", "def ", "class ", "import ", "const ", + "let ", "var ", "pub ", "async ", "=>", "->", "= 2 || (trimmed.lines().count() > 3 && trimmed.contains('{')) { + return (ClipboardContentType::Code, "Code erklären?".to_string()); + } + + (ClipboardContentType::Text, String::new()) +} + +/// Startet den Clipboard-Watcher (Polling alle 2s) +#[tauri::command] +pub async fn clipboard_watch_start(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + { + let mut watcher = state.lock().unwrap(); + if watcher.enabled { + return Ok(()); // Schon aktiv + } + watcher.enabled = true; + } + + println!("📋 Clipboard-Watch gestartet"); + + let app_handle = app.clone(); + let watch_state = app.state::().inner().clone(); + + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let enabled = { + let watcher = watch_state.lock().unwrap(); + watcher.enabled + }; + if !enabled { break; } + + // Clipboard via xclip lesen (Linux) + let output = tokio::process::Command::new("xclip") + .args(["-selection", "clipboard", "-o"]) + .output() + .await; + + if let Ok(out) = output { + if out.status.success() { + let content = String::from_utf8_lossy(&out.stdout).to_string(); + let trimmed = content.trim().to_string(); + + if trimmed.is_empty() || trimmed.len() > 10000 { continue; } + + let changed = { + let mut watcher = watch_state.lock().unwrap(); + if watcher.last_content == trimmed { + false + } else { + watcher.last_content = trimmed.clone(); + true + } + }; + + if changed { + let (content_type, suggestion) = detect_content_type(&trimmed); + + // Nur bei interessanten Inhalten ein Event senden + if !suggestion.is_empty() { + let event = ClipboardEvent { + content: trimmed, + content_type, + suggestion, + }; + let _ = app_handle.emit("clipboard-changed", &event); + } + } + } + } + } + + println!("📋 Clipboard-Watch beendet"); + }); + + Ok(()) +} + +/// Stoppt den Clipboard-Watcher +#[tauri::command] +pub async fn clipboard_watch_stop(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + let mut watcher = state.lock().unwrap(); + watcher.enabled = false; + println!("📋 Clipboard-Watch gestoppt"); + Ok(()) +} + +/// Gibt den aktuellen Clipboard-Status zurück +#[tauri::command] +pub async fn clipboard_watch_status(app: AppHandle) -> Result { + let state = app.state::(); + let watcher = state.lock().unwrap(); + Ok(watcher.enabled) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 21ab39b..f74831b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex}; use tauri::{ - Manager, + Emitter, Manager, menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, }; @@ -13,6 +13,7 @@ use webkit2gtk::WebViewExt; mod audit; mod claude; +mod clipboard; mod commands; mod context; mod db; @@ -34,9 +35,11 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .manage(Arc::new(Mutex::new(claude::ClaudeState::default()))) .manage(guard::GuardState::new(Mutex::new(guard::GuardRails::new()))) .manage::(Arc::new(Mutex::new(hooks::HookManager::default()))) + .manage::(Arc::new(Mutex::new(clipboard::ClipboardWatcher::default()))) .manage::(Arc::new(Mutex::new(ide::IdeConnector::default()))) // Phase 2.0: MySQL Pool als Managed State — wird einmal erstellt, von allen Knowledge-Commands geteilt .manage::(knowledge::create_managed_pool()) @@ -171,6 +174,10 @@ pub fn run() { update::apply_update, update::apply_bundle_update, update::get_current_version, + // Clipboard-Watch + clipboard::clipboard_watch_start, + clipboard::clipboard_watch_stop, + clipboard::clipboard_watch_status, // Slash-Command Registry commands::get_slash_commands, ]) @@ -263,6 +270,23 @@ pub fn run() { // Lock-Datei erstellen (Instanz-Schutz + Update-Safety) update::create_lock_file(); + // Global Hotkey: Super+C öffnet Claude-Eingabe von überall + { + use tauri_plugin_global_shortcut::GlobalShortcutExt; + let hotkey_handle = app.handle().clone(); + let _ = app.global_shortcut().on_shortcut("Super+C", move |_app, _shortcut, event| { + if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed { + if let Some(window) = hotkey_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + let _ = hotkey_handle.emit("focus-chat-input", ()); + } + } + }); + println!("⌨️ Global Hotkey registriert: Super+C"); + } + // WebKitGTK: Mikrofon-/Kamera-Permissions automatisch erlauben #[cfg(target_os = "linux")] { diff --git a/src/lib/components/ChatPanel.svelte b/src/lib/components/ChatPanel.svelte index 856f0ab..8abf9df 100644 --- a/src/lib/components/ChatPanel.svelte +++ b/src/lib/components/ChatPanel.svelte @@ -1,6 +1,6 @@