Compare commits
No commits in common. "c779fa7fc5666d142e53aa4962113d4d973ea684" and "61541098d7a14893fc19d413e7098848518b0c14" have entirely different histories.
c779fa7fc5
...
61541098d7
11 changed files with 90 additions and 1120 deletions
|
|
@ -36,16 +36,6 @@ if (!IS_DAEMON) process.stdin.resume();
|
|||
let activeAbort = null;
|
||||
let currentAgentId = null;
|
||||
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)
|
||||
let agentMode = 'solo';
|
||||
|
|
@ -349,20 +339,6 @@ function summarizeToolInput(tool, input) {
|
|||
// ============ Claude Agent SDK ============
|
||||
|
||||
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)
|
||||
const useModel = model || currentModel;
|
||||
|
||||
|
|
@ -504,9 +480,6 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
|
|||
// als auch als standalone tool_use Event. Via toolUseId deduplizieren.
|
||||
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
|
||||
// als auch fuer tool_use Bloecke innerhalb einer assistant-Nachricht
|
||||
function handleToolUse(ev) {
|
||||
|
|
@ -551,19 +524,6 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
|
|||
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);
|
||||
sendMonitorEvent('tool', `${toolName} ${toolSummary}`, {
|
||||
toolId,
|
||||
|
|
@ -592,17 +552,6 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
|
|||
success: !ev.is_error,
|
||||
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.
|
||||
|
|
@ -818,19 +767,6 @@ async function sendMessage(message, requestId, model = null, contextOverride = n
|
|||
sendEvent('all-stopped');
|
||||
currentAgentId = 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}`, {}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
// 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)
|
||||
}
|
||||
|
|
@ -515,57 +515,6 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) {
|
|||
let mut state = state.lock().unwrap();
|
||||
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 => {
|
||||
// Generische Weiterleitung aller Bridge-Events ans Frontend
|
||||
// (subagent-started, subagent-stopped, monitor-event, mode-changed,
|
||||
|
|
|
|||
|
|
@ -591,205 +591,3 @@ pub async fn list_sticky_context(
|
|||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,19 +54,6 @@ pub struct MonitorEvent {
|
|||
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
|
||||
pub struct Database {
|
||||
pub(crate) conn: Connection,
|
||||
|
|
@ -243,19 +230,6 @@ impl Database {
|
|||
UNIQUE(error_hash)
|
||||
);
|
||||
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(())
|
||||
|
|
@ -913,94 +887,6 @@ impl Database {
|
|||
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 ============
|
||||
|
||||
/// Fehler-Occurrence zählen und zurückgeben
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ use tauri::{
|
|||
use webkit2gtk::WebViewExt;
|
||||
|
||||
mod audit;
|
||||
mod checkpoint;
|
||||
mod claude;
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
|
|
@ -151,9 +150,6 @@ pub fn run() {
|
|||
context::log_context_failure,
|
||||
context::get_full_context,
|
||||
context::list_sticky_context,
|
||||
context::fuzzy_search_files,
|
||||
context::resolve_file_path,
|
||||
context::read_file_content,
|
||||
// Voice-Interface
|
||||
voice::transcribe_audio,
|
||||
voice::text_to_speech,
|
||||
|
|
@ -200,11 +196,6 @@ pub fn run() {
|
|||
clipboard::clipboard_watch_status,
|
||||
// Slash-Command Registry
|
||||
commands::get_slash_commands,
|
||||
// Checkpoints (Accept/Reject + Rewind)
|
||||
checkpoint::accept_change,
|
||||
checkpoint::reject_change,
|
||||
checkpoint::list_checkpoints,
|
||||
checkpoint::rewind_to_checkpoint,
|
||||
])
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { emit, listen } from '@tauri-apps/api/event';
|
||||
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, agentMode, pendingChanges, type Message, type QuickAction, type FileChange } from '$lib/stores/app';
|
||||
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, agentMode, type Message, type QuickAction } from '$lib/stores/app';
|
||||
import { currentTool, processingPhase } from '$lib/stores/events';
|
||||
import { marked, type Tokens } from 'marked';
|
||||
import { tick, onDestroy, onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import CommandPalette from './CommandPalette.svelte';
|
||||
import DiffView from './DiffView.svelte';
|
||||
import FileMention from './FileMention.svelte';
|
||||
import QuickActions from './QuickActions.svelte';
|
||||
|
||||
// Props
|
||||
|
|
@ -325,45 +323,18 @@
|
|||
let commandQuery = $state('');
|
||||
let commandPaletteRef: CommandPalette | undefined = $state(undefined);
|
||||
|
||||
// @-Mention Autocomplete State
|
||||
let showFileMention = $state(false);
|
||||
let mentionQuery = $state('');
|
||||
let fileMentionRef: FileMention | undefined = $state(undefined);
|
||||
|
||||
// Slash-Command und @-Mention Erkennung im Input
|
||||
// Slash-Command Erkennung im Input
|
||||
$effect(() => {
|
||||
const text = $currentInput;
|
||||
if (text.startsWith('/')) {
|
||||
showCommandPalette = true;
|
||||
commandQuery = text.slice(1);
|
||||
showFileMention = false;
|
||||
} else {
|
||||
showCommandPalette = false;
|
||||
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
|
||||
function handleCommandSelect(cmd: { name: string; description: string; category: string }) {
|
||||
if (!cmd.name) {
|
||||
|
|
@ -746,8 +717,21 @@
|
|||
addMessage('system', `📋 Clipboard (${content_type}): ${suggestion}\n\`\`\`\n${preview}\n\`\`\``);
|
||||
});
|
||||
|
||||
// Legacy-Queue Subscriber (Sicherheitsnetz — Hauptlogik ist jetzt in der Bridge)
|
||||
const unsubProcessing = isProcessing.subscribe(() => {});
|
||||
// Queue-Auto-Dispatch: sobald isProcessing von true auf false wechselt
|
||||
// wird die naechste Nachricht aus der Queue abgeschickt (FIFO).
|
||||
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 () => {
|
||||
window.removeEventListener('keydown', handleGlobalKeydown);
|
||||
|
|
@ -758,46 +742,39 @@
|
|||
});
|
||||
|
||||
function cancelQueued() {
|
||||
// Legacy: Queue-Cancel (Bridge hat jetzt eigene Pending-Queue)
|
||||
// Alle gequeuten Nachrichten verwerfen + aus dem Chat entfernen
|
||||
messageQueue.set([]);
|
||||
$queuedMessage = null;
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
messages.update((msgs) => msgs.filter((m) => !(m as any).queued));
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = $currentInput.trim();
|
||||
if (!text) return;
|
||||
|
||||
// Nachricht IMMER sofort senden — auch während Claude arbeitet.
|
||||
// Die Bridge puffert die Nachricht intern und verarbeitet sie
|
||||
// automatisch nach dem aktuellen Turn (wie in Claude Code/VS Code Extension).
|
||||
// Waehrend Claude antwortet: Nachricht in FIFO-Queue.
|
||||
// Sofort als User-Message im Chat anzeigen (mit queued-Marker).
|
||||
// Der Subscriber dispatcht automatisch wenn Claude fertig ist.
|
||||
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);
|
||||
}
|
||||
|
||||
// Den eigentlichen Send-Flow — sendet immer sofort an die Bridge.
|
||||
// Wenn eine query() laeuft, puffert die Bridge die Nachricht intern.
|
||||
// Den eigentlichen Send-Flow ausgelagert, damit er auch fuer die Queue genutzt wird.
|
||||
async function dispatchMessage(text: string) {
|
||||
// Auto-Session erstellen falls keine aktiv
|
||||
let sessionId = get(currentSessionId);
|
||||
|
|
@ -817,27 +794,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
// @-Mentions aufloesen: @pfad/datei.ts durch Dateiinhalt ersetzen
|
||||
let resolvedText = text;
|
||||
const mentionPattern = /@([\w.\-/]+(?:#\d+(?:-\d+)?)?)/g;
|
||||
const mentions = [...text.matchAll(mentionPattern)];
|
||||
for (const match of mentions) {
|
||||
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`
|
||||
// Pruefen ob die Nachricht schon als queued im Chat steht
|
||||
const existingQueued = get(messages).find((m) => m.queued && m.content === text);
|
||||
if (existingQueued) {
|
||||
// Queued-Markierung entfernen — Nachricht ist jetzt aktiv
|
||||
messages.update((msgs) =>
|
||||
msgs.map((m) => m.id === existingQueued.id ? { ...m, queued: false } : m)
|
||||
);
|
||||
} catch {
|
||||
// Datei nicht gefunden — @-Mention unverändert lassen
|
||||
}
|
||||
}
|
||||
|
||||
// User-Nachricht sofort im Chat anzeigen + in DB speichern
|
||||
// In DB speichern (wurde vorher nicht gespeichert da queued)
|
||||
await saveMessageToDb({ ...existingQueued, queued: false });
|
||||
} else {
|
||||
// Neue Nachricht hinzufuegen (normaler Send, nicht aus Queue)
|
||||
const msgId = crypto.randomUUID();
|
||||
const msg: Message = {
|
||||
id: msgId,
|
||||
|
|
@ -846,23 +813,18 @@
|
|||
timestamp: new Date(),
|
||||
};
|
||||
messages.update((msgs) => [...msgs, msg]);
|
||||
// Sofort speichern (nicht auf Subscriber warten)
|
||||
await saveMessageToDb(msg);
|
||||
|
||||
$currentInput = '';
|
||||
|
||||
// isProcessing nur setzen wenn nicht schon aktiv
|
||||
// (bei gepufferten Nachrichten laeuft Claude ja schon)
|
||||
if (!$isProcessing) {
|
||||
$isProcessing = true;
|
||||
}
|
||||
|
||||
$currentInput = '';
|
||||
$isProcessing = true;
|
||||
|
||||
try {
|
||||
// Aufgeloesten Text an Claude senden (mit eingebetteten Datei-Inhalten)
|
||||
await invoke('send_message', { message: resolvedText });
|
||||
await invoke('send_message', { message: text });
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Senden:', err);
|
||||
addMessage('system', `Fehler: ${err}`);
|
||||
// Nur auf false setzen wenn keine weiteren Nachrichten pending
|
||||
$isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -874,12 +836,6 @@
|
|||
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)
|
||||
if (event.key === 'Enter' && event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
|
|
@ -1245,32 +1201,6 @@
|
|||
{/if}
|
||||
</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">
|
||||
<CommandPalette
|
||||
bind:this={commandPaletteRef}
|
||||
|
|
@ -1278,12 +1208,6 @@
|
|||
visible={showCommandPalette}
|
||||
onSelect={handleCommandSelect}
|
||||
/>
|
||||
<FileMention
|
||||
bind:this={fileMentionRef}
|
||||
query={mentionQuery}
|
||||
visible={showFileMention}
|
||||
onSelect={handleFileSelect}
|
||||
/>
|
||||
{#if $agentMode && $agentMode !== 'solo'}
|
||||
<div class="mode-indicator mode-{$agentMode}">
|
||||
<span class="mode-icon">
|
||||
|
|
@ -1315,11 +1239,18 @@
|
|||
<span class="transcript-text">{liveTranscript}</span>
|
||||
</div>
|
||||
{/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
|
||||
bind:this={inputTextarea}
|
||||
bind:value={$currentInput}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={$isProcessing ? 'Weiter tippen — wird nach aktuellem Turn verarbeitet...' : 'Nachricht eingeben... (Ctrl+K = Quick-Actions, Ctrl+Enter = Senden)'}
|
||||
placeholder={$isProcessing ? 'Nachricht eingeben — wird nach Antwort automatisch gesendet' : 'Nachricht eingeben... (Ctrl+K = Quick-Actions, Ctrl+Enter = Senden)'}
|
||||
disabled={isRecording}
|
||||
rows="3"
|
||||
></textarea>
|
||||
|
|
@ -2036,41 +1967,6 @@
|
|||
/* Alte Typing-Animation entfernt — ersetzt durch .activity-indicator */
|
||||
|
||||
/* 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 {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
|
|
|
|||
|
|
@ -1,28 +1,15 @@
|
|||
<script lang="ts">
|
||||
// DiffView — Zeigt Unterschiede zwischen altem und neuem Code
|
||||
// Mit optionalen Accept/Reject-Buttons fuer interaktiven Modus
|
||||
// Verwendet für Edit-Tool Ergebnisse
|
||||
|
||||
interface Props {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
filename?: string;
|
||||
language?: string;
|
||||
interactive?: boolean;
|
||||
toolId?: string;
|
||||
onAccept?: (toolId: string) => void;
|
||||
onReject?: (toolId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
oldText,
|
||||
newText,
|
||||
filename = '',
|
||||
language = '',
|
||||
interactive = false,
|
||||
toolId = '',
|
||||
onAccept,
|
||||
onReject,
|
||||
}: Props = $props();
|
||||
let { oldText, newText, filename = '', language = '' }: Props = $props();
|
||||
|
||||
// Einfache Diff-Berechnung (zeilenbasiert)
|
||||
interface DiffLine {
|
||||
|
|
@ -44,7 +31,7 @@
|
|||
|
||||
while (oldIdx < oldLines.length || newIdx < newLines.length) {
|
||||
if (lcsIdx < lcs.length && oldIdx < oldLines.length && oldLines[oldIdx] === lcs[lcsIdx]) {
|
||||
// Unveraenderte Zeile
|
||||
// Unveränderte Zeile
|
||||
if (newIdx < newLines.length && newLines[newIdx] === lcs[lcsIdx]) {
|
||||
result.push({
|
||||
type: 'unchanged',
|
||||
|
|
@ -55,7 +42,7 @@
|
|||
newIdx++;
|
||||
lcsIdx++;
|
||||
} else {
|
||||
// Neue Zeile hinzugefuegt
|
||||
// Neue Zeile hinzugefügt
|
||||
result.push({
|
||||
type: 'added',
|
||||
lineNo: { old: null, new: newIdx + 1 },
|
||||
|
|
@ -72,7 +59,7 @@
|
|||
});
|
||||
oldIdx++;
|
||||
} else if (newIdx < newLines.length) {
|
||||
// Zeile hinzugefuegt
|
||||
// Zeile hinzugefügt
|
||||
result.push({
|
||||
type: 'added',
|
||||
lineNo: { old: null, new: newIdx + 1 },
|
||||
|
|
@ -118,7 +105,7 @@
|
|||
return result;
|
||||
}
|
||||
|
||||
// Diff berechnen wenn sich Inputs aendern
|
||||
// Diff berechnen wenn sich Inputs ändern
|
||||
let diffLines = $derived(computeDiff(oldText, newText));
|
||||
|
||||
// Statistiken
|
||||
|
|
@ -127,81 +114,24 @@
|
|||
removed: diffLines.filter(l => l.type === 'removed').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>
|
||||
|
||||
<div class="diff-view" class:interactive>
|
||||
<div class="diff-header">
|
||||
<div class="diff-header-left">
|
||||
<div class="diff-view">
|
||||
{#if filename}
|
||||
<span class="filename" title={filename}>{shortenPath(filename)}</span>
|
||||
{/if}
|
||||
<div class="diff-header">
|
||||
<span class="filename">{filename}</span>
|
||||
{#if language}
|
||||
<span class="language">{language}</span>
|
||||
{/if}
|
||||
<span class="stats">
|
||||
<span class="stat-added">+{stats.added}</span>
|
||||
<span class="stat-removed">-{stats.removed}</span>
|
||||
<span class="added">+{stats.added}</span>
|
||||
<span class="removed">-{stats.removed}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="diff-header-right">
|
||||
{#if diffLines.length > 20}
|
||||
<button class="btn-toggle" onclick={() => showFullDiff = !showFullDiff}>
|
||||
{showFullDiff ? 'Kompakt' : 'Alles zeigen'}
|
||||
</button>
|
||||
{/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">
|
||||
{#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}
|
||||
{#each diffLines as line, idx (idx)}
|
||||
<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 new">{line.lineNo.new ?? ''}</span>
|
||||
|
|
@ -223,42 +153,18 @@
|
|||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: var(--bg-secondary);
|
||||
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 {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.language {
|
||||
|
|
@ -270,66 +176,23 @@
|
|||
}
|
||||
|
||||
.stats {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.stat-added { 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;
|
||||
}
|
||||
|
||||
.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);
|
||||
.stats .added {
|
||||
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);
|
||||
.stats .removed {
|
||||
color: var(--error);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-reject:hover {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
overflow-x: auto;
|
||||
max-height: 400px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
|
@ -376,13 +239,4 @@
|
|||
flex: 1;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -59,18 +59,6 @@ export interface QuickAction {
|
|||
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
|
||||
export const agents = writable<Agent[]>([]);
|
||||
export const toolCalls = writable<ToolCall[]>([]);
|
||||
|
|
|
|||
|
|
@ -25,13 +25,11 @@ import {
|
|||
loadMonitorEventsFromDb,
|
||||
activeKnowledgeHints,
|
||||
agentMode,
|
||||
pendingChanges,
|
||||
type Message,
|
||||
type Agent,
|
||||
type MonitorEventType,
|
||||
type KnowledgeHint,
|
||||
type AgentMode,
|
||||
type FileChange,
|
||||
type AgentMode
|
||||
} from './app';
|
||||
|
||||
// Aktuell laufendes Tool (für inline Aktivitätsanzeige)
|
||||
|
|
@ -461,38 +459,6 @@ 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');
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue