Compare commits

..

2 commits

Author SHA1 Message Date
Eddy
c779fa7fc5 feat: Phase 7 — Accept/Reject DiffView, @-Mentions, Checkpoint/Rewind [appimage]
All checks were successful
Build AppImage / build (push) Successful in 10m19s
Accept/Reject DiffView:
- Bridge sendet checkpoint-before/after Events bei Edit/Write
- Rust speichert Datei-Snapshots in SQLite (content_before/after)
- Frontend zeigt interaktive DiffView mit Accept/Reject-Buttons
- Bei Reject: Datei wird automatisch auf Zustand VOR der Aenderung zurueckgesetzt
- Neues Modul: checkpoint.rs mit Accept/Reject/Rewind Commands

@-Mentions (Datei-Referenzen):
- @datei.ts im Chat-Input oeffnet Fuzzy-Autocomplete
- Fuzzy-Suche scannt Projektverzeichnis (max 5000 Dateien)
- Score-basiertes Ranking (Anfang, Separator, konsekutive Matches)
- Bei Auswahl: Dateiinhalt wird in Prompt injiziert (als Code-Block)
- @datei.ts#5-10 fuer Zeilenbereiche
- Neue Komponente: FileMention.svelte

Checkpoint/Rewind:
- Automatische Snapshots bei jeder Dateiänderung (Edit/Write)
- SQLite-Tabelle checkpoints mit content_before/content_after
- Rewind: Alle Dateien ab einem Checkpoint zuruecksetzen
- Commands: accept_change, reject_change, list_checkpoints, rewind_to_checkpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:56:56 +02:00
Eddy
f394d69b70 feat: Nachrichten während Claude arbeitet senden — wie in VS Code Extension
Bridge hat jetzt eine interne Pending-Queue: Nachrichten die während einer
laufenden query() eingehen werden gepuffert und nach dem aktuellen Turn
automatisch FIFO abgearbeitet. Kein Frontend-Queue mehr nötig.

User kann jetzt wie in Claude Code/VS Code Extension weiter tippen während
Claude arbeitet. Nachrichten erscheinen sofort im Chat und werden nahtlos
nach dem aktuellen Turn verarbeitet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:36:21 +02:00
11 changed files with 1121 additions and 91 deletions

View file

@ -36,6 +36,16 @@ if (!IS_DAEMON) process.stdin.resume();
let activeAbort = null; let activeAbort = null;
let currentAgentId = null; let currentAgentId = null;
let currentModel = process.env.CLAUDE_MODEL || 'opus'; let currentModel = process.env.CLAUDE_MODEL || 'opus';
let isQueryRunning = false;
// Pending-Queue: Nachrichten die während einer laufenden query() eingehen
// werden hier gepuffert und nach dem aktuellen Turn automatisch abgearbeitet.
// So kann der User wie in Claude Code/VS Code Extension weiter tippen.
const pendingMessages = [];
// Tool-Changes: Bei Edit/Write wird der Diff an Rust/Frontend gemeldet
// damit der User Accept/Reject entscheiden kann (Post-Execution mit Rewind)
const CHANGE_TOOLS = ['Edit', 'Write'];
// Agent-Modus (solo | handlanger | experten | auto) // Agent-Modus (solo | handlanger | experten | auto)
let agentMode = 'solo'; let agentMode = 'solo';
@ -339,6 +349,20 @@ function summarizeToolInput(tool, input) {
// ============ Claude Agent SDK ============ // ============ Claude Agent SDK ============
async function sendMessage(message, requestId, model = null, contextOverride = null, resumeSessionId = null) { async function sendMessage(message, requestId, model = null, contextOverride = null, resumeSessionId = null) {
// Wenn bereits eine query() läuft: Nachricht in Pending-Queue puffern
// und sofort bestätigen. Wird nach aktuellem Turn automatisch abgearbeitet.
if (isQueryRunning) {
pendingMessages.push({ message, requestId, model, contextOverride, resumeSessionId });
sendResponse(requestId, { status: 'queued', position: pendingMessages.length });
sendMonitorEvent('agent', `Nachricht gepuffert (Position ${pendingMessages.length})`, {
messageLength: message.length,
queueSize: pendingMessages.length,
});
return;
}
isQueryRunning = true;
// Modell für diese Anfrage (Parameter > State > Default) // Modell für diese Anfrage (Parameter > State > Default)
const useModel = model || currentModel; const useModel = model || currentModel;
@ -480,6 +504,9 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
// als auch als standalone tool_use Event. Via toolUseId deduplizieren. // als auch als standalone tool_use Event. Via toolUseId deduplizieren.
const handledTools = new Set(); const handledTools = new Set();
// Tool-Info Cache: toolId → { name, filePath } fuer Checkpoint-After
const toolInfoCache = new Map();
// Tool-Use handhaben — funktioniert sowohl fuer standalone tool_use Events // Tool-Use handhaben — funktioniert sowohl fuer standalone tool_use Events
// als auch fuer tool_use Bloecke innerhalb einer assistant-Nachricht // als auch fuer tool_use Bloecke innerhalb einer assistant-Nachricht
function handleToolUse(ev) { function handleToolUse(ev) {
@ -524,6 +551,19 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
agentId: currentAgentId, agentId: currentAgentId,
}); });
// Bei Edit/Write: Tool-Info cachen + Checkpoint-Event senden
if (CHANGE_TOOLS.includes(toolName) && toolInput.file_path) {
toolInfoCache.set(toolId, { name: toolName, filePath: toolInput.file_path });
sendEvent('checkpoint-before', {
toolId,
tool: toolName,
filePath: toolInput.file_path,
oldString: toolInput.old_string || null, // Edit: was ersetzt wird
newString: toolInput.new_string || null, // Edit: womit ersetzt wird
content: toolInput.content || null, // Write: neuer Inhalt
});
}
const toolSummary = summarizeToolInput(toolName, toolInput); const toolSummary = summarizeToolInput(toolName, toolInput);
sendMonitorEvent('tool', `${toolName} ${toolSummary}`, { sendMonitorEvent('tool', `${toolName} ${toolSummary}`, {
toolId, toolId,
@ -552,6 +592,17 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
success: !ev.is_error, success: !ev.is_error,
agentId: currentAgentId, agentId: currentAgentId,
}); });
// Bei Edit/Write: Checkpoint-After senden damit Frontend den Diff anzeigen kann
const toolInfo = toolInfoCache.get(toolId);
if (!ev.is_error && toolInfo) {
sendEvent('checkpoint-after', {
toolId,
tool: toolInfo.name,
filePath: toolInfo.filePath,
});
toolInfoCache.delete(toolId);
}
} }
// Hilfsfunktion: Iteration mit Fallback bei ungueltiger Session-ID. // Hilfsfunktion: Iteration mit Fallback bei ungueltiger Session-ID.
@ -767,6 +818,19 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
sendEvent('all-stopped'); sendEvent('all-stopped');
currentAgentId = null; currentAgentId = null;
activeAbort = null; activeAbort = null;
isQueryRunning = false;
// Pending-Queue: Nächste Nachricht automatisch abarbeiten (FIFO)
if (pendingMessages.length > 0) {
const next = pendingMessages.shift();
sendMonitorEvent('agent', `Pending-Nachricht wird verarbeitet (${pendingMessages.length} verbleibend)`, {
messageLength: next.message.length,
remaining: pendingMessages.length,
});
// Asynchron starten — nicht awaiten damit finally sauber abschließt
sendMessage(next.message, next.requestId, next.model, next.contextOverride, next.resumeSessionId)
.catch(err => sendMonitorEvent('error', `Pending-Dispatch fehlgeschlagen: ${err.message}`, {}));
}
} }
} }

