diff --git a/src-tauri/src/auth_broker.rs b/src-tauri/src/auth_broker.rs new file mode 100644 index 0000000..2ef6bc6 --- /dev/null +++ b/src-tauri/src/auth_broker.rs @@ -0,0 +1,204 @@ +use crate::error::{AppError, AppResult}; +use crate::settings::{self, AppSettings}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; +use tokio::time::timeout; + +const LOGIN_TIMEOUT: Duration = Duration::from_secs(600); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LoginProvider { + pub id: String, + pub name: String, +} + +fn resolve_omp(settings: &AppSettings) -> AppResult { + settings::resolve_omp_binary(settings) +} + +fn configure_command(command: &mut Command, omp_bin: &Path, hide_window: bool) { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + if let Some(path) = settings::runtime_command_path(omp_bin) { + command.env("PATH", path); + } + // Keep list/logout quiet; login may need to launch the system browser. + #[cfg(windows)] + if hide_window { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } +} + +fn combine_output(stdout: &[u8], stderr: &[u8]) -> String { + let mut parts = Vec::new(); + let out = String::from_utf8_lossy(stdout).trim().to_string(); + let err = String::from_utf8_lossy(stderr).trim().to_string(); + if !out.is_empty() { + parts.push(out); + } + if !err.is_empty() { + parts.push(err); + } + parts.join("\n") +} + +pub async fn list_login_providers(settings: &AppSettings) -> AppResult> { + let omp_bin = resolve_omp(settings)?; + let mut command = Command::new(&omp_bin); + command.args(["auth-broker", "list", "--json"]); + configure_command(&mut command, &omp_bin, true); + + let output = command + .output() + .await + .map_err(|error| AppError::Msg(format!("failed to run omp auth-broker list: {error}")))?; + + if !output.status.success() { + let detail = combine_output(&output.stdout, &output.stderr); + return Err(AppError::Msg(if detail.is_empty() { + "omp auth-broker list failed".into() + } else { + format!("omp auth-broker list failed\n{detail}") + })); + } + + let value: Value = serde_json::from_slice(&output.stdout).map_err(|error| { + AppError::Msg(format!("invalid auth-broker list JSON: {error}")) + })?; + let array = value + .as_array() + .ok_or_else(|| AppError::Msg("auth-broker list JSON must be an array".into()))?; + + let mut providers = Vec::new(); + for item in array { + let Some(obj) = item.as_object() else { + continue; + }; + let id = obj + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if id.is_empty() { + continue; + } + let name = obj + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(id.as_str()) + .to_string(); + providers.push(LoginProvider { id, name }); + } + Ok(providers) +} + +pub async fn login_provider(settings: &AppSettings, provider_id: &str) -> AppResult<()> { + let provider_id = provider_id.trim(); + if provider_id.is_empty() { + return Err(AppError::Msg("provider id is required".into())); + } + + let omp_bin = resolve_omp(settings)?; + let mut command = Command::new(&omp_bin); + command.args(["auth-broker", "login", provider_id]); + configure_command(&mut command, &omp_bin, false); + + let output = timeout(LOGIN_TIMEOUT, command.output()) + .await + .map_err(|_| { + AppError::Msg(format!( + "timed out waiting for omp auth-broker login ({provider_id})" + )) + })? + .map_err(|error| { + AppError::Msg(format!("failed to run omp auth-broker login: {error}")) + })?; + + if output.status.success() { + return Ok(()); + } + + let detail = combine_output(&output.stdout, &output.stderr); + Err(AppError::Msg(if detail.is_empty() { + format!("omp auth-broker login failed for {provider_id}") + } else { + format!("omp auth-broker login failed for {provider_id}\n{detail}") + })) +} + +pub async fn logout_provider(settings: &AppSettings, provider_id: &str) -> AppResult<()> { + let provider_id = provider_id.trim(); + if provider_id.is_empty() { + return Err(AppError::Msg("provider id is required".into())); + } + + let omp_bin = resolve_omp(settings)?; + let mut command = Command::new(&omp_bin); + command.args(["auth-broker", "logout", provider_id]); + configure_command(&mut command, &omp_bin, true); + + let output = command + .output() + .await + .map_err(|error| AppError::Msg(format!("failed to run omp auth-broker logout: {error}")))?; + + if output.status.success() { + return Ok(()); + } + + let detail = combine_output(&output.stdout, &output.stderr); + Err(AppError::Msg(if detail.is_empty() { + format!("omp auth-broker logout failed for {provider_id}") + } else { + format!("omp auth-broker logout failed for {provider_id}\n{detail}") + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_provider_array() { + let raw = json!([ + { "id": "anthropic", "name": "Anthropic (Claude Pro/Max)" }, + { "id": "cursor", "name": "Cursor" }, + { "id": "", "name": "skip" }, + { "name": "missing-id" } + ]); + let array = raw.as_array().unwrap(); + let mut providers = Vec::new(); + for item in array { + let Some(obj) = item.as_object() else { + continue; + }; + let id = obj + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if id.is_empty() { + continue; + } + let name = obj + .get("name") + .and_then(Value::as_str) + .unwrap_or(id.as_str()) + .to_string(); + providers.push(LoginProvider { id, name }); + } + assert_eq!(providers.len(), 2); + assert_eq!(providers[0].id, "anthropic"); + assert_eq!(providers[1].id, "cursor"); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 276803d..f26a6b3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,8 @@ +use crate::auth_broker::{self, LoginProvider}; use crate::error::AppError; use crate::memory::{self, JobCard, MemoryStore, PersistentAgent, RoleMemoryNote, RoleScratchpad}; use crate::omp_config::{self, AvailableModel, ModelRolesSnapshot}; +use crate::provider_keys::{self, ProviderKeyStatus, ProviderKeyUpdate}; use crate::pty::{PtyManager, PtyOutput}; use crate::session::{SessionInfo, SessionManager}; use crate::session_history; @@ -52,6 +54,74 @@ struct SessionEventEnvelope { event: Value, } +/// Strip heavy nested fields before crossing the Tauri IPC boundary. +/// OMP `message_update` / `agent_end` frames embed full `partial` trees and +/// thinking signatures; those payloads can fail to emit and leave the UI stuck +/// on "Working…" after a successful `agent_start`. +fn slim_omp_event(mut event: Value) -> Value { + let Some(root) = event.as_object_mut() else { + return event; + }; + + if let Some(assistant) = root + .get_mut("assistantMessageEvent") + .and_then(Value::as_object_mut) + { + assistant.remove("partial"); + } + + if let Some(message) = root.get_mut("message").and_then(Value::as_object_mut) { + slim_message_object(message); + } + + if root.get("type").and_then(Value::as_str) == Some("agent_end") { + if let Some(messages) = root.get_mut("messages").and_then(Value::as_array_mut) { + for message in messages.iter_mut() { + if let Some(object) = message.as_object_mut() { + slim_message_object(object); + } + } + } + } + + event +} + +fn slim_message_object(message: &mut serde_json::Map) { + if let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) { + for block in content.iter_mut() { + if let Some(object) = block.as_object_mut() { + object.remove("thinkingSignature"); + } + } + } + for key in [ + "usage", + "compat", + "compatConfig", + "cost", + "cttl", + "stopReason", + "duration", + "ttft", + ] { + message.remove(key); + } +} + +fn emit_omp_event(app: &AppHandle, session_id: &str, event: Value) { + let slim = slim_omp_event(event); + if let Err(error) = app.emit( + "omp-event", + SessionEventEnvelope { + session_id: session_id.to_owned(), + event: slim, + }, + ) { + log::warn!("failed to emit omp-event for {session_id}: {error}"); + } +} + #[tauri::command(rename_all = "camelCase")] pub async fn get_model_roles(cwd: Option) -> Result { omp_config::load_model_roles_for(cwd.as_deref().map(Path::new)) @@ -99,6 +169,46 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result Result, AppError> { + let path = provider_keys::agent_env_path()?; + provider_keys::list_provider_keys(&path) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn save_provider_keys( + updates: Vec, +) -> Result, AppError> { + let path = provider_keys::agent_env_path()?; + provider_keys::save_provider_keys(&path, &updates) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn list_login_providers( + state: State<'_, AppState>, +) -> Result, AppError> { + let settings = state.settings.lock().await.clone(); + auth_broker::list_login_providers(&settings).await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn login_provider( + state: State<'_, AppState>, + provider_id: String, +) -> Result<(), AppError> { + let settings = state.settings.lock().await.clone(); + auth_broker::login_provider(&settings, &provider_id).await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn logout_provider( + state: State<'_, AppState>, + provider_id: String, +) -> Result<(), AppError> { + let settings = state.settings.lock().await.clone(); + auth_broker::logout_provider(&settings, &provider_id).await +} + #[tauri::command(rename_all = "camelCase")] pub async fn save_settings( state: State<'_, AppState>, @@ -474,13 +584,7 @@ pub async fn create_session( let session_id = info.id.clone(); tauri::async_runtime::spawn(async move { while let Some(event) = events.recv().await { - let _ = event_app.emit( - "omp-event", - SessionEventEnvelope { - session_id: session_id.clone(), - event, - }, - ); + emit_omp_event(&event_app, &session_id, event); } event_app .state::() @@ -600,13 +704,7 @@ pub async fn create_ssh_session( let session_id = info.id.clone(); tokio::spawn(async move { while let Some(event) = events.recv().await { - let _ = app_handle.emit( - "omp-event", - SessionEventEnvelope { - session_id: session_id.clone(), - event, - }, - ); + emit_omp_event(&app_handle, &session_id, event); } app_handle .state::() @@ -675,18 +773,45 @@ pub async fn close_pty(state: State<'_, AppState>, session_id: String) -> Result state.ptys.lock().await.close_pty(&session_id) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PromptImage { + #[serde(rename = "type")] + pub kind: String, + pub data: String, + pub mime_type: String, + pub detail: Option, +} + #[tauri::command(rename_all = "camelCase")] pub async fn prompt( state: State<'_, AppState>, session_id: String, message: String, streaming_behavior: Option, + images: Option>, ) -> Result { + let images = images.map(|items| { + items + .into_iter() + .filter(|image| image.kind == "image" && !image.data.is_empty()) + .map(|image| { + let mut map = serde_json::Map::new(); + map.insert("type".into(), Value::String("image".into())); + map.insert("data".into(), Value::String(image.data)); + map.insert("mimeType".into(), Value::String(image.mime_type)); + if let Some(detail) = image.detail { + map.insert("detail".into(), Value::String(detail)); + } + Value::Object(map) + }) + .collect::>() + }); state .sessions .lock() .await - .prompt(&session_id, message, streaming_behavior) + .prompt(&session_id, message, streaming_behavior, images) .await } @@ -941,6 +1066,45 @@ mod tests { ); } + #[test] + fn slim_omp_event_strips_partial_and_signatures() { + let slim = slim_omp_event(json!({ + "type": "message_update", + "assistantMessageEvent": { + "type": "text_delta", + "delta": "hi", + "partial": { "role": "assistant", "content": [] } + }, + "message": { + "role": "assistant", + "content": [{ + "type": "thinking", + "thinking": "note", + "thinkingSignature": "huge" + }], + "usage": { "totalTokens": 99 } + } + })); + + assert_eq!( + slim, + json!({ + "type": "message_update", + "assistantMessageEvent": { + "type": "text_delta", + "delta": "hi" + }, + "message": { + "role": "assistant", + "content": [{ + "type": "thinking", + "thinking": "note" + }] + } + }) + ); + } + #[test] fn unresolved_omp_binary_falls_back_to_command_name() { let settings = AppSettings { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4dd06a0..3386104 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,9 @@ +mod auth_broker; mod commands; mod error; mod memory; mod omp_config; +mod provider_keys; mod pty; mod rpc; mod session; @@ -46,6 +48,11 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::get_settings, + commands::get_provider_keys, + commands::save_provider_keys, + commands::list_login_providers, + commands::login_provider, + commands::logout_provider, commands::get_model_roles, commands::list_available_models, commands::set_model_role, diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs index 738ee74..27d08b1 100644 --- a/src-tauri/src/omp_config.rs +++ b/src-tauri/src/omp_config.rs @@ -92,17 +92,15 @@ fn parse_selector(selector: &str) -> (Option, Option, Option String { - let (provider, model_id, thinking) = parse_selector(selector); + let (_provider, model_id, thinking) = parse_selector(selector); let model = model_id.as_deref().unwrap_or(selector); let mut label = model.to_string(); if label.len() > 28 { label = format!("{}…", &label[..27]); } - match (provider.as_deref(), thinking.as_deref()) { - (Some(provider), Some(thinking)) => format!("{provider}/{label}:{thinking}"), - (Some(provider), None) => format!("{provider}/{label}"), - (None, Some(thinking)) => format!("{label}:{thinking}"), - (None, None) => label, + match thinking.as_deref() { + Some(thinking) => format!("{label}:{thinking}"), + None => label, } } diff --git a/src-tauri/src/provider_keys.rs b/src-tauri/src/provider_keys.rs new file mode 100644 index 0000000..6311198 --- /dev/null +++ b/src-tauri/src/provider_keys.rs @@ -0,0 +1,319 @@ +use crate::error::{AppError, AppResult}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +pub const ALLOWED_KEYS: &[&str] = &[ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "OPENROUTER_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "DEEPSEEK_API_KEY", +]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderKeyStatus { + pub name: String, + pub label: String, + pub configured: bool, + pub masked: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderKeyUpdate { + pub name: String, + /// New secret value. Ignored when `clear` is true. + #[serde(default)] + pub value: Option, + /// When true, remove this key from the agent `.env`. + #[serde(default)] + pub clear: bool, +} + +fn label_for(name: &str) -> &'static str { + match name { + "ANTHROPIC_API_KEY" => "Anthropic", + "OPENAI_API_KEY" => "OpenAI", + "GEMINI_API_KEY" => "Google Gemini", + "OPENROUTER_API_KEY" => "OpenRouter", + "GROQ_API_KEY" => "Groq", + "XAI_API_KEY" => "xAI", + "MISTRAL_API_KEY" => "Mistral", + "DEEPSEEK_API_KEY" => "DeepSeek", + _ => "Provider", + } +} + +fn is_allowed(name: &str) -> bool { + ALLOWED_KEYS.iter().any(|key| *key == name) +} + +pub fn agent_env_path() -> AppResult { + let home = dirs::home_dir().ok_or_else(|| AppError::Msg("home directory not found".into()))?; + Ok(home.join(".omp").join("agent").join(".env")) +} + +pub fn mask_secret(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return String::new(); + } + let chars: Vec = trimmed.chars().collect(); + if chars.len() <= 4 { + return "••••".into(); + } + let suffix: String = chars[chars.len().saturating_sub(4)..].iter().collect(); + format!("••••{suffix}") +} + +fn strip_quotes(value: &str) -> String { + let value = value.trim(); + if value.len() >= 2 { + let bytes = value.as_bytes(); + if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'') + { + return value[1..value.len() - 1] + .replace("\\\"", "\"") + .replace("\\\\", "\\"); + } + } + value.to_string() +} + +fn escape_env_value(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +fn parse_env_file(contents: &str) -> HashMap { + let mut map = HashMap::new(); + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + map.insert(key.to_string(), strip_quotes(value)); + } + map +} + +pub fn list_provider_keys(path: &Path) -> AppResult> { + let map = if path.is_file() { + parse_env_file(&fs::read_to_string(path)?) + } else { + HashMap::new() + }; + + Ok(ALLOWED_KEYS + .iter() + .map(|name| { + let value = map.get(*name).map(String::as_str).unwrap_or("").trim(); + let configured = !value.is_empty(); + ProviderKeyStatus { + name: (*name).to_string(), + label: label_for(name).to_string(), + configured, + masked: if configured { + Some(mask_secret(value)) + } else { + None + }, + } + }) + .collect()) +} + +pub fn save_provider_keys(path: &Path, updates: &[ProviderKeyUpdate]) -> AppResult> { + for update in updates { + if !is_allowed(&update.name) { + return Err(AppError::Msg(format!( + "unsupported provider key: {}", + update.name + ))); + } + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let existing = if path.is_file() { + fs::read_to_string(path)? + } else { + String::new() + }; + + let mut lines: Vec = if existing.is_empty() { + Vec::new() + } else { + existing.lines().map(str::to_string).collect() + }; + + for update in updates { + if update.clear { + lines.retain(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return true; + } + trimmed + .split_once('=') + .map(|(key, _)| key.trim() != update.name) + .unwrap_or(true) + }); + continue; + } + + let Some(value) = update.value.as_deref().map(str::trim).filter(|v| !v.is_empty()) else { + // Blank value means keep existing. + continue; + }; + + let assignment = format!("{}={}", update.name, escape_env_value(value)); + let mut replaced = false; + for line in &mut lines { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some((key, _)) = trimmed.split_once('=') { + if key.trim() == update.name { + *line = assignment.clone(); + replaced = true; + break; + } + } + } + if !replaced { + if !lines.is_empty() && !lines.last().map(|l| l.trim().is_empty()).unwrap_or(true) { + // Keep a single trailing newline style by appending cleanly. + } + lines.push(assignment); + } + } + + let mut body = lines.join("\n"); + if !body.is_empty() && !body.ends_with('\n') { + body.push('\n'); + } + fs::write(path, body)?; + list_provider_keys(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_env_path(tag: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("omp-desktop-provider-keys-{tag}-{stamp}.env")) + } + + #[test] + fn masks_secret_tail() { + assert_eq!(mask_secret("sk-ant-abcdef"), "••••cdef"); + assert_eq!(mask_secret("ab"), "••••"); + } + + #[test] + fn merge_preserves_unrelated_and_clears() { + let path = temp_env_path("merge"); + fs::write( + &path, + "# keep me\nANTHROPIC_API_KEY=old-key\nPI_NO_PTY=1\nOPENAI_API_KEY=openai-old\n", + ) + .unwrap(); + + let statuses = save_provider_keys( + &path, + &[ + ProviderKeyUpdate { + name: "ANTHROPIC_API_KEY".into(), + value: Some("sk-ant-new-value".into()), + clear: false, + }, + ProviderKeyUpdate { + name: "OPENAI_API_KEY".into(), + value: None, + clear: true, + }, + ProviderKeyUpdate { + name: "GEMINI_API_KEY".into(), + value: Some("gemini-key".into()), + clear: false, + }, + ], + ) + .unwrap(); + + let raw = fs::read_to_string(&path).unwrap(); + assert!(raw.contains("# keep me")); + assert!(raw.contains("PI_NO_PTY=1")); + assert!(raw.contains("ANTHROPIC_API_KEY=\"sk-ant-new-value\"")); + assert!(raw.contains("GEMINI_API_KEY=\"gemini-key\"")); + assert!(!raw.contains("OPENAI_API_KEY")); + + let anthropic = statuses + .iter() + .find(|s| s.name == "ANTHROPIC_API_KEY") + .unwrap(); + assert!(anthropic.configured); + assert_eq!(anthropic.masked.as_deref(), Some("••••alue")); + + let openai = statuses.iter().find(|s| s.name == "OPENAI_API_KEY").unwrap(); + assert!(!openai.configured); + + let _ = fs::remove_file(path); + } + + #[test] + fn blank_update_keeps_existing() { + let path = temp_env_path("keep"); + fs::write(&path, "ANTHROPIC_API_KEY=keep-me\n").unwrap(); + save_provider_keys( + &path, + &[ProviderKeyUpdate { + name: "ANTHROPIC_API_KEY".into(), + value: Some(" ".into()), + clear: false, + }], + ) + .unwrap(); + let raw = fs::read_to_string(&path).unwrap(); + assert!(raw.contains("ANTHROPIC_API_KEY=keep-me") || raw.contains("keep-me")); + let _ = fs::remove_file(path); + } + + #[test] + fn rejects_unknown_keys() { + let path = temp_env_path("bad"); + let err = save_provider_keys( + &path, + &[ProviderKeyUpdate { + name: "NOT_A_KEY".into(), + value: Some("x".into()), + clear: false, + }], + ) + .unwrap_err(); + assert!(err.to_string().contains("unsupported")); + let _ = fs::remove_file(path); + } +} diff --git a/src-tauri/src/rpc/client.rs b/src-tauri/src/rpc/client.rs index d425c82..38bc130 100644 --- a/src-tauri/src/rpc/client.rs +++ b/src-tauri/src/rpc/client.rs @@ -16,6 +16,7 @@ use tokio::time::{timeout, Duration}; const MAX_RPC_FRAME_BYTES: usize = 1024 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const PROCESS_EXIT_TIMEOUT: Duration = Duration::from_secs(2); +const STDERR_TAIL_CHARS: usize = 1_200; type PendingRequests = Arc>>>; type SharedChild = Arc>; @@ -28,9 +29,27 @@ pub struct RpcClient { next_id: AtomicU64, pending: PendingRequests, ready: watch::Receiver, + stderr_tail: Arc>, events: Option>, } +fn append_stderr_tail(buffer: &mut String, chunk: &str) { + buffer.push_str(chunk); + if buffer.len() > STDERR_TAIL_CHARS { + let drain = buffer.len() - STDERR_TAIL_CHARS; + buffer.drain(..drain); + } +} + +fn exited_before_ready_error(stderr_tail: &str) -> AppError { + let trimmed = stderr_tail.trim(); + if trimmed.is_empty() { + AppError::from("OMP RPC exited before ready") + } else { + AppError::from(format!("OMP RPC exited before ready\n{trimmed}")) + } +} + impl RpcClient { pub async fn spawn(program: P, args: I) -> AppResult where @@ -44,6 +63,7 @@ impl RpcClient { .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .kill_on_drop(true); if let Some(path) = settings::runtime_command_path(Path::new(program)) { command.env("PATH", path); @@ -58,6 +78,10 @@ impl RpcClient { .stdout .take() .ok_or_else(|| AppError::from("RPC child stdout was not piped"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| AppError::from("RPC child stderr was not piped"))?; let child = Arc::new(Mutex::new(child)); let pending: PendingRequests = Arc::new(Mutex::new(HashMap::new())); @@ -65,6 +89,17 @@ impl RpcClient { let reader_child = Arc::clone(&child); let (events_tx, events) = mpsc::unbounded_channel(); let (ready_tx, ready) = watch::channel(false); + let stderr_tail = Arc::new(Mutex::new(String::new())); + let stderr_sink = Arc::clone(&stderr_tail); + + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let mut buffer = stderr_sink.lock().await; + append_stderr_tail(&mut buffer, &line); + append_stderr_tail(&mut buffer, "\n"); + } + }); tokio::spawn(async move { let mut reader = BufReader::new(stdout); @@ -127,6 +162,7 @@ impl RpcClient { next_id: AtomicU64::new(0), pending, ready, + stderr_tail, events: Some(events), }) } @@ -141,19 +177,34 @@ impl RpcClient { return Ok(()); } - timeout(wait_timeout, async { + let stderr_tail = Arc::clone(&self.stderr_tail); + let result = timeout(wait_timeout, async { loop { - ready - .changed() - .await - .map_err(|_| AppError::from("OMP RPC exited before ready"))?; + if ready.changed().await.is_err() { + let stderr = stderr_tail.lock().await.clone(); + return Err(exited_before_ready_error(&stderr)); + } if *ready.borrow() { return Ok(()); } } }) - .await - .map_err(|_| AppError::from("timed out waiting for OMP RPC ready"))? + .await; + + match result { + Ok(inner) => inner, + Err(_) => { + let stderr = self.stderr_tail.lock().await; + let trimmed = stderr.trim(); + if trimmed.is_empty() { + Err(AppError::from("timed out waiting for OMP RPC ready")) + } else { + Err(AppError::from(format!( + "timed out waiting for OMP RPC ready\n{trimmed}" + ))) + } + } + } } pub async fn request(&self, command_type: &str, params: Value) -> AppResult { diff --git a/src-tauri/src/session/mod.rs b/src-tauri/src/session/mod.rs index 39c40b3..1ac90ad 100644 --- a/src-tauri/src/session/mod.rs +++ b/src-tauri/src/session/mod.rs @@ -178,6 +178,7 @@ impl SessionManager { session_id: &str, message: String, streaming_behavior: Option, + images: Option>, ) -> AppResult { let mut params = Map::new(); params.insert("message".into(), Value::String(message)); @@ -187,6 +188,9 @@ impl SessionManager { Value::String(streaming_behavior), ); } + if let Some(images) = images.filter(|items| !items.is_empty()) { + params.insert("images".into(), Value::Array(images)); + } self.rpc_command(session_id, "prompt", Value::Object(params)) .await } @@ -518,7 +522,7 @@ process.stdin.resume(); .any(|args| args == ["--resume", "previous-session"])); let prompt = manager - .prompt(&info.id, "hello".into(), Some("followUp".into())) + .prompt(&info.id, "hello".into(), Some("followUp".into()), None) .await .unwrap(); assert_eq!(prompt["command"], "prompt"); diff --git a/src-tauri/src/settings/mod.rs b/src-tauri/src/settings/mod.rs index 3ffaa3f..658d8d6 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -32,6 +32,13 @@ impl ApprovalMode { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModelRolePreset { + pub name: String, + pub roles: std::collections::BTreeMap, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AppSettings { @@ -45,6 +52,9 @@ pub struct AppSettings { /// files is treated as completed so existing installs are not interrupted. #[serde(default)] pub onboarding_completed: bool, + /// Named role→selector bundles for the Agents panel. + #[serde(default)] + pub model_role_presets: Vec, } impl Default for AppSettings { @@ -57,6 +67,7 @@ impl Default for AppSettings { default_profile: None, theme: "dark".into(), onboarding_completed: false, + model_role_presets: Vec::new(), } } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ca68499..23374f9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,14 @@ "resizable": true, "fullscreen": false, "minWidth": 960, - "minHeight": 640 + "minHeight": 640, + "theme": "Dark", + "backgroundColor": { + "red": 15, + "green": 18, + "blue": 22, + "alpha": 0 + } } ], "security": { diff --git a/ui/index.html b/ui/index.html index b6e9e2d..74c4042 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,7 +4,21 @@ - ui + + OMP Desktop +
diff --git a/ui/src/app/app-context-menu.tsx b/ui/src/app/app-context-menu.tsx new file mode 100644 index 0000000..3e4dec7 --- /dev/null +++ b/ui/src/app/app-context-menu.tsx @@ -0,0 +1,105 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { getVersion } from "@tauri-apps/api/app"; + +import { isTauriRuntime } from "../lib/tauri.ts"; + +type MenuPoint = { x: number; y: number }; + +const NON_TEXT_INPUT_TYPES = new Set([ + "button", + "checkbox", + "radio", + "submit", + "reset", + "file", + "image", + "range", + "color", + "hidden", +]); + +/** Let the WebView native menu handle spellcheck / cut-copy-paste in fields. */ +const isEditableTarget = (target: EventTarget | null): boolean => { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + if (target.closest("[contenteditable='true']")) return true; + + const input = target.closest("input"); + if (input instanceof HTMLInputElement) { + return !NON_TEXT_INPUT_TYPES.has(input.type.toLowerCase()); + } + return Boolean(target.closest("textarea")); +}; + +export const AppContextMenu = () => { + const [menu, setMenu] = useState(null); + const [version, setVersion] = useState("…"); + const menuRef = useRef(null); + + useEffect(() => { + if (!isTauriRuntime()) { + setVersion("dev"); + return; + } + void getVersion() + .then(setVersion) + .catch(() => setVersion("unknown")); + }, []); + + useEffect(() => { + const onContextMenu = (event: MouseEvent) => { + // Keep native edit/spellcheck menus in inputs and textareas. + if (isEditableTarget(event.target)) { + setMenu(null); + return; + } + event.preventDefault(); + setMenu({ x: event.clientX, y: event.clientY }); + }; + const dismiss = () => setMenu(null); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") dismiss(); + }; + + window.addEventListener("contextmenu", onContextMenu); + window.addEventListener("click", dismiss); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("blur", dismiss); + window.addEventListener("resize", dismiss); + return () => { + window.removeEventListener("contextmenu", onContextMenu); + window.removeEventListener("click", dismiss); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("blur", dismiss); + window.removeEventListener("resize", dismiss); + }; + }, []); + + useLayoutEffect(() => { + if (!menu || !menuRef.current) return; + const rect = menuRef.current.getBoundingClientRect(); + const pad = 8; + const x = Math.min(menu.x, window.innerWidth - rect.width - pad); + const y = Math.min(menu.y, window.innerHeight - rect.height - pad); + const next = { x: Math.max(pad, x), y: Math.max(pad, y) }; + if (next.x !== menu.x || next.y !== menu.y) setMenu(next); + }, [menu]); + + if (!menu) return null; + + return ( +
event.stopPropagation()} + onContextMenu={(event) => event.preventDefault()} + > +
+ OMP Desktop v{version} +
+
+ ); +}; diff --git a/ui/src/app/extension-ui-dialog.tsx b/ui/src/app/extension-ui-dialog.tsx index 84e9cd8..d55588f 100644 --- a/ui/src/app/extension-ui-dialog.tsx +++ b/ui/src/app/extension-ui-dialog.tsx @@ -22,22 +22,46 @@ const denialResponse = ( return denyOption ? { value: denyOption } : { cancelled: true }; }; +const detailText = (request: ExtensionUiRequest) => { + if (request.method === "select" && request.title) return request.title; + return ( + request.message ?? + request.instructions ?? + request.title ?? + "Respond to continue the session." + ); +}; + +const softTitle = (request: ExtensionUiRequest) => { + if (request.method === "confirm") return request.title ?? "Confirm action"; + if (request.method === "select") { + const detail = request.title ?? ""; + const tool = detail.match(/Allow tool:\s*(\S+)/i)?.[1]; + if (tool) return `Allow ${tool}`; + return "Choose an option"; + } + return request.title ?? "OMP needs input"; +}; + export const ExtensionUiDialog = () => { const requests = useSessionStore((state) => state.extensionUiRequests); const activeSessionId = useSessionStore((state) => state.activeSessionId); const sessions = useSessionStore((state) => state.sessions); + const setActive = useSessionStore((state) => state.setActive); const respond = useSessionStore((state) => state.respondExtensionUi); const [value, setValue] = useState(""); const [busy, setBusy] = useState(false); - const dialogRef = useRef(null); + const dockRef = useRef(null); const pending = useMemo(() => { const active = activeSessionId ? requests[activeSessionId]?.[0] : undefined; if (active && activeSessionId) { - return { sessionId: activeSessionId, request: active }; + return { sessionId: activeSessionId, request: active, foreign: false }; } for (const [sessionId, queued] of Object.entries(requests)) { - if (queued[0]) return { sessionId, request: queued[0] }; + if (queued[0]) { + return { sessionId, request: queued[0], foreign: true }; + } } return null; }, [activeSessionId, requests]); @@ -47,23 +71,23 @@ export const ExtensionUiDialog = () => { const requestMethod = pending?.request.method; const requestPrefill = pending?.request.prefill; const requestTimeout = pending?.request.timeout; + useEffect(() => { setValue(requestPrefill ?? ""); setBusy(false); + if (!pending || pending.foreign) return; const frame = requestAnimationFrame(() => { const preferred = requestMethod === "input" || requestMethod === "editor" - ? dialogRef.current?.querySelector("textarea, input") - : dialogRef.current?.querySelector( - "[data-safe-default]", - ); - const fallback = dialogRef.current?.querySelector( + ? dockRef.current?.querySelector("textarea, input") + : dockRef.current?.querySelector("[data-safe-default]"); + const fallback = dockRef.current?.querySelector( "textarea, input, button:not([disabled])", ); (preferred ?? fallback)?.focus(); }); return () => cancelAnimationFrame(frame); - }, [requestId, pendingSessionId, requestMethod, requestPrefill]); + }, [requestId, pendingSessionId, requestMethod, requestPrefill, pending]); useEffect(() => { if (!pendingSessionId || !requestId || !requestTimeout || requestTimeout <= 0) { @@ -78,15 +102,35 @@ export const ExtensionUiDialog = () => { return () => window.clearTimeout(timer); }, [pendingSessionId, requestId, requestTimeout, respond]); + useEffect(() => { + if (!pending || pending.foreign) return; + const onKey = (event: globalThis.KeyboardEvent) => { + if (event.key !== "Escape") return; + const target = event.target as HTMLElement | null; + if ( + target && + (target.tagName === "TEXTAREA" || target.tagName === "INPUT") && + dockRef.current?.contains(target) + ) { + return; + } + event.preventDefault(); + void respond(pending.sessionId, pending.request.id, denialResponse(pending.request)); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [pending, respond]); + if (!pending) return null; - const { sessionId, request } = pending; + const { sessionId, request, foreign } = pending; const session = sessions.find((candidate) => candidate.id === sessionId); const queuedCount = Object.values(requests).reduce( (total, queued) => total + queued.length, 0, ); const isTextEntry = request.method === "input" || request.method === "editor"; + const detail = detailText(request); const submit = async (response: ExtensionUiResponse) => { if (busy) return; @@ -95,165 +139,154 @@ export const ExtensionUiDialog = () => { if (!answered) setBusy(false); }; - const handleDialogKeyDown = (event: KeyboardEvent) => { + const handleDockKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); void submit(denialResponse(request)); - return; - } - if (event.key !== "Tab") return; - const focusable = Array.from( - dialogRef.current?.querySelectorAll( - "textarea, input, button:not([disabled])", - ) ?? [], - ); - if (focusable.length === 0) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); } }; + if (foreign) { + return ( +
+
+ ); + } + return ( -
-