From 4164e6ccfea71b8cfe03faab280f29b19e84be45 Mon Sep 17 00:00:00 2001 From: Davey Mason Date: Thu, 20 Nov 2025 23:57:31 +0000 Subject: [PATCH 1/5] Preflight CHeck --- package-lock.json | 49 ++ package.json | 1 + src-tauri/Cargo.lock | 61 +++ src-tauri/Cargo.toml | 3 +- src-tauri/src/lib.rs | 874 +++++++++++++++++++++++++++++- src-tauri/src/main.rs | 2 +- src/App.css | 452 +++++++++++++++ src/App.tsx | 23 +- src/components/Chatbot.tsx | 67 ++- src/components/PreflightModal.tsx | 117 ++++ src/components/SettingsPanel.tsx | 234 ++++++++ src/components/Terminal.tsx | 313 ++++++++++- src/main.tsx | 5 +- src/state/settings.tsx | 84 +++ src/types/preflight.ts | 20 + 15 files changed, 2265 insertions(+), 40 deletions(-) create mode 100644 src/components/PreflightModal.tsx create mode 100644 src/components/SettingsPanel.tsx create mode 100644 src/state/settings.tsx create mode 100644 src/types/preflight.ts diff --git a/package-lock.json b/package-lock.json index d2f43fe..4ea223b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "clsx": "^2.1.1", + "framer-motion": "^12.23.24", "lucide-react": "^0.553.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -2264,6 +2265,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3300,6 +3328,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4294,6 +4337,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", diff --git a/package.json b/package.json index 48025cd..d6383ee 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "clsx": "^2.1.1", + "framer-motion": "^12.23.24", "lucide-react": "^0.553.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ef87d36..d2a048c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,7 @@ version = "0.1.0" dependencies = [ "anyhow", "futures-util", + "json5", "once_cell", "portable-pty", "reqwest", @@ -1869,6 +1870,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "jsonptr" version = "0.6.3" @@ -2579,6 +2591,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "pest_meta" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "phf" version = "0.8.0" @@ -4564,6 +4619,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f54bf5f..6386538 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,7 +11,7 @@ edition = "2021" # The `_lib` suffix may seem redundant but it is necessary # to make the lib name unique and wouldn't conflict with the bin name. # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 -name = "Termalime_lib" +name = "termalime_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] @@ -29,4 +29,5 @@ uuid = { version = "1", features = ["v4"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } tokio = { version = "=1.40.0", features = ["macros", "rt-multi-thread", "sync"] } futures-util = "0.3" +json5 = "0.4" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6430164..2dd2f6f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,17 +1,23 @@ pub mod pty; use std::{collections::HashMap, io::Read, sync::Arc, time::Duration}; +use tokio::sync::Mutex; + +const TERMINAL_BUFFER_MAX: usize = 16 * 1024; +const TERMINAL_LINES_MAX: usize = 400; +const DEFAULT_PREFLIGHT_MODEL: &str = "gemma3:270m"; +const PREFLIGHT_SYSTEM_PROMPT: &str = "You are a senior security operations (SOC) analyst. Your job is to analyze a shell command for potential risks. Do not be conversational. Respond only in JSON with the following keys: summary (one sentence), is_risky (true/false), risk_reason (one paragraph), safe_alternative (optional string offering a safer approach)."; +const PREFLIGHT_REPAIR_PROMPT: &str = "You are a JSON repair bot. Convert the provided text into valid JSON with the keys summary (string), is_risky (boolean), risk_reason (string), and safe_alternative (string, optional). Respond with JSON only."; +const PREFLIGHT_TEXT_PROMPT: &str = "You are a senior SOC analyst. Provide a concise assessment of a shell command using exactly three plain-text lines, no code fences or quoting: (1) 'Summary: ' (2) 'Likelihood of maliciousness: ' (3) 'Rationale: '. Keep the rationale focused on potential malicious impact rather than benign behavior."; use anyhow::Error; use futures_util::StreamExt; use once_cell::sync::Lazy; use pty::{PtySize, PTY_REGISTRY}; use reqwest::Client; -use serde::{Deserialize, Serialize}; +use serde::{de::Error as _, Deserialize, Serialize}; use serde_json::json; use tauri::{AppHandle, Emitter, Manager, State}; -use tokio::sync::Mutex; - static HTTP_CLIENT: Lazy = Lazy::new(|| { Client::builder() .timeout(Duration::from_secs(30)) @@ -24,6 +30,36 @@ type ReaderHandle = tauri::async_runtime::JoinHandle<()>; #[derive(Default)] struct AppState { readers: Arc>>, + terminal_snapshots: Arc>>, +} + +#[derive(Default, Clone)] +struct TerminalSnapshot { + buffer: String, +} + +impl TerminalSnapshot { + fn append(&mut self, chunk: &str) { + self.buffer.push_str(chunk); + if self.buffer.len() > TERMINAL_BUFFER_MAX { + let excess = self.buffer.len() - TERMINAL_BUFFER_MAX; + self.buffer.drain(..excess); + } + } + + fn last_lines(&self, limit: usize) -> String { + if self.buffer.is_empty() { + return String::new(); + } + let lines: Vec<&str> = self.buffer.lines().rev().take(limit).collect(); + lines.into_iter().rev().collect::>().join("\n") + } +} + +#[derive(Serialize)] +struct TerminalContextPayload { + session_id: String, + last_lines: String, } #[derive(Serialize, Clone)] @@ -52,6 +88,42 @@ struct ResizeRequest { struct AskOllamaRequest { prompt: String, model: Option, + system_prompt: Option, + persona_prompt: Option, + terminal_context: Option, +} + +#[derive(Deserialize)] +struct AnalyzeCommandRequest { + command: String, + model: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +enum AnalyzeAction { + Run, + Review, + Error, +} + +#[derive(Serialize, Deserialize)] +struct PreflightReport { + summary: String, + is_risky: bool, + risk_reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + safe_alternative: Option, +} + +#[derive(Serialize)] +struct AnalyzeCommandResponse { + action: AnalyzeAction, + #[serde(skip_serializing_if = "Option::is_none")] + report: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + score: i32, } #[derive(Deserialize)] @@ -72,7 +144,18 @@ async fn spawn_pty(state: State<'_, AppState>, app_handle: AppHandle) -> Result< .map_err(|err| err.to_string())? .map_err(|err| err.to_string())?; - let reader_task = spawn_terminal_reader(app_handle, session_id.clone(), reader); + state + .terminal_snapshots + .lock() + .await + .insert(session_id.clone(), TerminalSnapshot::default()); + + let reader_task = spawn_terminal_reader( + app_handle, + session_id.clone(), + reader, + state.terminal_snapshots.clone(), + ); state .readers .lock() @@ -120,10 +203,57 @@ async fn resize_pty(request: ResizeRequest) -> Result<(), String> { #[tauri::command] async fn ask_ollama(app_handle: AppHandle, request: AskOllamaRequest) -> Result<(), String> { let client = HTTP_CLIENT.clone(); - let model = request.model.unwrap_or_else(|| "llama3".to_string()); + let AskOllamaRequest { + prompt, + model, + system_prompt, + persona_prompt, + terminal_context, + } = request; + let model = model.unwrap_or_else(|| "llama3".to_string()); + + let mut messages = Vec::new(); + + if let Some(system_prompt) = system_prompt + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + messages.push(json!({ + "role": "system", + "content": system_prompt, + })); + } + + if let Some(persona_prompt) = persona_prompt + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + messages.push(json!({ + "role": "system", + "content": persona_prompt, + })); + } + + let user_prompt = if let Some(context) = terminal_context + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + format!( + "Recent terminal output:\n{}\n\nUser request:\n{}", + context, prompt + ) + } else { + prompt + }; + + messages.push(json!({ + "role": "user", + "content": user_prompt, + })); + let body = json!({ "model": model, - "messages": [{"role": "user", "content": request.prompt}], + "messages": messages, "stream": true }); @@ -166,6 +296,24 @@ async fn ask_ollama(app_handle: AppHandle, request: AskOllamaRequest) -> Result< Ok(()) } +#[tauri::command] +async fn get_terminal_context( + state: State<'_, AppState>, + session_id: String, + max_lines: Option, +) -> Result { + let max_lines = max_lines.unwrap_or(200).min(TERMINAL_LINES_MAX).max(1); + let snapshots = state.terminal_snapshots.lock().await; + let snapshot = snapshots + .get(&session_id) + .ok_or_else(|| format!("terminal session {session_id} not found"))?; + + Ok(TerminalContextPayload { + session_id, + last_lines: snapshot.last_lines(max_lines), + }) +} + #[tauri::command] async fn check_ollama() -> Result { let response = HTTP_CLIENT @@ -194,15 +342,624 @@ async fn list_ollama_models() -> Result, String> { )); } - let data: OllamaTagsResponse = response - .json() - .await - .map_err(|err| err.to_string())?; + let data: OllamaTagsResponse = response.json().await.map_err(|err| err.to_string())?; let models = data.models.into_iter().map(|model| model.name).collect(); Ok(models) } +#[tauri::command] +async fn analyze_command(request: AnalyzeCommandRequest) -> Result { + let AnalyzeCommandRequest { command, model } = request; + let command = command.trim().to_string(); + if command.is_empty() { + return Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Run, + report: None, + message: None, + score: 0, + }); + } + + let lower_command = command.to_lowercase(); + + let resolved_model = model + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_PREFLIGHT_MODEL.to_string()); + + let score = suspicion_score(&command); + if score < 10 { + return Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Run, + report: None, + message: None, + score, + }); + } + + let heuristic_reasons = collect_heuristic_reasons(&lower_command); + let heuristic_flagged = !heuristic_reasons.is_empty(); + let heuristic_note = if heuristic_flagged { + Some(format!( + "Preflight heuristics flagged this command: {}.", + heuristic_reasons.join("; ") + )) + } else { + None + }; + + let body = json!({ + "model": resolved_model, + "messages": [ + {"role": "system", "content": PREFLIGHT_SYSTEM_PROMPT}, + { + "role": "user", + "content": format!( + "Analyze this command and respond strictly with JSON:\n{}", + command + ), + } + ], + "stream": false + }); + + let response = HTTP_CLIENT + .post("http://127.0.0.1:11434/api/chat") + .json(&body) + .send() + .await + .map_err(|err| err.to_string())?; + + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Error, + report: None, + message: Some(format!("Ollama responded with {}: {}", status, detail)), + score, + }); + } + + let payload: serde_json::Value = response.json().await.map_err(|err| err.to_string())?; + let content = payload + .get("message") + .and_then(|msg| msg.get("content")) + .and_then(|value| value.as_str()) + .unwrap_or(""); + + let parsed_report: Option = match parse_preflight_report(content) { + Ok(report) => Some(report), + Err(parse_error) => match repair_preflight_report(&resolved_model, content).await { + Ok(Some(report)) => Some(report), + Ok(None) => { + let assessment = fallback_text_summary(&resolved_model, &command, content, Some(&parse_error)) + .await + .unwrap_or_else(|fallback_error| { + format!( + "Structured risk report unavailable ({}; fallback failed: {}). Original model output:\n{}", + parse_error, + fallback_error, + content.trim() + ) + }); + + if let Some(mut report) = assessment_text_to_report(&assessment) { + if let Some(note) = heuristic_note.as_deref() { + report.risk_reason = format!("{}\n\n{}", report.risk_reason, note); + } + return Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Review, + report: Some(report), + message: None, + score, + }); + } + + let message = if let Some(note) = heuristic_note.as_deref() { + format!("{}\n\n{}", assessment, note) + } else { + assessment + }; + + return Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Review, + report: None, + message: Some(message), + score, + }); + } + Err(repair_error) => { + let assessment = fallback_text_summary(&resolved_model, &command, content, Some(&parse_error)) + .await + .unwrap_or_else(|fallback_error| { + format!( + "Structured risk report unavailable ({}; repair failed: {}; fallback failed: {}). Original model output:\n{}", + parse_error, + repair_error, + fallback_error, + content.trim() + ) + }); + + if let Some(mut report) = assessment_text_to_report(&assessment) { + if let Some(note) = heuristic_note.as_deref() { + report.risk_reason = format!("{}\n\n{}", report.risk_reason, note); + } + return Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Review, + report: Some(report), + message: None, + score, + }); + } + + let message = if let Some(note) = heuristic_note.as_deref() { + format!("{}\n\n{}", assessment, note) + } else { + assessment + }; + + return Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Review, + report: None, + message: Some(message), + score, + }); + } + }, + }; + + if let Some(report) = parsed_report { + if report.is_risky { + return Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Review, + report: Some(report), + message: heuristic_note.clone(), + score, + }); + } + + if heuristic_flagged { + return Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Review, + report: Some(report), + message: heuristic_note.clone(), + score, + }); + } + return Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Run, + report: Some(report), + message: None, + score, + }); + } + + Ok(AnalyzeCommandResponse { + action: AnalyzeAction::Run, + report: None, + message: Some("No AI report was produced.".to_string()), + score, + }) +} + +fn parse_preflight_report(content: &str) -> Result { + let mut candidates: Vec = Vec::new(); + candidates.push(content.trim().to_string()); + if let Some(clean) = strip_code_fence(content) { + candidates.push(clean); + } + if let Some(extracted) = extract_json_object(content) { + candidates.push(extracted); + } + + let mut last_error: Option = None; + + for candidate in candidates { + match serde_json::from_str::(&candidate) { + Ok(report) => return Ok(report), + Err(err) => { + last_error = Some(err); + if let Ok(report) = json5::from_str::(&candidate) { + return Ok(report); + } + if let Some(fixed) = insert_missing_commas(&candidate) { + if let Ok(report) = serde_json::from_str::(&fixed) { + return Ok(report); + } + if let Ok(report) = json5::from_str::(&fixed) { + return Ok(report); + } + } + if let Some(backtick_fixed) = replace_quotes_inside_backticks(&candidate) { + if let Ok(report) = serde_json::from_str::(&backtick_fixed) { + return Ok(report); + } + if let Ok(report) = json5::from_str::(&backtick_fixed) { + return Ok(report); + } + } + } + } + } + + Err(last_error.unwrap_or_else(|| serde_json::Error::custom("Unable to parse preflight report"))) +} + +fn strip_code_fence(raw: &str) -> Option { + let trimmed = raw.trim(); + let without_prefix = if let Some(rest) = trimmed.strip_prefix("```json") { + Some(rest) + } else { + trimmed.strip_prefix("```") + }?; + + Some( + without_prefix + .trim() + .trim_end_matches("```") + .trim() + .to_string(), + ) +} + +fn extract_json_object(raw: &str) -> Option { + let mut depth = 0usize; + let mut start: Option = None; + + for (idx, ch) in raw.char_indices() { + match ch { + '{' => { + if depth == 0 { + start = Some(idx); + } + depth += 1; + } + '}' => { + if depth > 0 { + depth -= 1; + if depth == 0 { + if let Some(begin) = start { + let end = idx + ch.len_utf8(); + return Some(raw[begin..end].to_string()); + } + } + } + } + _ => {} + } + } + + None +} + +fn insert_missing_commas(input: &str) -> Option { + let lines: Vec<&str> = input.lines().collect(); + if lines.is_empty() { + return None; + } + + let mut modified = false; + let mut output: Vec = Vec::with_capacity(lines.len()); + + for (idx, line) in lines.iter().enumerate() { + let mut current = (*line).to_string(); + let trimmed = current.trim(); + if looks_like_field(trimmed) && !trimmed.ends_with(',') { + if next_significant_line(&lines, idx + 1) + .map(|next| { + let nt = next.trim_start(); + !nt.starts_with('}') && !nt.starts_with(']') + }) + .unwrap_or(false) + { + current.push(','); + modified = true; + } + } + output.push(current); + } + + if modified { + Some(output.join("\n")) + } else { + None + } +} + +fn looks_like_field(line: &str) -> bool { + if line.is_empty() { + return false; + } + + let first = line.chars().next().unwrap(); + let has_colon = line.contains(':'); + if !has_colon { + return false; + } + + if first == '"' || first == '\'' { + return true; + } + + first.is_ascii_alphabetic() +} + +fn next_significant_line<'a>(lines: &[&'a str], mut idx: usize) -> Option<&'a str> { + while idx < lines.len() { + let line = lines[idx].trim(); + if !line.is_empty() { + return Some(lines[idx]); + } + idx += 1; + } + None +} + +fn sanitize_plain_text_assessment(raw: &str) -> String { + let mut content = raw.trim().to_string(); + + if let Some(clean) = strip_code_fence(&content) { + content = clean.trim().to_string(); + } + + if content.starts_with("text ") { + content = content[5..].trim_start().to_string(); + } + + content = content + .trim_matches(|ch| ch == '"' || ch == '\'' || ch == '`') + .trim() + .to_string(); + + let filtered_lines: Vec<&str> = content + .lines() + .map(str::trim) + .filter(|line| { + !(line.starts_with("Command to review") + || line.starts_with("Previous model output") + || line.starts_with("Original parser error")) + }) + .collect(); + + let cleaned = filtered_lines.join("\n").trim().to_string(); + if cleaned.is_empty() { + content + } else { + cleaned + } +} + +fn assessment_text_to_report(text: &str) -> Option { + let mut summary: Option = None; + let mut rationale: Option = None; + let mut likelihood: Option = None; + let mut safe_alternative: Option = None; + + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("summary:") { + if let Some(value) = trimmed.splitn(2, ':').nth(1) { + let value = value.trim(); + if !value.is_empty() { + summary = Some(value.to_string()); + } + } + continue; + } + if lower.starts_with("likelihood") { + if let Some(value) = trimmed.splitn(2, ':').nth(1) { + likelihood = parse_percentage(value.trim()); + } + continue; + } + if lower.starts_with("rationale:") { + if let Some(value) = trimmed.splitn(2, ':').nth(1) { + let value = value.trim(); + if !value.is_empty() { + rationale = Some(value.to_string()); + } + } + continue; + } + if lower.starts_with("recommendation:") || lower.starts_with("mitigation:") { + if let Some(value) = trimmed.splitn(2, ':').nth(1) { + let value = value.trim(); + if !value.is_empty() { + safe_alternative = Some(value.to_string()); + } + } + continue; + } + } + + let summary = summary.or_else(|| { + text.lines() + .find(|line| !line.trim().is_empty()) + .map(|line| line.trim().to_string()) + })?; + + let mut risk_reason = rationale.unwrap_or_else(|| summary.clone()); + if let Some(value) = likelihood { + let label = if value >= 70.0 { + "high" + } else if value >= 40.0 { + "medium" + } else if value >= 15.0 { + "low" + } else { + "very low" + }; + risk_reason = format!( + "{} (assessed malicious likelihood: {}% — {} risk)", + risk_reason, value, label + ); + } + + let is_risky = likelihood.map(|value| value >= 20.0).unwrap_or(true); + + Some(PreflightReport { + summary, + is_risky, + risk_reason, + safe_alternative, + }) +} + +fn parse_percentage(value: &str) -> Option { + let cleaned = value + .trim() + .trim_end_matches('%') + .trim() + .replace('%', "") + .replace(|ch: char| ch == ',', ""); + cleaned.parse::().ok() +} + +fn replace_quotes_inside_backticks(input: &str) -> Option { + let mut output = String::with_capacity(input.len()); + let mut in_backtick = false; + let mut changed = false; + + for ch in input.chars() { + if ch == '`' { + in_backtick = !in_backtick; + output.push(ch); + continue; + } + + if in_backtick && ch == '"' { + output.push('\''); + changed = true; + } else { + output.push(ch); + } + } + + if changed { + Some(output) + } else { + None + } +} + +async fn repair_preflight_report( + model: &str, + raw_content: &str, +) -> Result, String> { + if raw_content.trim().is_empty() { + return Ok(None); + } + + let body = json!({ + "model": model, + "messages": [ + {"role": "system", "content": PREFLIGHT_REPAIR_PROMPT}, + { + "role": "user", + "content": format!( + "Convert the following text into valid JSON with the required keys:\n{}", + raw_content + ), + } + ], + "stream": false + }); + + let response = HTTP_CLIENT + .post("http://127.0.0.1:11434/api/chat") + .json(&body) + .send() + .await + .map_err(|err| err.to_string())?; + + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(format!("repair request failed with {}: {}", status, detail)); + } + + let payload: serde_json::Value = response.json().await.map_err(|err| err.to_string())?; + let content = payload + .get("message") + .and_then(|msg| msg.get("content")) + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + if content.is_empty() { + return Ok(None); + } + + match parse_preflight_report(&content) { + Ok(report) => Ok(Some(report)), + Err(_) => Ok(None), + } +} + +async fn fallback_text_summary( + model: &str, + command: &str, + raw_content: &str, + parse_error: Option<&serde_json::Error>, +) -> Result { + let mut context = format!("Command to review:\n{}\n", command); + if !raw_content.trim().is_empty() { + context.push_str("\nPrevious model output (may be malformed JSON):\n"); + context.push_str(raw_content.trim()); + } + if let Some(err) = parse_error { + context.push_str(&format!("\n\nOriginal parser error: {}", err)); + } + + let body = json!({ + "model": model, + "messages": [ + {"role": "system", "content": PREFLIGHT_TEXT_PROMPT}, + {"role": "user", "content": context}, + ], + "stream": false + }); + + let response = HTTP_CLIENT + .post("http://127.0.0.1:11434/api/chat") + .json(&body) + .send() + .await + .map_err(|err| err.to_string())?; + + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(format!( + "fallback request failed with {}: {}", + status, detail + )); + } + + let payload: serde_json::Value = response.json().await.map_err(|err| err.to_string())?; + let content = payload + .get("message") + .and_then(|msg| msg.get("content")) + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + if content.is_empty() { + return Err("fallback summary came back empty".into()); + } + + Ok(sanitize_plain_text_assessment(&content)) +} + fn process_ollama_buffer(app_handle: &AppHandle, buffer: &mut Vec) -> Result<(), String> { loop { let Some(position) = buffer.iter().position(|b| *b == b'\n') else { @@ -270,6 +1027,7 @@ fn spawn_terminal_reader( app_handle: AppHandle, session_id: String, mut reader: Box, + snapshots: Arc>>, ) -> ReaderHandle { tauri::async_runtime::spawn_blocking(move || { let mut buf = [0_u8; 4096]; @@ -277,10 +1035,17 @@ fn spawn_terminal_reader( match reader.read(&mut buf) { Ok(0) => break, Ok(len) => { + let chunk = String::from_utf8_lossy(&buf[..len]).to_string(); let payload = TerminalOutputPayload { session_id: session_id.clone(), - data: String::from_utf8_lossy(&buf[..len]).to_string(), + data: chunk.clone(), }; + tauri::async_runtime::block_on(async { + let mut guard = snapshots.lock().await; + if let Some(snapshot) = guard.get_mut(&session_id) { + snapshot.append(&chunk); + } + }); let _ = app_handle.emit("terminal-output", payload); } Err(err) => { @@ -296,6 +1061,89 @@ fn spawn_terminal_reader( }) } +fn suspicion_score(command: &str) -> i32 { + let lower = command.to_lowercase(); + let mut score = 0; + + if lower.contains("sudo") { + score += 10; + } + + if contains_piped_interpreter(&lower) { + score += 50; + } + + if lower.contains("rm -rf") || lower.contains("rm -fr") { + score += 20; + } + + if lower.contains("base64") { + score += 10; + } + + if lower.contains("/dev/tcp") || lower.contains("/dev/udp") { + score += 30; + } + + if references_ip(&lower) { + score += 5; + } + + score +} + +fn collect_heuristic_reasons(command: &str) -> Vec<&'static str> { + let mut reasons = Vec::new(); + + if contains_piped_interpreter(command) { + reasons.push("Downloads remote content and pipes it directly into a shell"); + } + + if command.contains("rm -rf") || command.contains("rm -fr") { + reasons.push("Contains destructive rm -rf deletion"); + } + + if command.contains("/dev/tcp") || command.contains("/dev/udp") { + reasons.push("Uses /dev/tcp or /dev/udp for raw network sockets"); + } + + reasons +} + +fn contains_piped_interpreter(command: &str) -> bool { + let interpreters = ["| bash", "| sh", "| python", "| sudo bash", "| sudo sh"]; + let downloaders = ["curl", "wget"]; // simple heuristic + + if !command.contains('|') { + return false; + } + + downloaders.iter().any(|tool| command.contains(tool)) + && interpreters.iter().any(|interp| command.contains(interp)) +} + +fn references_ip(command: &str) -> bool { + command + .split(|ch: char| !(ch.is_ascii_digit() || ch == '.')) + .any(is_ipv4_token) +} + +fn is_ipv4_token(token: &str) -> bool { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 4 { + return false; + } + parts.iter().all(|part| { + if part.is_empty() || part.len() > 3 { + return false; + } + match part.parse::() { + Ok(_) => true, + Err(_) => false, + } + }) +} + #[derive(Deserialize)] struct OllamaResponseChunk { message: Option, @@ -340,7 +1188,9 @@ pub fn run() { resize_pty, ask_ollama, check_ollama, - list_ollama_models + list_ollama_models, + get_terminal_context, + analyze_command ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9acc20f..b5b9128 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - Termalime_lib::run() + termalime_lib::run() } diff --git a/src/App.css b/src/App.css index 587c762..f47f965 100644 --- a/src/App.css +++ b/src/App.css @@ -201,6 +201,44 @@ body { color: rgba(255, 255, 255, 0.75); } +.preflight-indicator { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + letter-spacing: 0.02em; + text-transform: uppercase; + padding: 0.25rem 0.7rem; + border-radius: 999px; + border: 1px solid rgba(45, 212, 191, 0.5); + background: rgba(13, 148, 136, 0.15); + color: rgba(209, 250, 229, 0.95); + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.preflight-indicator svg { + width: 0.9rem; + height: 0.9rem; +} + +.preflight-indicator--ready { + border-color: rgba(74, 222, 128, 0.55); + background: rgba(6, 95, 70, 0.25); + color: #bbf7d0; +} + +.preflight-indicator--busy { + border-color: rgba(250, 204, 21, 0.55); + background: rgba(120, 53, 15, 0.35); + color: #facc15; +} + +.preflight-indicator--alert { + border-color: rgba(248, 113, 113, 0.6); + background: rgba(67, 20, 7, 0.45); + color: #fecaca; +} + .status-dot { width: 0.55rem; height: 0.55rem; @@ -620,6 +658,420 @@ body { background: rgba(255, 255, 255, 0.15); } +.settings-fab { + position: fixed; + bottom: 1.75rem; + left: 1.75rem; + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: rgba(4, 16, 24, 0.85); + border: 1px solid rgba(45, 212, 191, 0.5); + color: #e0fffa; + border-radius: 999px; + padding: 0.65rem 1.15rem; + font-weight: 600; + box-shadow: 0 15px 35px rgba(2, 8, 12, 0.65); + cursor: pointer; + transition: transform 0.2s ease, border-color 0.2s ease; + z-index: 30; +} + +.settings-fab:hover { + transform: translateY(-2px); + border-color: rgba(45, 212, 191, 0.8); +} + +.settings-overlay { + position: fixed; + inset: 0; + background: rgba(3, 5, 8, 0.75); + backdrop-filter: blur(12px); + display: flex; + align-items: flex-end; + justify-content: flex-start; + padding: 2rem; + z-index: 40; +} + +.settings-panel { + width: min(540px, 100%); + max-height: min(90vh, 720px); + background: rgba(2, 8, 12, 0.95); + border-radius: 1.25rem; + border: 1px solid rgba(45, 212, 191, 0.25); + padding: 1.75rem; + display: flex; + flex-direction: column; + gap: 1.35rem; + overflow-y: auto; + overscroll-behavior: contain; + box-shadow: 0 40px 90px rgba(0, 0, 0, 0.55); +} + +.settings-panel__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.settings-panel__header h2 { + margin: 0.1rem 0 0; + font-size: 1.4rem; + letter-spacing: -0.01em; +} + +.settings-panel__eyebrow { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.25em; + margin: 0; + color: rgba(45, 212, 191, 0.85); +} + +.icon-btn { + width: 2.5rem; + height: 2.5rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: inherit; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.15s ease, transform 0.15s ease; +} + +.icon-btn:hover { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.35); +} + +.settings-section { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.settings-section__label { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); +} + +.settings-section input[type="range"] { + width: 100%; + accent-color: #34d399; +} + +.settings-section textarea { + background: rgba(3, 7, 10, 0.8); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.85rem; + padding: 0.9rem; + color: inherit; + font-family: inherit; + resize: vertical; + min-height: 120px; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.toggle-card { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 0.95rem; + padding: 1rem 1.1rem; + background: rgba(4, 16, 24, 0.65); + text-align: left; + color: inherit; + display: flex; + justify-content: space-between; + gap: 0.65rem; + cursor: pointer; + transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease; +} + +.toggle-card--active { + border-color: rgba(45, 212, 191, 0.6); + background: rgba(45, 212, 191, 0.08); +} + +.toggle-card__title { + margin: 0; + font-weight: 600; +} + +.toggle-card__desc { + margin: 0.35rem 0 0; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.65); +} + +.toggle-card__switch { + width: 36px; + height: 18px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.25); + position: relative; + flex-shrink: 0; +} + +.toggle-card__switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + background: rgba(255, 255, 255, 0.85); + border-radius: 999px; + transition: transform 0.2s ease, background 0.2s ease; +} + +.toggle-card__switch--on { + border-color: rgba(45, 212, 191, 0.6); + background: rgba(45, 212, 191, 0.2); +} + +.toggle-card__switch--on::after { + transform: translateX(16px); + background: #34d399; +} + +.persona-row { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.persona-pill { + width: 100%; + border-radius: 1rem; + padding: 0.85rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(4, 10, 16, 0.7); + color: inherit; + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: center; + cursor: pointer; + text-align: left; +} + +.persona-pill--active { + border-color: rgba(45, 212, 191, 0.7); + box-shadow: 0 12px 30px rgba(45, 212, 191, 0.15); +} + +.persona-pill__title { + margin: 0; + font-weight: 600; +} + +.persona-pill__desc { + margin: 0.2rem 0 0; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.6); +} + +.settings-panel__footer { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.6); +} + +.text-btn { + background: none; + border: none; + color: #5eead4; + font-weight: 600; + cursor: pointer; + padding: 0; +} + +.text-btn:hover { + text-decoration: underline; +} + +.settings-panel__hint { + color: rgba(255, 255, 255, 0.45); +} + +.settings-hint { + display: block; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.55); + margin-top: -0.25rem; +} + +.settings-hint--error { + color: #f87171; +} + +.settings-panel input, +.settings-panel select, +.settings-panel textarea, +.settings-panel button { + font-family: inherit; +} + +.settings-panel input[type="text"], +.settings-panel input[list] { + background: rgba(3, 7, 10, 0.8); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.75rem; + padding: 0.65rem 0.75rem; + color: inherit; +} + +.settings-panel input:focus, +.settings-panel textarea:focus, +.settings-panel select:focus { + outline: 2px solid rgba(45, 212, 191, 0.5); + outline-offset: 2px; +} + +.preflight-overlay { + position: fixed; + inset: 0; + background: rgba(3, 5, 8, 0.65); + backdrop-filter: blur(10px); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + z-index: 60; +} + +.preflight-card { + width: min(520px, 100%); + max-height: 90vh; + overflow-y: auto; + border-radius: 1.2rem; + border: 1px solid rgba(45, 212, 191, 0.25); + background: rgba(2, 8, 12, 0.96); + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: 0 35px 110px rgba(0, 0, 0, 0.65); +} + +.preflight-header { + display: flex; + gap: 0.85rem; + align-items: flex-start; +} + +.preflight-header h3 { + margin: 0.2rem 0 0; + font-size: 1.25rem; +} + +.preflight-header__icon { + width: 2.75rem; + height: 2.75rem; + border-radius: 0.9rem; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(45, 212, 191, 0.1); +} + +.preflight-header--alert .preflight-header__icon { + background: rgba(249, 115, 22, 0.18); + color: #fb923c; +} + +.preflight-header--error .preflight-header__icon { + background: rgba(248, 113, 113, 0.18); + color: #fca5a5; +} + +.preflight-eyebrow { + text-transform: uppercase; + letter-spacing: 0.25em; + font-size: 0.75rem; + margin: 0; + color: rgba(94, 234, 212, 0.85); +} + +.preflight-desc { + margin: 0.35rem 0 0; + color: rgba(255, 255, 255, 0.7); + font-size: 0.9rem; +} + +.preflight-section { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.preflight-label { + margin: 0; + font-size: 0.78rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.55); +} + +.preflight-command { + margin: 0; + border-radius: 0.75rem; + padding: 0.75rem; + background: rgba(4, 16, 24, 0.9); + border: 1px solid rgba(255, 255, 255, 0.08); + font-family: "JetBrains Mono", monospace; + font-size: 0.9rem; + overflow-x: auto; +} + +.preflight-body { + margin: 0; + color: rgba(255, 255, 255, 0.9); + line-height: 1.5; +} + +.preflight-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.preflight-run-btn { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 0.9rem; + padding: 0.7rem 1.35rem; + background: linear-gradient(135deg, rgba(249, 115, 22, 0.25), rgba(248, 113, 113, 0.25)); + color: #fef3c7; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.preflight-run-btn:disabled { + opacity: 0.6; + cursor: wait; +} + +.preflight-run-btn:not(:disabled):hover { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.35); +} + @media (max-width: 900px) { .app-shell { padding: 1rem; diff --git a/src/App.tsx b/src/App.tsx index 825194e..85113ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,15 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import "./App.css"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import Chatbot from "./components/Chatbot"; import Terminal from "./components/Terminal"; +import { SettingsButton, SettingsPanel } from "./components/SettingsPanel"; +import { useSettings } from "./state/settings"; function App() { + const { settings } = useSettings(); + const [settingsOpen, setSettingsOpen] = useState(false); + const [terminalSessionId, setTerminalSessionId] = useState(null); useEffect(() => { const splash = document.getElementById("splash"); if (!splash) return; @@ -32,14 +37,20 @@ function App() { style={{ height: "100%", width: "100%" }} > - - - - - + + {settings.showChat && ( + <> + + + + + + )} + setSettingsOpen(true)} /> + setSettingsOpen(false)} /> ); } diff --git a/src/components/Chatbot.tsx b/src/components/Chatbot.tsx index a8bde96..376c69a 100644 --- a/src/components/Chatbot.tsx +++ b/src/components/Chatbot.tsx @@ -13,6 +13,7 @@ import { import { FormEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { PERSONA_DESCRIPTIONS, useSettings } from "../state/settings"; type ChatRole = "user" | "assistant"; @@ -31,6 +32,11 @@ type OllamaChunkPayload = { error?: string; }; +type TerminalContextPayload = { + session_id: string; + last_lines: string; +}; + const createId = () => crypto.randomUUID?.() ?? Math.random().toString(36).slice(2); const formatTimestamp = (value: number) => new Intl.DateTimeFormat(undefined, { @@ -60,7 +66,11 @@ const mergeStreamContent = (previous: string, incoming: string) => { return previous + incoming.slice(overlap); }; -const Chatbot = () => { +interface ChatbotProps { + sessionId?: string | null; +} + +const Chatbot = ({ sessionId }: ChatbotProps) => { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); @@ -73,6 +83,7 @@ const Chatbot = () => { const responseIdRef = useRef(null); const responseBufferRef = useRef(""); const bottomRef = useRef(null); + const { settings } = useSettings(); const handleCopyMessage = useCallback(async (message: ChatMessage) => { const text = message.content?.trim(); @@ -298,12 +309,46 @@ const Chatbot = () => { setIsStreaming(true); setChatError(null); + let terminalContext: string | null = null; + if (settings.includeTerminalContext && sessionId) { + try { + const context = await invoke("get_terminal_context", { + session_id: sessionId, + max_lines: 250, + }); + const trimmedContext = context.last_lines?.trim(); + if (trimmedContext) { + terminalContext = trimmedContext; + } + } catch (error) { + console.warn("Unable to fetch terminal context", error); + } + } + + const personaDescription = PERSONA_DESCRIPTIONS[settings.persona]; + const personaPrompt = `Persona directive: Adopt the ${settings.persona} persona – ${personaDescription}`; + const systemPrompt = settings.systemPrompt?.trim(); + + const requestPayload: Record = { + prompt: trimmed, + model, + }; + + if (systemPrompt) { + requestPayload.system_prompt = systemPrompt; + } + + if (personaPrompt) { + requestPayload.persona_prompt = personaPrompt; + } + + if (terminalContext) { + requestPayload.terminal_context = terminalContext; + } + try { await invoke("ask_ollama", { - request: { - prompt: trimmed, - model, - }, + request: requestPayload, }); setOllamaOnline(true); } catch (error) { @@ -328,7 +373,17 @@ const Chatbot = () => { ), ); } - }, [checkingOllama, input, isStreaming, model, ollamaOnline, refreshHealth, refreshModels]); + }, [ + checkingOllama, + input, + isStreaming, + model, + ollamaOnline, + refreshHealth, + refreshModels, + sessionId, + settings, + ]); const handleSubmit = (event: FormEvent) => { event.preventDefault(); diff --git a/src/components/PreflightModal.tsx b/src/components/PreflightModal.tsx new file mode 100644 index 0000000..9816f9b --- /dev/null +++ b/src/components/PreflightModal.tsx @@ -0,0 +1,117 @@ +import { Loader2, ShieldAlert, XCircle } from "lucide-react"; +import clsx from "clsx"; +import { PreflightReport } from "../types/preflight"; + +export type PreflightStatus = "hidden" | "analyzing" | "review" | "error"; + +interface PreflightModalProps { + command: string; + status: PreflightStatus; + report?: PreflightReport; + message?: string; + onCancel: () => void; + onRunAnyway: () => void; +} + +const statusMeta = { + analyzing: { + icon: , + tone: "neutral" as const, + title: "Analyzing potentially risky command…", + description: "Termalime is checking heuristics and asking the AI for a second opinion.", + }, + review: { + icon: , + tone: "alert" as const, + title: "⚠️ Command requires review", + description: "Double-check the findings below before running it anyway.", + }, + error: { + icon: , + tone: "error" as const, + title: "Preflight check failed", + description: "Run manually or retry after fixing the issue below.", + }, +}; + +export function PreflightModal({ + command, + status, + report, + message, + onCancel, + onRunAnyway, +}: PreflightModalProps) { + if (status === "hidden") { + return null; + } + + const meta = statusMeta[status]; + + return ( +
+
+
+
{meta?.icon}
+
+

Preflight Check

+

{meta?.title}

+ {meta?.description &&

{meta.description}

} +
+
+ +
+

Command

+
{command}
+
+ + {status === "review" && report && ( + <> +
+

Summary

+

{report.summary}

+
+
+

Risk reasoning

+

{report.risk_reason}

+
+ {report.safe_alternative && ( +
+

Safer approach

+

{report.safe_alternative}

+
+ )} + + )} + + {status === "review" && message && ( +
+

AI assessment

+

{message}

+
+ )} + + {status === "error" && message && ( +
+

Error

+

{message}

+
+ )} + +
+ + +
+
+
+ ); +} +export default PreflightModal; diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..fb5a30f --- /dev/null +++ b/src/components/SettingsPanel.tsx @@ -0,0 +1,234 @@ +import { MouseEvent, useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Check, Settings2, X } from "lucide-react"; +import clsx from "clsx"; +import { invoke } from "@tauri-apps/api/core"; +import { PERSONA_DESCRIPTIONS, useSettings } from "../state/settings"; + +const personaOrder = ["helpful", "concise", "neutral", "playful"] as const; + +interface SettingsPanelProps { + open: boolean; + onClose: () => void; +} + +export function SettingsPanel({ open, onClose }: SettingsPanelProps) { + const { settings, updateSettings, resetSettings } = useSettings(); + const [modelOptions, setModelOptions] = useState([]); + const [modelsError, setModelsError] = useState(null); + const [loadingModels, setLoadingModels] = useState(false); + const preflightModelRef = useRef(settings.preflightModel); + const preferredDefault = "gemma3:270m"; + + useEffect(() => { + preflightModelRef.current = settings.preflightModel; + }, [settings.preflightModel]); + + useEffect(() => { + if (!open) { + return; + } + let cancelled = false; + setLoadingModels(true); + invoke("list_ollama_models") + .then((models) => { + if (cancelled) { + return; + } + setModelOptions(models); + setModelsError(null); + const current = preflightModelRef.current?.trim(); + if (current && models.includes(current)) { + return; + } + if (models.includes(preferredDefault)) { + preflightModelRef.current = preferredDefault; + updateSettings({ preflightModel: preferredDefault }); + return; + } + if (models.length > 0 && current !== models[0]) { + preflightModelRef.current = models[0]; + updateSettings({ preflightModel: models[0] }); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + const message = + typeof error === "string" ? error : "Unable to fetch local Ollama models."; + setModelsError(message); + }) + .finally(() => { + if (!cancelled) { + setLoadingModels(false); + } + }); + + return () => { + cancelled = true; + }; + }, [open, updateSettings]); + + return ( + + {open && ( + + ) => event.stopPropagation()} + > +
+
+

Control room

+

Termalime settings

+
+ +
+ +
+
+

Terminal font size

+ {settings.terminalFontSize}px +
+ updateSettings({ terminalFontSize: Number(event.currentTarget.value) })} + /> +
+ +
+ updateSettings({ showChat: !settings.showChat })} + /> + updateSettings({ includeTerminalContext: !settings.includeTerminalContext })} + /> + updateSettings({ preflightCheck: !settings.preflightCheck })} + /> +
+ +
+
+

Preflight model

+ Choose which Ollama model analyzes intercepted commands. +
+ + {loadingModels && Detecting local Ollama models…} + {modelsError && {modelsError}} +
+ +
+
+

Persona

+ Choose the assistant’s tone +
+
+ {personaOrder.map((persona) => ( + + ))} +
+
+ +
+
+

System prompt

+ Give Termalime a mission statement. +
+