125
src-tauri/src/checkpoint.rs Normal file
View file

@ -0,0 +1,125 @@
// Claude Desktop — Checkpoint-System (Accept/Reject + Rewind)
// Ermoeglicht das Rueckgaengigmachen von Datei-Aenderungen
use std::sync::{Arc, Mutex};
use tauri::command;
use crate::db;
/// Checkpoint-Info fuer Frontend (Serialisierbar)
#[derive(Debug, serde::Serialize)]
pub struct CheckpointInfo {
pub tool_id: String,
pub tool_name: String,
pub file_path: String,
pub status: String,
pub created_at: String,
pub has_diff: bool,
}
/// Aenderung akzeptieren — Checkpoint als 'accepted' markieren
#[command]
pub fn accept_change(
tool_id: String,
db_state: tauri::State<'_, Arc<Mutex<db::Database>>>,
) -> Result<String, String> {
let db = db_state.lock().map_err(|e| e.to_string())?;
db.accept_checkpoint(&tool_id)
.map_err(|e| format!("Checkpoint akzeptieren fehlgeschlagen: {}", e))?;
println!("✅ Aenderung akzeptiert: {}", tool_id);
Ok("akzeptiert".to_string())
}
/// Aenderung ablehnen — Datei auf content_before zuruecksetzen
#[command]
pub fn reject_change(
tool_id: String,
db_state: tauri::State<'_, Arc<Mutex<db::Database>>>,
) -> Result<String, String> {
let db = db_state.lock().map_err(|e| e.to_string())?;
let checkpoint = db
.get_checkpoint(&tool_id)
.map_err(|e| format!("Checkpoint laden fehlgeschlagen: {}", e))?
.ok_or_else(|| format!("Checkpoint nicht gefunden: {}", tool_id))?;
// Datei auf Zustand VOR der Aenderung zuruecksetzen
std::fs::write(&checkpoint.file_path, &checkpoint.content_before)
.map_err(|e| format!("Datei wiederherstellen fehlgeschlagen: {}", e))?;
db.reject_checkpoint(&tool_id)
.map_err(|e| format!("Checkpoint Status-Update fehlgeschlagen: {}", e))?;
println!(
"↩️ Aenderung abgelehnt + zurueckgesetzt: {} ({})",
checkpoint.file_path, tool_id
);
Ok(format!("Datei zurueckgesetzt: {}", checkpoint.file_path))
}
/// Alle Checkpoints einer Session auflisten
#[command]
pub fn list_checkpoints(
session_id: String,
db_state: tauri::State<'_, Arc<Mutex<db::Database>>>,
) -> Result<Vec<CheckpointInfo>, String> {
let db = db_state.lock().map_err(|e| e.to_string())?;
let checkpoints = db
.list_checkpoints(&session_id)
.map_err(|e| format!("Checkpoints laden fehlgeschlagen: {}", e))?;
Ok(checkpoints
.into_iter()
.map(|c| CheckpointInfo {
tool_id: c.tool_id,
tool_name: c.tool_name,
file_path: c.file_path,
status: c.status,
created_at: c.created_at,
has_diff: c.content_after.is_some(),
})
.collect())
}
/// Zu einem bestimmten Checkpoint zurueckspulen (alle danach auch revert)
#[command]
pub fn rewind_to_checkpoint(
tool_id: String,
session_id: String,
db_state: tauri::State<'_, Arc<Mutex<db::Database>>>,
) -> Result<Vec<String>, String> {
let db = db_state.lock().map_err(|e| e.to_string())?;
let checkpoints = db
.list_checkpoints(&session_id)
.map_err(|e| format!("Checkpoints laden fehlgeschlagen: {}", e))?;
// Alle Checkpoints ab dem angegebenen zuruecksetzen (neueste zuerst = richtige Reihenfolge)
let mut reverted_files = Vec::new();
let mut found = false;
for cp in &checkpoints {
if cp.tool_id == tool_id {
found = true;
}
if found && (cp.status == "completed" || cp.status == "pending") {
// Datei wiederherstellen
if let Err(e) = std::fs::write(&cp.file_path, &cp.content_before) {
println!("⚠️ Rewind fehlgeschlagen fuer {}: {}", cp.file_path, e);
continue;
}
let _ = db.reject_checkpoint(&cp.tool_id);
reverted_files.push(cp.file_path.clone());
println!("↩️ Rewind: {}", cp.file_path);
}
}
if !found {
return Err(format!("Checkpoint nicht gefunden: {}", tool_id));
}
println!(
"↩️ Rewind abgeschlossen: {} Dateien zurueckgesetzt",
reverted_files.len()
);
Ok(reverted_files)
}

View file

@ -515,6 +515,57 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) {
let mut state = state.lock().unwrap(); let mut state = state.lock().unwrap();
state.agents.clear(); state.agents.clear();
} }
"checkpoint-before" => {
// Datei VOR der Aenderung lesen und als Snapshot speichern
if let Some(file_path) = payload.get("filePath").and_then(|v| v.as_str()) {
let content_before = std::fs::read_to_string(file_path).unwrap_or_default();
let tool_id = payload.get("toolId").and_then(|v| v.as_str()).unwrap_or("");
let tool_name = payload.get("tool").and_then(|v| v.as_str()).unwrap_or("");
// In DB speichern
if let Some(db_state) = app.try_state::<Arc<Mutex<db::Database>>>() {
let db_lock = db_state.lock().unwrap();
if let Ok(Some(session_id)) = db_lock.get_setting("active_session_id") {
let _ = db_lock.save_checkpoint(
tool_id,
&session_id,
tool_name,
file_path,
&content_before,
);
println!("📸 Checkpoint: {} ({} Bytes)", file_path, content_before.len());
}
}
}
let _ = app.emit("checkpoint-before", &payload);
}
"checkpoint-after" => {
// Datei NACH der Aenderung lesen und Diff ans Frontend senden
if let Some(file_path) = payload.get("filePath").and_then(|v| v.as_str()) {
let tool_id = payload.get("toolId").and_then(|v| v.as_str()).unwrap_or("");
let content_after = std::fs::read_to_string(file_path).unwrap_or_default();
// content_before aus DB holen
if let Some(db_state) = app.try_state::<Arc<Mutex<db::Database>>>() {
let db_lock = db_state.lock().unwrap();
if let Ok(Some(checkpoint)) = db_lock.get_checkpoint(tool_id) {
// Erweitertes Event mit Diff-Daten ans Frontend
let change_event = serde_json::json!({
"toolId": tool_id,
"tool": payload.get("tool"),
"filePath": file_path,
"contentBefore": checkpoint.content_before,
"contentAfter": content_after,
});
let _ = app.emit("file-change", &change_event);
println!("📝 Datei-Aenderung: {}", file_path);
// content_after in Checkpoint updaten
let _ = db_lock.update_checkpoint_after(tool_id, &content_after);
}
}
}
}
other => { other => {
// Generische Weiterleitung aller Bridge-Events ans Frontend // Generische Weiterleitung aller Bridge-Events ans Frontend
// (subagent-started, subagent-stopped, monitor-event, mode-changed, // (subagent-started, subagent-stopped, monitor-event, mode-changed,

View file

@ -591,3 +591,205 @@ pub async fn list_sticky_context(
db.load_sticky_context().map_err(|e| e.to_string()) db.load_sticky_context().map_err(|e| e.to_string())
} }
// ============ @-Mentions: Fuzzy File Search ============
/// Datei-Ergebnis fuer Frontend
#[derive(Debug, Serialize)]
pub struct FileResult {
pub name: String,
pub path: String, // Relativer Pfad
pub full_path: String, // Absoluter Pfad
}
/// Ignorierte Verzeichnisse beim Datei-Scan
const IGNORE_DIRS: &[&str] = &[
"node_modules", ".git", "target", "build", "dist", ".svelte-kit",
"__pycache__", ".next", ".nuxt", "vendor", ".cargo",
];
/// Relativen Pfad in absoluten aufloesen (relativ zum Projektverzeichnis)
#[tauri::command]
pub async fn resolve_file_path(
relative_path: String,
app: AppHandle,
) -> Result<String, String> {
let working_dir = {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.get_setting("working_dir")
.ok()
.flatten()
.unwrap_or_else(|| ".".to_string())
};
let full_path = std::path::Path::new(&working_dir).join(&relative_path);
if full_path.exists() {
Ok(full_path.to_string_lossy().to_string())
} else {
Err(format!("Datei nicht gefunden: {}", relative_path))
}
}
/// Dateiinhalt lesen (optional mit Zeilenbereich)
#[tauri::command]
pub async fn read_file_content(
file_path: String,
line_range: Option<String>,
) -> Result<String, String> {
let content = std::fs::read_to_string(&file_path)
.map_err(|e| format!("Datei lesen fehlgeschlagen: {}", e))?;
// Zeilenbereich anwenden: "5-10" oder "5"
if let Some(range) = line_range {
let lines: Vec<&str> = content.lines().collect();
let parts: Vec<&str> = range.split('-').collect();
let start = parts[0].parse::<usize>().unwrap_or(1).saturating_sub(1);
let end = if parts.len() > 1 {
parts[1].parse::<usize>().unwrap_or(lines.len())
} else {
start + 1
};
let end = end.min(lines.len());
Ok(lines[start..end].join("\n"))
} else {
// Max 50KB
if content.len() > 50_000 {
Ok(content[..50_000].to_string() + "\n... (abgeschnitten bei 50KB)")
} else {
Ok(content)
}
}
}
/// Fuzzy-Suche in Projektdateien
#[tauri::command]
pub async fn fuzzy_search_files(
query: String,
app: AppHandle,
) -> Result<Vec<FileResult>, String> {
// Aktives Projektverzeichnis ermitteln
let working_dir = {
let state = app.state::<DbState>();
let db = state.lock().unwrap();
db.get_setting("working_dir")
.ok()
.flatten()
.unwrap_or_else(|| ".".to_string())
};
let query_lower = query.to_lowercase();
let mut results: Vec<(FileResult, i32)> = Vec::new();
// Rekursiv Dateien scannen (max 5000)
let mut stack = vec![std::path::PathBuf::from(&working_dir)];
let mut file_count = 0;
let max_files = 5000;
while let Some(dir) = stack.pop() {
if file_count >= max_files {
break;
}
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
// Ignorierte Verzeichnisse ueberspringen
if path.is_dir() {
if !IGNORE_DIRS.contains(&file_name.as_str()) && !file_name.starts_with('.') {
stack.push(path);
}
continue;
}
file_count += 1;
// Fuzzy-Match: Query-Zeichen muessen in Reihenfolge im Dateinamen vorkommen
let name_lower = file_name.to_lowercase();
let score = fuzzy_score(&query_lower, &name_lower);
if score > 0 {
let rel_path = path
.strip_prefix(&working_dir)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
results.push((
FileResult {
name: file_name,
path: rel_path,
full_path: path.to_string_lossy().to_string(),
},
score,
));
}
}
}
// Nach Score sortieren (hoechster zuerst), max 10 Ergebnisse
results.sort_by(|a, b| b.1.cmp(&a.1));
Ok(results.into_iter().take(10).map(|(r, _)| r).collect())
}
/// Fuzzy-Score berechnen: Wie gut passt query in target?
/// Hoeher = besser. 0 = kein Match.
fn fuzzy_score(query: &str, target: &str) -> i32 {
let query_chars: Vec<char> = query.chars().collect();
let target_chars: Vec<char> = target.chars().collect();
if query_chars.is_empty() {
return 0;
}
let mut score = 0i32;
let mut qi = 0;
let mut prev_match = false;
for (ti, &tc) in target_chars.iter().enumerate() {
if qi < query_chars.len() && tc == query_chars[qi] {
score += 10; // Basis-Punkte fuer Match
// Bonus fuer aufeinanderfolgende Matches
if prev_match {
score += 5;
}
// Bonus fuer Match am Anfang
if ti == 0 {
score += 15;
}
// Bonus fuer Match nach Separator (/, -, _, .)
if ti > 0 {
let prev = target_chars[ti - 1];
if prev == '/' || prev == '-' || prev == '_' || prev == '.' {
score += 10;
}
}
qi += 1;
prev_match = true;
} else {
prev_match = false;
}
}
// Alle Query-Zeichen muessen matchen
if qi < query_chars.len() {
return 0;
}
// Bonus fuer exakte Laenge-Naehe (kurze Namen bevorzugen)
let length_diff = (target_chars.len() as i32 - query_chars.len() as i32).abs();
score -= length_diff;
score.max(1)
}

View file

@ -54,6 +54,19 @@ pub struct MonitorEvent {
pub error: Option<String>, pub error: Option<String>,
} }
/// Checkpoint-Eintrag (Datei-Snapshot fuer Accept/Reject + Rewind)
#[allow(dead_code)]
pub struct CheckpointEntry {
pub tool_id: String,
pub session_id: String,
pub tool_name: String,
pub file_path: String,
pub content_before: String,
pub content_after: Option<String>,
pub status: String,
pub created_at: String,
}
/// Datenbank-Wrapper /// Datenbank-Wrapper
pub struct Database { pub struct Database {
pub(crate) conn: Connection, pub(crate) conn: Connection,
@ -230,6 +243,19 @@ impl Database {
UNIQUE(error_hash) UNIQUE(error_hash)
); );
CREATE INDEX IF NOT EXISTS idx_error_tracker_count ON error_tracker(occurrence_count DESC); CREATE INDEX IF NOT EXISTS idx_error_tracker_count ON error_tracker(occurrence_count DESC);
-- Checkpoints: Datei-Snapshots fuer Accept/Reject und Rewind
CREATE TABLE IF NOT EXISTS checkpoints (
tool_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
file_path TEXT NOT NULL,
content_before TEXT NOT NULL,
content_after TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON checkpoints(session_id, created_at DESC);
", ",
)?; )?;
Ok(()) Ok(())
@ -887,6 +913,94 @@ impl Database {
Ok(counts) Ok(counts)
} }
// ============ Checkpoints (Accept/Reject + Rewind) ============
/// Checkpoint speichern (VOR der Aenderung)
pub fn save_checkpoint(
&self,
tool_id: &str,
session_id: &str,
tool_name: &str,
file_path: &str,
content_before: &str,
) -> SqlResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO checkpoints (tool_id, session_id, tool_name, file_path, content_before, status)
VALUES (?1, ?2, ?3, ?4, ?5, 'pending')",
params![tool_id, session_id, tool_name, file_path, content_before],
)?;
Ok(())
}
/// Checkpoint After-Content updaten
pub fn update_checkpoint_after(&self, tool_id: &str, content_after: &str) -> SqlResult<()> {
self.conn.execute(
"UPDATE checkpoints SET content_after = ?1, status = 'completed' WHERE tool_id = ?2",
params![content_after, tool_id],
)?;
Ok(())
}
/// Checkpoint abrufen
pub fn get_checkpoint(&self, tool_id: &str) -> SqlResult<Option<CheckpointEntry>> {
let mut stmt = self.conn.prepare(
"SELECT tool_id, session_id, tool_name, file_path, content_before, content_after, status, created_at
FROM checkpoints WHERE tool_id = ?1"
)?;
let result: Vec<CheckpointEntry> = stmt.query_map(params![tool_id], |row| {
Ok(CheckpointEntry {
tool_id: row.get(0)?,
session_id: row.get(1)?,
tool_name: row.get(2)?,
file_path: row.get(3)?,
content_before: row.get(4)?,
content_after: row.get(5)?,
status: row.get(6)?,
created_at: row.get(7)?,
})
})?.collect::<SqlResult<Vec<_>>>()?;
Ok(result.into_iter().next())
}
/// Alle Checkpoints einer Session (neueste zuerst)
pub fn list_checkpoints(&self, session_id: &str) -> SqlResult<Vec<CheckpointEntry>> {
let mut stmt = self.conn.prepare(
"SELECT tool_id, session_id, tool_name, file_path, content_before, content_after, status, created_at
FROM checkpoints WHERE session_id = ?1 ORDER BY created_at DESC LIMIT 100"
)?;
let result = stmt.query_map(params![session_id], |row| {
Ok(CheckpointEntry {
tool_id: row.get(0)?,
session_id: row.get(1)?,
tool_name: row.get(2)?,
file_path: row.get(3)?,
content_before: row.get(4)?,
content_after: row.get(5)?,
status: row.get(6)?,
created_at: row.get(7)?,
})
})?.collect::<SqlResult<Vec<_>>>()?;
Ok(result)
}
/// Checkpoint-Status auf 'rejected' setzen
pub fn reject_checkpoint(&self, tool_id: &str) -> SqlResult<()> {
self.conn.execute(
"UPDATE checkpoints SET status = 'rejected' WHERE tool_id = ?1",
params![tool_id],
)?;
Ok(())
}
/// Checkpoint-Status auf 'accepted' setzen
pub fn accept_checkpoint(&self, tool_id: &str) -> SqlResult<()> {
self.conn.execute(
"UPDATE checkpoints SET status = 'accepted' WHERE tool_id = ?1",
params![tool_id],
)?;
Ok(())
}
// ============ Phase 2.0: Fehler-Tracking ============ // ============ Phase 2.0: Fehler-Tracking ============
/// Fehler-Occurrence zählen und zurückgeben /// Fehler-Occurrence zählen und zurückgeben

View file

@ -12,6 +12,7 @@ use tauri::{
use webkit2gtk::WebViewExt; use webkit2gtk::WebViewExt;
mod audit; mod audit;
mod checkpoint;
mod claude; mod claude;
mod clipboard; mod clipboard;
mod commands; mod commands;
@ -150,6 +151,9 @@ pub fn run() {
context::log_context_failure, context::log_context_failure,
context::get_full_context, context::get_full_context,
context::list_sticky_context, context::list_sticky_context,
context::fuzzy_search_files,
context::resolve_file_path,
context::read_file_content,
// Voice-Interface // Voice-Interface
voice::transcribe_audio, voice::transcribe_audio,
voice::text_to_speech, voice::text_to_speech,
@ -196,6 +200,11 @@ pub fn run() {
clipboard::clipboard_watch_status, clipboard::clipboard_watch_status,
// Slash-Command Registry // Slash-Command Registry
commands::get_slash_commands, commands::get_slash_commands,
// Checkpoints (Accept/Reject + Rewind)
checkpoint::accept_change,
checkpoint::reject_change,
checkpoint::list_checkpoints,
checkpoint::rewind_to_checkpoint,
]) ])
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();

View file

