feat: Global Hotkey (Super+C) + Clipboard-Watch + Desktop-Integration [appimage]
All checks were successful
Build AppImage / build (push) Successful in 8m53s

- 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 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-21 11:50:56 +02:00
parent 6efbd5de5f
commit 87ba8f7bdf
8 changed files with 288 additions and 8 deletions

View file

@ -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`)

View file

@ -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 |
---

67
src-tauri/Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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"
]
}

168
src-tauri/src/clipboard.rs Normal file
View file

@ -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<Mutex<ClipboardWatcher>>;
/// 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 ", "=>", "->", "<?php", "#!/",
"{", "}", "();", "= {", "= ["];
let code_score: usize = code_indicators.iter()
.filter(|ind| trimmed.contains(*ind))
.count();
if code_score >= 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::<ClipboardState>();
{
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::<ClipboardState>().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::<ClipboardState>();
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<bool, String> {
let state = app.state::<ClipboardState>();
let watcher = state.lock().unwrap();
Ok(watcher.enabled)
}

View file

@ -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::<hooks::HookState>(Arc::new(Mutex::new(hooks::HookManager::default())))
.manage::<clipboard::ClipboardState>(Arc::new(Mutex::new(clipboard::ClipboardWatcher::default())))
.manage::<ide::IdeState>(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::MysqlPoolState>(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")]
{

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { emit } from '@tauri-apps/api/event';
import { emit, listen } from '@tauri-apps/api/event';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, type Message, type QuickAction } from '$lib/stores/app';
import { currentTool, processingPhase } from '$lib/stores/events';
import { marked, type Tokens } from 'marked';
@ -702,9 +702,21 @@
}
}
onMount(() => {
onMount(async () => {
window.addEventListener('keydown', handleGlobalKeydown);
// Global Hotkey: Super+C fokussiert das Input-Feld
const unlistenFocus = await listen('focus-chat-input', () => {
inputTextarea?.focus();
});
// Clipboard-Watch Events: Vorschlag im Chat anzeigen
const unlistenClipboard = await listen<{ content: string; content_type: string; suggestion: string }>('clipboard-changed', (event) => {
const { content, content_type, suggestion } = event.payload;
const preview = content.length > 200 ? content.substring(0, 200) + '...' : content;
addMessage('system', `📋 Clipboard (${content_type}): ${suggestion}\n\`\`\`\n${preview}\n\`\`\``);
});
// Queue-Auto-Dispatch: sobald isProcessing von true auf false wechselt
// wird die naechste Nachricht aus der Queue abgeschickt (FIFO).
let lastProcessing = false;
@ -724,6 +736,8 @@
return () => {
window.removeEventListener('keydown', handleGlobalKeydown);
unsubProcessing();
unlistenFocus();
unlistenClipboard();
};
});