@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { emit, listen } from '@tauri-apps/api/event'; import { emit, listen } from '@tauri-apps/api/event';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, agentMode, type Message, type QuickAction } from '$lib/stores/app'; import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, agentMode, pendingChanges, type Message, type QuickAction, type FileChange } from '$lib/stores/app';
import { currentTool, processingPhase } from '$lib/stores/events'; import { currentTool, processingPhase } from '$lib/stores/events';
import { marked, type Tokens } from 'marked'; import { marked, type Tokens } from 'marked';
import { tick, onDestroy, onMount } from 'svelte'; import { tick, onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import CommandPalette from './CommandPalette.svelte'; import CommandPalette from './CommandPalette.svelte';
import DiffView from './DiffView.svelte';
import FileMention from './FileMention.svelte';
import QuickActions from './QuickActions.svelte'; import QuickActions from './QuickActions.svelte';
// Props // Props
@ -323,18 +325,45 @@
let commandQuery = $state(''); let commandQuery = $state('');
let commandPaletteRef: CommandPalette | undefined = $state(undefined); let commandPaletteRef: CommandPalette | undefined = $state(undefined);
// Slash-Command Erkennung im Input // @-Mention Autocomplete State
let showFileMention = $state(false);
let mentionQuery = $state('');
let fileMentionRef: FileMention | undefined = $state(undefined);
// Slash-Command und @-Mention Erkennung im Input
$effect(() => { $effect(() => {
const text = $currentInput; const text = $currentInput;
if (text.startsWith('/')) { if (text.startsWith('/')) {
showCommandPalette = true; showCommandPalette = true;
commandQuery = text.slice(1); commandQuery = text.slice(1);
showFileMention = false;
} else { } else {
showCommandPalette = false; showCommandPalette = false;
commandQuery = ''; commandQuery = '';
} }
// @-Mention: Suche das letzte @ im Text
const atMatch = text.match(/@([\w.\-/]+)$/);
if (atMatch && atMatch[1].length > 0) {
showFileMention = true;
mentionQuery = atMatch[1];
} else {
showFileMention = false;
mentionQuery = '';
}
}); });
// @-Mention: Datei ausgewaehlt → @query durch @dateiname ersetzen
function handleFileSelect(file: { name: string; path: string; full_path: string }) {
// Ersetze das letzte @query durch @dateiname
const text = $currentInput;
const atIdx = text.lastIndexOf('@');
if (atIdx >= 0) {
$currentInput = text.substring(0, atIdx) + `@${file.path} `;
}
showFileMention = false;
}
// Command auswählen: Text ersetzen // Command auswählen: Text ersetzen
function handleCommandSelect(cmd: { name: string; description: string; category: string }) { function handleCommandSelect(cmd: { name: string; description: string; category: string }) {
if (!cmd.name) { if (!cmd.name) {
@ -717,21 +746,8 @@
addMessage('system', `📋 Clipboard (${content_type}): ${suggestion}\n\`\`\`\n${preview}\n\`\`\``); addMessage('system', `📋 Clipboard (${content_type}): ${suggestion}\n\`\`\`\n${preview}\n\`\`\``);
}); });
// Queue-Auto-Dispatch: sobald isProcessing von true auf false wechselt // Legacy-Queue Subscriber (Sicherheitsnetz — Hauptlogik ist jetzt in der Bridge)
// wird die naechste Nachricht aus der Queue abgeschickt (FIFO). const unsubProcessing = isProcessing.subscribe(() => {});
let lastProcessing = false;
const unsubProcessing = isProcessing.subscribe((val) => {
if (lastProcessing && !val) {
const queue = get(messageQueue);
if (queue.length > 0) {
const [next, ...rest] = queue;
messageQueue.set(rest);
$queuedMessage = rest.length > 0 ? rest[0] : null;
dispatchMessage(next).catch((e) => console.error('Queue-Dispatch fehlgeschlagen:', e));
}
}
lastProcessing = val;
});
return () => { return () => {
window.removeEventListener('keydown', handleGlobalKeydown); window.removeEventListener('keydown', handleGlobalKeydown);
@ -742,39 +758,46 @@
}); });
function cancelQueued() { function cancelQueued() {
// Alle gequeuten Nachrichten verwerfen + aus dem Chat entfernen // Legacy: Queue-Cancel (Bridge hat jetzt eigene Pending-Queue)
messageQueue.set([]); messageQueue.set([]);
$queuedMessage = null; $queuedMessage = null;
messages.update((msgs) => msgs.filter((m) => !(m as any).queued)); }
// Accept/Reject Datei-Aenderungen
async function acceptChange(toolId: string) {
try {
await invoke('accept_change', { toolId });
pendingChanges.update((changes) => changes.filter((c) => c.toolId !== toolId));
console.log('✅ Aenderung akzeptiert:', toolId);
} catch (err) {
console.error('Accept fehlgeschlagen:', err);
}
}
async function rejectChange(toolId: string) {
try {
const result = await invoke<string>('reject_change', { toolId });
pendingChanges.update((changes) => changes.filter((c) => c.toolId !== toolId));
addMessage('system', `↩️ ${result}`);
console.log('↩️ Aenderung abgelehnt:', toolId);
} catch (err) {
console.error('Reject fehlgeschlagen:', err);
addMessage('system', `Fehler beim Zuruecksetzen: ${err}`);
}
} }
async function sendMessage() { async function sendMessage() {
const text = $currentInput.trim(); const text = $currentInput.trim();
if (!text) return; if (!text) return;
// Waehrend Claude antwortet: Nachricht in FIFO-Queue. // Nachricht IMMER sofort senden — auch während Claude arbeitet.
// Sofort als User-Message im Chat anzeigen (mit queued-Marker). // Die Bridge puffert die Nachricht intern und verarbeitet sie
// Der Subscriber dispatcht automatisch wenn Claude fertig ist. // automatisch nach dem aktuellen Turn (wie in Claude Code/VS Code Extension).
if ($isProcessing) {
messageQueue.update((q) => [...q, text]);
$queuedMessage = text;
// Sofort im Chat anzeigen damit User sieht dass die Nachricht angekommen ist
const queuedMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content: text,
timestamp: new Date(),
queued: true,
};
messages.update((msgs) => [...msgs, queuedMsg]);
$currentInput = '';
return;
}
await dispatchMessage(text); await dispatchMessage(text);
} }
// Den eigentlichen Send-Flow ausgelagert, damit er auch fuer die Queue genutzt wird. // Den eigentlichen Send-Flow — sendet immer sofort an die Bridge.
// Wenn eine query() laeuft, puffert die Bridge die Nachricht intern.
async function dispatchMessage(text: string) { async function dispatchMessage(text: string) {
// Auto-Session erstellen falls keine aktiv // Auto-Session erstellen falls keine aktiv
let sessionId = get(currentSessionId); let sessionId = get(currentSessionId);
@ -794,17 +817,27 @@
} }
} }
// Pruefen ob die Nachricht schon als queued im Chat steht // @-Mentions aufloesen: @pfad/datei.ts durch Dateiinhalt ersetzen
const existingQueued = get(messages).find((m) => m.queued && m.content === text); let resolvedText = text;
if (existingQueued) { const mentionPattern = /@([\w.\-/]+(?:#\d+(?:-\d+)?)?)/g;
// Queued-Markierung entfernen — Nachricht ist jetzt aktiv const mentions = [...text.matchAll(mentionPattern)];
messages.update((msgs) => for (const match of mentions) {
msgs.map((m) => m.id === existingQueued.id ? { ...m, queued: false } : m) const ref = match[1];
const [filePath, lineRange] = ref.split('#');
try {
const fullPath = filePath.startsWith('/') ? filePath : await invoke<string>('resolve_file_path', { relativePath: filePath });
const content = await invoke<string>('read_file_content', { filePath: fullPath, lineRange: lineRange || null });
const ext = filePath.split('.').pop() || '';
resolvedText = resolvedText.replace(
match[0],
`\n\`\`\`${ext} (${filePath})\n${content}\n\`\`\`\n`
); );
// In DB speichern (wurde vorher nicht gespeichert da queued) } catch {
await saveMessageToDb({ ...existingQueued, queued: false }); // Datei nicht gefunden — @-Mention unverändert lassen
} else { }
// Neue Nachricht hinzufuegen (normaler Send, nicht aus Queue) }
// User-Nachricht sofort im Chat anzeigen + in DB speichern
const msgId = crypto.randomUUID(); const msgId = crypto.randomUUID();
const msg: Message = { const msg: Message = {
id: msgId, id: msgId,
@ -813,18 +846,23 @@
timestamp: new Date(), timestamp: new Date(),
}; };
messages.update((msgs) => [...msgs, msg]); messages.update((msgs) => [...msgs, msg]);
// Sofort speichern (nicht auf Subscriber warten)
await saveMessageToDb(msg); await saveMessageToDb(msg);
}
$currentInput = ''; $currentInput = '';
// isProcessing nur setzen wenn nicht schon aktiv
// (bei gepufferten Nachrichten laeuft Claude ja schon)
if (!$isProcessing) {
$isProcessing = true; $isProcessing = true;
}
try { try {
await invoke('send_message', { message: text }); // Aufgeloesten Text an Claude senden (mit eingebetteten Datei-Inhalten)
await invoke('send_message', { message: resolvedText });
} catch (err) { } catch (err) {
console.error('Fehler beim Senden:', err); console.error('Fehler beim Senden:', err);
addMessage('system', `Fehler: ${err}`); addMessage('system', `Fehler: ${err}`);
// Nur auf false setzen wenn keine weiteren Nachrichten pending
$isProcessing = false; $isProcessing = false;
} }
} }
@ -836,6 +874,12 @@
if (handled) return; if (handled) return;
} }
// FileMention hat Vorrang bei @-Autocomplete
if (showFileMention && fileMentionRef) {
const handled = fileMentionRef.handleKey(event);
if (handled) return;
}
// Ctrl+Enter: Immer senden (auch bei mehrzeiligem Text) // Ctrl+Enter: Immer senden (auch bei mehrzeiligem Text)
if (event.key === 'Enter' && event.ctrlKey) { if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault(); event.preventDefault();
@ -1201,6 +1245,32 @@
{/if} {/if}
</div> </div>
<!-- Pending Changes: DiffView mit Accept/Reject -->
{#if $pendingChanges.length > 0}
<div class="pending-changes">
<div class="pending-changes-header">
<span>📝 {$pendingChanges.length} Datei-Aenderung{$pendingChanges.length > 1 ? 'en' : ''}</span>
<button
class="btn-accept-all"
onclick={() => $pendingChanges.forEach((c) => acceptChange(c.toolId))}
>
✅ Alle behalten
</button>
</div>
{#each $pendingChanges as change (change.toolId)}
<DiffView
oldText={change.contentBefore}
newText={change.contentAfter}
filename={change.filePath}
interactive={true}
toolId={change.toolId}
onAccept={acceptChange}
onReject={rejectChange}
/>
{/each}
</div>
{/if}
<div class="chat-input"> <div class="chat-input">
<CommandPalette <CommandPalette
bind:this={commandPaletteRef} bind:this={commandPaletteRef}
@ -1208,6 +1278,12 @@
visible={showCommandPalette} visible={showCommandPalette}
onSelect={handleCommandSelect} onSelect={handleCommandSelect}
/> />
<FileMention
bind:this={fileMentionRef}
query={mentionQuery}
visible={showFileMention}
onSelect={handleFileSelect}
/>
{#if $agentMode && $agentMode !== 'solo'} {#if $agentMode && $agentMode !== 'solo'}
<div class="mode-indicator mode-{$agentMode}"> <div class="mode-indicator mode-{$agentMode}">
<span class="mode-icon"> <span class="mode-icon">
@ -1239,18 +1315,11 @@
<span class="transcript-text">{liveTranscript}</span> <span class="transcript-text">{liveTranscript}</span>
</div> </div>
{/if} {/if}
{#if $messageQueue.length > 0}
<div class="queued-pill" title="Wird gesendet sobald Claude die aktuelle Antwort fertig hat">
<span class="queued-icon">📬</span>
<span class="queued-text">{$messageQueue.length} Nachricht{$messageQueue.length > 1 ? 'en' : ''} in der Queue</span>
<button class="queued-cancel" onclick={cancelQueued} aria-label="Wartende Nachrichten verwerfen"></button>
</div>
{/if}
<textarea <textarea
bind:this={inputTextarea} bind:this={inputTextarea}
bind:value={$currentInput} bind:value={$currentInput}
onkeydown={handleKeydown} onkeydown={handleKeydown}
placeholder={$isProcessing ? 'Nachricht eingeben — wird nach Antwort automatisch gesendet' : 'Nachricht eingeben... (Ctrl+K = Quick-Actions, Ctrl+Enter = Senden)'} placeholder={$isProcessing ? 'Weiter tippen — wird nach aktuellem Turn verarbeitet...' : 'Nachricht eingeben... (Ctrl+K = Quick-Actions, Ctrl+Enter = Senden)'}
disabled={isRecording} disabled={isRecording}
rows="3" rows="3"
></textarea> ></textarea>
@ -1967,6 +2036,41 @@
/* Alte Typing-Animation entfernt — ersetzt durch .activity-indicator */ /* Alte Typing-Animation entfernt — ersetzt durch .activity-indicator */
/* Input-Bereich */ /* Input-Bereich */
/* Pending Changes (Accept/Reject DiffView) */
.pending-changes {
border-top: 2px solid var(--accent);
background: var(--bg-secondary);
max-height: 300px;
overflow-y: auto;
flex-shrink: 0;
}
.pending-changes-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 0.7rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.btn-accept-all {
padding: 2px 8px;
font-size: 0.6rem;
background: rgba(34, 197, 94, 0.15);
border: 1px solid var(--success);
border-radius: var(--radius-sm);
color: var(--success);
cursor: pointer;
font-weight: 600;
}
.btn-accept-all:hover {
background: var(--success);
color: white;
}
.chat-input { .chat-input {
display: flex; display: flex;
gap: var(--spacing-sm); gap: var(--spacing-sm);

View file

@ -1,15 +1,28 @@
<script lang="ts"> <script lang="ts">
// DiffView — Zeigt Unterschiede zwischen altem und neuem Code // DiffView — Zeigt Unterschiede zwischen altem und neuem Code
// Verwendet für Edit-Tool Ergebnisse // Mit optionalen Accept/Reject-Buttons fuer interaktiven Modus
interface Props { interface Props {
oldText: string; oldText: string;
newText: string; newText: string;
filename?: string; filename?: string;
language?: string; language?: string;
interactive?: boolean;
toolId?: string;
onAccept?: (toolId: string) => void;
onReject?: (toolId: string) => void;
} }
let { oldText, newText, filename = '', language = '' }: Props = $props(); let {
oldText,
newText,
filename = '',
language = '',
interactive = false,
toolId = '',
onAccept,
onReject,
}: Props = $props();
// Einfache Diff-Berechnung (zeilenbasiert) // Einfache Diff-Berechnung (zeilenbasiert)
interface DiffLine { interface DiffLine {
@ -31,7 +44,7 @@
while (oldIdx < oldLines.length || newIdx < newLines.length) { while (oldIdx < oldLines.length || newIdx < newLines.length) {
if (lcsIdx < lcs.length && oldIdx < oldLines.length && oldLines[oldIdx] === lcs[lcsIdx]) { if (lcsIdx < lcs.length && oldIdx < oldLines.length && oldLines[oldIdx] === lcs[lcsIdx]) {
// Unveränderte Zeile // Unveraenderte Zeile
if (newIdx < newLines.length && newLines[newIdx] === lcs[lcsIdx]) { if (newIdx < newLines.length && newLines[newIdx] === lcs[lcsIdx]) {
result.push({ result.push({
type: 'unchanged', type: 'unchanged',
@ -42,7 +55,7 @@
newIdx++; newIdx++;
lcsIdx++; lcsIdx++;
} else { } else {
// Neue Zeile hinzugefügt // Neue Zeile hinzugefuegt
result.push({ result.push({
type: 'added', type: 'added',
lineNo: { old: null, new: newIdx + 1 }, lineNo: { old: null, new: newIdx + 1 },
@ -59,7 +72,7 @@
}); });
oldIdx++; oldIdx++;
} else if (newIdx < newLines.length) { } else if (newIdx < newLines.length) {
// Zeile hinzugefügt // Zeile hinzugefuegt
result.push({ result.push({
type: 'added', type: 'added',
lineNo: { old: null, new: newIdx + 1 }, lineNo: { old: null, new: newIdx + 1 },
@ -105,7 +118,7 @@
return result; return result;
} }
// Diff berechnen wenn sich Inputs ändern // Diff berechnen wenn sich Inputs aendern
let diffLines = $derived(computeDiff(oldText, newText)); let diffLines = $derived(computeDiff(oldText, newText));
// Statistiken // Statistiken
@ -114,24 +127,81 @@
removed: diffLines.filter(l => l.type === 'removed').length, removed: diffLines.filter(l => l.type === 'removed').length,
unchanged: diffLines.filter(l => l.type === 'unchanged').length, unchanged: diffLines.filter(l => l.type === 'unchanged').length,
}); });
// Nur geaenderte Zeilen anzeigen mit Kontext (3 Zeilen davor/danach)
let showFullDiff = $state(false);
let contextLines = 3;
function getVisibleLines(): { line: DiffLine; idx: number }[] {
if (showFullDiff) return diffLines.map((line, idx) => ({ line, idx }));
const changedIndices = new Set<number>();
diffLines.forEach((line, idx) => {
if (line.type !== 'unchanged') {
for (let i = Math.max(0, idx - contextLines); i <= Math.min(diffLines.length - 1, idx + contextLines); i++) {
changedIndices.add(i);
}
}
});
return Array.from(changedIndices).sort((a, b) => a - b).map(idx => ({ line: diffLines[idx], idx }));
}
let visibleLines = $derived(getVisibleLines());
function handleAccept() {
if (onAccept && toolId) onAccept(toolId);
}
function handleReject() {
if (onReject && toolId) onReject(toolId);
}
// Dateiname kuerzen fuer Anzeige
function shortenPath(path: string): string {
const parts = path.split('/');
if (parts.length > 3) return `.../${parts.slice(-2).join('/')}`;
return path;
}
</script> </script>
<div class="diff-view"> <div class="diff-view" class:interactive>
{#if filename}
<div class="diff-header"> <div class="diff-header">
<span class="filename">{filename}</span> <div class="diff-header-left">
{#if filename}
<span class="filename" title={filename}>{shortenPath(filename)}</span>
{/if}
{#if language} {#if language}
<span class="language">{language}</span> <span class="language">{language}</span>
{/if} {/if}
<span class="stats"> <span class="stats">
<span class="added">+{stats.added}</span> <span class="stat-added">+{stats.added}</span>
<span class="removed">-{stats.removed}</span> <span class="stat-removed">-{stats.removed}</span>
</span> </span>
</div> </div>
<div class="diff-header-right">
{#if diffLines.length > 20}
<button class="btn-toggle" onclick={() => showFullDiff = !showFullDiff}>
{showFullDiff ? 'Kompakt' : 'Alles zeigen'}
</button>
{/if} {/if}
{#if interactive}
<button class="btn-accept" onclick={handleAccept} title="Aenderung behalten">
✅ Behalten
</button>
<button class="btn-reject" onclick={handleReject} title="Aenderung rueckgaengig machen">
↩️ Zurueck
</button>
{/if}
</div>
</div>
<div class="diff-content"> <div class="diff-content">
{#each diffLines as line, idx (idx)} {#each visibleLines as { line, idx } (idx)}
{@const prevIdx = visibleLines[visibleLines.indexOf({ line, idx }) - 1]?.idx}
{#if idx > 0 && prevIdx !== undefined && idx - prevIdx > 1}
<div class="diff-separator"></div>
{/if}
<div class="diff-line" class:added={line.type === 'added'} class:removed={line.type === 'removed'}> <div class="diff-line" class:added={line.type === 'added'} class:removed={line.type === 'removed'}>
<span class="line-no old">{line.lineNo.old ?? ''}</span> <span class="line-no old">{line.lineNo.old ?? ''}</span>
<span class="line-no new">{line.lineNo.new ?? ''}</span> <span class="line-no new">{line.lineNo.new ?? ''}</span>
@ -153,18 +223,42 @@
overflow: hidden; overflow: hidden;
} }
.diff-view.interactive {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 2px 8px rgba(0, 0, 0, 0.2);
}
.diff-header { .diff-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm); justify-content: space-between;
padding: var(--spacing-xs) var(--spacing-sm); padding: var(--spacing-xs) var(--spacing-sm);
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
gap: var(--spacing-sm);
}
.diff-header-left {
display: flex;
align-items: center;
gap: var(--spacing-sm);
min-width: 0;
flex: 1;
}
.diff-header-right {
display: flex;
align-items: center;
gap: var(--spacing-xs);
flex-shrink: 0;
} }
.filename { .filename {
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.language { .language {
@ -176,23 +270,66 @@
} }
.stats { .stats {
margin-left: auto;
display: flex; display: flex;
gap: var(--spacing-xs); gap: var(--spacing-xs);
font-size: 0.7rem; font-size: 0.7rem;
} }
.stats .added { .stat-added { color: var(--success); }
color: var(--success); .stat-removed { color: var(--error); }
.btn-toggle {
padding: 2px 6px;
font-size: 0.6rem;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
} }
.stats .removed { .btn-toggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-accept {
padding: 2px 8px;
font-size: 0.65rem;
background: rgba(34, 197, 94, 0.15);
border: 1px solid var(--success);
border-radius: var(--radius-sm);
color: var(--success);
cursor: pointer;
font-weight: 600;
transition: all 0.15s;
}
.btn-accept:hover {
background: var(--success);
color: white;
}
.btn-reject {
padding: 2px 8px;
font-size: 0.65rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
border-radius: var(--radius-sm);
color: var(--error); color: var(--error);
cursor: pointer;
font-weight: 600;
transition: all 0.15s;
}
.btn-reject:hover {
background: var(--error);
color: white;
} }
.diff-content { .diff-content {
overflow-x: auto; overflow-x: auto;
max-height: 300px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
} }
@ -239,4 +376,13 @@
flex: 1; flex: 1;
padding-left: var(--spacing-xs); padding-left: var(--spacing-xs);
} }
.diff-separator {
text-align: center;
padding: 2px 0;
color: var(--text-secondary);
background: var(--bg-tertiary);
font-size: 0.6rem;
user-select: none;
}
</style> </style>

View file

@ -0,0 +1,169 @@
<script lang="ts">
// FileMention — @-Autocomplete fuer Projekt-Dateien
// Trigger: @ gefolgt von mindestens 1 Zeichen im Chat-Input
// Fuzzy-Match gegen Dateien im Projektverzeichnis
import { invoke } from '@tauri-apps/api/core';
interface FileResult {
name: string; // Dateiname
path: string; // Relativer Pfad
full_path: string; // Absoluter Pfad
}
interface Props {
query: string;
visible: boolean;
onSelect: (file: FileResult) => void;
}
let { query, visible, onSelect }: Props = $props();
let results: FileResult[] = $state([]);
let selectedIndex = $state(0);
let loading = $state(false);
// Suche triggern wenn Query sich aendert
$effect(() => {
if (visible && query.length > 0) {
searchFiles(query);
} else {
results = [];
}
});
async function searchFiles(q: string) {
loading = true;
try {
results = await invoke<FileResult[]>('fuzzy_search_files', { query: q });
selectedIndex = 0;
} catch (err) {
console.debug('Datei-Suche fehlgeschlagen:', err);
results = [];
}
loading = false;
}
// Keyboard-Navigation (wird von ChatPanel aufgerufen)
export function handleKey(event: KeyboardEvent): boolean {
if (!visible || results.length === 0) return false;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
selectedIndex = (selectedIndex + 1) % results.length;
return true;
case 'ArrowUp':
event.preventDefault();
selectedIndex = (selectedIndex - 1 + results.length) % results.length;
return true;
case 'Tab':
case 'Enter':
event.preventDefault();
if (results[selectedIndex]) {
onSelect(results[selectedIndex]);
}
return true;
case 'Escape':
event.preventDefault();
return true;
}
return false;
}
// Extension → Icon Mapping
function getFileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
const icons: Record<string, string> = {
rs: '🦀', ts: '📘', js: '📙', svelte: '🟠',
css: '🎨', html: '🌐', json: '📋', md: '📝',
toml: '⚙️', yml: '📄', yaml: '📄', sql: '🗃️',
sh: '🐚', nix: '❄️', py: '🐍', go: '🔵',
};
return icons[ext] || '📄';
}
</script>
{#if visible && (results.length > 0 || loading)}
<div class="file-mention">
{#if loading && results.length === 0}
<div class="mention-loading">Suche...</div>
{/if}
{#each results.slice(0, 8) as file, idx (file.full_path)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mention-item"
class:selected={idx === selectedIndex}
onclick={() => onSelect(file)}
>
<span class="mention-icon">{getFileIcon(file.name)}</span>
<span class="mention-name">{file.name}</span>
<span class="mention-path">{file.path}</span>
</div>
{/each}
</div>
{/if}
<style>
.file-mention {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-bottom: none;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
max-height: 240px;
overflow-y: auto;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
z-index: 50;
}
.mention-loading {
padding: var(--spacing-sm);
color: var(--text-secondary);
font-size: 0.7rem;
text-align: center;
}
.mention-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
cursor: pointer;
transition: background 0.1s;
}
.mention-item:hover,
.mention-item.selected {
background: var(--bg-hover);
}
.mention-item.selected {
border-left: 2px solid var(--accent);
}
.mention-icon {
font-size: 0.8rem;
flex-shrink: 0;
}
.mention-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary);
}
.mention-path {
font-size: 0.6rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
text-align: right;
}
</style>

View file

@ -59,6 +59,18 @@ export interface QuickAction {
invokeArgs?: Record<string, unknown>; invokeArgs?: Record<string, unknown>;
} }
// Pending File-Changes (Accept/Reject DiffView)
export interface FileChange {
toolId: string;
tool: string;
filePath: string;
contentBefore: string;
contentAfter: string;
timestamp: Date;
}
export const pendingChanges = writable<FileChange[]>([]);
// Stores // Stores
export const agents = writable<Agent[]>([]); export const agents = writable<Agent[]>([]);
export const toolCalls = writable<ToolCall[]>([]); export const toolCalls = writable<ToolCall[]>([]);

View file

@ -25,11 +25,13 @@ import {
loadMonitorEventsFromDb, loadMonitorEventsFromDb,
activeKnowledgeHints, activeKnowledgeHints,
agentMode, agentMode,
pendingChanges,
type Message, type Message,
type Agent, type Agent,
type MonitorEventType, type MonitorEventType,
type KnowledgeHint, type KnowledgeHint,
type AgentMode type AgentMode,
type FileChange,
} from './app'; } from './app';
// Aktuell laufendes Tool (für inline Aktivitätsanzeige) // Aktuell laufendes Tool (für inline Aktivitätsanzeige)
@ -459,6 +461,38 @@ export async function initEventListeners(): Promise<void> {
}) })
); );
// File-Change Events — Accept/Reject DiffView
listeners.push(
await listen<{
toolId: string;
tool: string;
filePath: string;
contentBefore: string;
contentAfter: string;
}>('file-change', (event) => {
const { toolId, tool, filePath, contentBefore, contentAfter } = event.payload;
console.log('📝 Datei-Aenderung:', filePath);
const change: FileChange = {
toolId,
tool,
filePath,
contentBefore,
contentAfter,
timestamp: new Date(),
};
pendingChanges.update((changes) => [...changes, change]);
addMonitorEvent('tool', `Datei geaendert: ${filePath.split('/').pop()}`, {
toolId,
tool,
filePath,
addedLines: contentAfter.split('\n').length - contentBefore.split('\n').length,
});
})
);
console.log('✅ Event-Listener initialisiert'); console.log('✅ Event-Listener initialisiert');
} }