From 6281e24c3a77f8cf48d72ca80e40afa108702363 Mon Sep 17 00:00:00 2001 From: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:15:53 -0700 Subject: [PATCH 01/12] fix(rpc): include stderr when OMP exits before ready Surface the process stderr tail in ready/timeout errors so startup failures are diagnosable. Co-authored-by: Cursor --- src-tauri/src/rpc/client.rs | 65 +++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/rpc/client.rs b/src-tauri/src/rpc/client.rs index 4797c7f..2b83174 100644 --- a/src-tauri/src/rpc/client.rs +++ b/src-tauri/src/rpc/client.rs @@ -14,6 +14,7 @@ use tokio::sync::{mpsc, oneshot, watch, Mutex}; use tokio::time::{timeout, Duration}; const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const STDERR_TAIL_CHARS: usize = 1_200; type PendingRequests = Arc>>>; @@ -24,9 +25,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 @@ -40,6 +59,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); @@ -53,11 +73,26 @@ 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 pending: PendingRequests = Arc::new(Mutex::new(HashMap::new())); let reader_pending = Arc::clone(&pending); 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 lines = BufReader::new(stdout).lines(); @@ -106,6 +141,7 @@ impl RpcClient { next_id: AtomicU64::new(0), pending, ready, + stderr_tail, events: Some(events), }) } @@ -120,19 +156,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 { From fd0f3a9c335d12e374516fd183d56fdf05c7c25e Mon Sep 17 00:00:00 2001 From: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:17:42 -0700 Subject: [PATCH 02/12] fix: slim omp-event payloads to avoid stuck Working Strip heavy partial/signature trees before Tauri emit so agent_end and message_update can reach the UI. --- src-tauri/src/commands/mod.rs | 123 ++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 72f1201..e840a73 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -52,6 +52,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() -> Result { omp_config::load_model_roles() @@ -466,13 +534,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::() @@ -592,13 +654,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::() @@ -942,4 +998,43 @@ mod tests { assert_eq!(omp_binary_or_fallback(&settings), PathBuf::from("omp")); } + + #[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" + }] + } + }) + ); + } } From 6daa6c16756e093999165ae3cc3ac1e2f2e02910 Mon Sep 17 00:00:00 2001 From: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:19:39 -0700 Subject: [PATCH 03/12] feat: add provider API keys and auth-broker login Let users manage provider env keys and OAuth login from Settings. --- src-tauri/src/auth_broker.rs | 204 ++++++++++++ src-tauri/src/commands/mod.rs | 43 +++ src-tauri/src/lib.rs | 7 + src-tauri/src/provider_keys.rs | 319 +++++++++++++++++++ ui/src/lib/tauri.ts | 16 + ui/src/panels/settings-panel.tsx | 531 +++++++++++++++++++++++-------- ui/src/session/types.ts | 18 ++ ui/src/styles.css | 142 +++++++++ 8 files changed, 1153 insertions(+), 127 deletions(-) create mode 100644 src-tauri/src/auth_broker.rs create mode 100644 src-tauri/src/provider_keys.rs 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 e840a73..3593666 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; @@ -165,6 +167,47 @@ 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>, 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/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/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 283e578..ffd8aba 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -7,6 +7,9 @@ import type { AppSettings, SetupStatus, AvailableModel, + ProviderKeyStatus, + ProviderKeyUpdate, + LoginProvider, RemoteTarget, SshHostInfo, SshProbeResult, @@ -57,6 +60,19 @@ export const api = { saveSettings: (settings: AppSettings) => invoke("save_settings", { settings }), + getProviderKeys: () => invoke("get_provider_keys"), + + saveProviderKeys: (updates: ProviderKeyUpdate[]) => + invoke("save_provider_keys", { updates }), + + listLoginProviders: () => invoke("list_login_providers"), + + loginProvider: (providerId: string) => + invoke("login_provider", { providerId }), + + logoutProvider: (providerId: string) => + invoke("logout_provider", { providerId }), + listSessions: () => invoke("list_sessions"), createSession: (cwd: string, resume?: string, remote?: RemoteTarget | null) => diff --git a/ui/src/panels/settings-panel.tsx b/ui/src/panels/settings-panel.tsx index 8536ea9..ec54ee9 100644 --- a/ui/src/panels/settings-panel.tsx +++ b/ui/src/panels/settings-panel.tsx @@ -1,7 +1,13 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { api } from "../lib/tauri.ts"; import { useSessionStore } from "../session/session-store.ts"; -import type { AppSettings, ApprovalMode } from "../session/types.ts"; +import type { + AppSettings, + ApprovalMode, + LoginProvider, + ProviderKeyStatus, +} from "../session/types.ts"; const defaultSettings: AppSettings = { approvalMode: "write", @@ -11,6 +17,7 @@ const defaultSettings: AppSettings = { defaultProfile: null, theme: "dark", onboardingCompleted: false, + modelRolePresets: [], }; const optionalValue = (value: string): string | null => value.trim() || null; @@ -19,10 +26,23 @@ export const SettingsPanel = () => { const settings = useSessionStore((state) => state.settings); const loadSettings = useSessionStore((state) => state.loadSettings); const saveSettings = useSessionStore((state) => state.saveSettings); + const loadAvailableModels = useSessionStore((state) => state.loadAvailableModels); const [form, setForm] = useState(settings ?? defaultSettings); const [saveState, setSaveState] = useState< "idle" | "saving" | "saved" | "error" >("idle"); + const [providerKeys, setProviderKeys] = useState([]); + const [keyDrafts, setKeyDrafts] = useState>({}); + const [keysToClear, setKeysToClear] = useState>(new Set()); + const [keysSaveState, setKeysSaveState] = useState< + "idle" | "saving" | "saved" | "error" + >("idle"); + const [keysError, setKeysError] = useState(null); + const [loginProviders, setLoginProviders] = useState([]); + const [loginQuery, setLoginQuery] = useState(""); + const [loginBusyId, setLoginBusyId] = useState(null); + const [loginStatus, setLoginStatus] = useState(null); + const [loginError, setLoginError] = useState(null); useEffect(() => { void loadSettings(); @@ -32,6 +52,42 @@ export const SettingsPanel = () => { if (settings) setForm(settings); }, [settings]); + useEffect(() => { + let cancelled = false; + void api + .getProviderKeys() + .then((keys) => { + if (!cancelled) setProviderKeys(keys); + }) + .catch((error: unknown) => { + if (!cancelled) { + setKeysError(error instanceof Error ? error.message : String(error)); + } + }); + void api + .listLoginProviders() + .then((providers) => { + if (!cancelled) setLoginProviders(providers); + }) + .catch((error: unknown) => { + if (!cancelled) { + setLoginError(error instanceof Error ? error.message : String(error)); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const filteredLoginProviders = useMemo(() => { + const query = loginQuery.trim().toLowerCase(); + if (!query) return loginProviders; + return loginProviders.filter((provider) => { + const hay = `${provider.name} ${provider.id}`.toLowerCase(); + return hay.includes(query); + }); + }, [loginProviders, loginQuery]); + const submit = async (event: FormEvent) => { event.preventDefault(); setSaveState("saving"); @@ -45,132 +101,353 @@ export const SettingsPanel = () => { }); setSaveState(didSave ? "saved" : "error"); }; + + const saveProviderKeys = async () => { + setKeysSaveState("saving"); + setKeysError(null); + const updates = providerKeys.flatMap((key) => { + if (keysToClear.has(key.name)) { + return [{ name: key.name, clear: true }]; + } + const draft = (keyDrafts[key.name] ?? "").trim(); + if (!draft) return []; + return [{ name: key.name, value: draft, clear: false }]; + }); + + try { + const next = await api.saveProviderKeys(updates); + setProviderKeys(next); + setKeyDrafts({}); + setKeysToClear(new Set()); + setKeysSaveState("saved"); + void loadAvailableModels(); + } catch (error) { + setKeysSaveState("error"); + setKeysError(error instanceof Error ? error.message : String(error)); + } + }; + + const signInProvider = async (providerId: string) => { + setLoginBusyId(providerId); + setLoginError(null); + setLoginStatus(`Opening browser for ${providerId}…`); + try { + await api.loginProvider(providerId); + setLoginStatus(`Signed in · ${providerId}`); + void loadAvailableModels(); + } catch (error) { + setLoginStatus(null); + setLoginError(error instanceof Error ? error.message : String(error)); + } finally { + setLoginBusyId(null); + } + }; + + const signOutProvider = async (providerId: string) => { + setLoginBusyId(providerId); + setLoginError(null); + setLoginStatus(`Signing out · ${providerId}`); + try { + await api.logoutProvider(providerId); + setLoginStatus(`Signed out · ${providerId}`); + void loadAvailableModels(); + } catch (error) { + setLoginStatus(null); + setLoginError(error instanceof Error ? error.message : String(error)); + } finally { + setLoginBusyId(null); + } + }; + return ( -
void submit(event)}> - - - - - - - - - - - - -
- +
+ +
+ + {saveState === "saved" && Saved} +
+ {saveState === "error" && ( +

+ Settings could not be saved. +

+ )} +
+ + +
+
+

Sign in

+

+ Subscription / OAuth providers — same list as omp /login. +

+
+ + + +
+ {filteredLoginProviders.length === 0 ? ( +

+ {loginProviders.length === 0 + ? "Loading providers…" + : "No providers matched."} +

+ ) : ( + filteredLoginProviders.map((provider) => { + const busy = loginBusyId === provider.id; + return ( +
+
+ {provider.name} + {provider.id} +
+
+ + +
+
+ ); }) - } - > - Replay first-launch walkthrough - -
- -
- - {saveState === "saved" && Saved for the next session} -
- {saveState === "error" && ( -

- Settings could not be saved. -

- )} - + )} + + + {loginStatus &&

{loginStatus}

} + {loginError && ( +

+ {loginError} +

+ )} +
+ +
+
+

API keys

+

+ Written to ~/.omp/agent/.env. Blank keeps the current key. +

+
+ +
+ {providerKeys.map((key) => { + const clearing = keysToClear.has(key.name); + const draft = keyDrafts[key.name] ?? ""; + const canClear = key.configured || draft.trim().length > 0 || clearing; + const status = clearing + ? "clearing" + : key.configured + ? key.masked ?? "set" + : "not set"; + return ( +
+
+ {key.label} + {key.name} + + {status} + +
+
+ { + const value = event.target.value; + setKeyDrafts((current) => ({ ...current, [key.name]: value })); + setKeysToClear((current) => { + if (!current.has(key.name)) return current; + const next = new Set(current); + next.delete(key.name); + return next; + }); + setKeysSaveState("idle"); + }} + /> + +
+
+ ); + })} +
+ +
+ + {keysSaveState === "saved" && Saved} +
+ {keysError && ( +

+ {keysError} +

+ )} +
+ ); }; diff --git a/ui/src/session/types.ts b/ui/src/session/types.ts index 5be0981..46578b0 100644 --- a/ui/src/session/types.ts +++ b/ui/src/session/types.ts @@ -69,6 +69,24 @@ export interface SetupStatus { homeDir?: string | null; } +export interface ProviderKeyStatus { + name: string; + label: string; + configured: boolean; + masked?: string | null; +} + +export interface ProviderKeyUpdate { + name: string; + value?: string | null; + clear?: boolean; +} + +export interface LoginProvider { + id: string; + name: string; +} + export interface ExtensionUiRequest { id: string; method: diff --git a/ui/src/styles.css b/ui/src/styles.css index a4a1e6d..00a4c1d 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -3855,3 +3855,145 @@ button:disabled { flex-direction: column; } } + +.settings-panel { + display: flex; + flex-direction: column; + gap: 18px; +} + +.settings-section { + display: flex; + flex-direction: column; + gap: 11px; +} + +.settings-section + .settings-section { + padding-top: 14px; + border-top: 1px solid var(--line-soft); +} + +.settings-section__head .eyebrow { + margin-bottom: 6px; +} + +.settings-section__blurb { + margin: 0; + color: #737c88; + font-size: 10px; + line-height: 1.45; +} + +.settings-section__blurb code { + color: var(--accent); + font-family: "SFMono-Regular", Menlo, monospace; + font-size: 9px; +} + +.settings-section__note { + margin: 0; + color: #7d9567; + font-size: 9px; + line-height: 1.45; +} + +.settings-section__error { + white-space: pre-wrap; +} + +.settings-list { + display: flex; + flex-direction: column; + max-height: min(320px, 42vh); + overflow: auto; + margin: 0 -2px; + padding: 0 2px; +} + +.settings-list__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 9px 4px; + border-bottom: 1px solid var(--line-soft); +} + +.settings-list__row--stack { + grid-template-columns: 1fr; + gap: 7px; +} + +.settings-list__copy { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 8px; +} + +.settings-list__title { + color: #c3c9d2; + font-size: 11px; + line-height: 1.35; +} + +.settings-list__id { + color: #69727e; + font-family: "SFMono-Regular", Menlo, monospace; + font-size: 7px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-list__status { + margin-left: auto; + color: #4f5864; + font-family: "SFMono-Regular", Menlo, monospace; + font-size: 7px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-list__status.is-set { + color: #7d9567; +} + +.settings-list__actions { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.settings-list__empty { + margin: 0; + padding: 10px 4px; + color: #626b77; + font-size: 10px; +} + +.settings-keys__input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; +} + +.settings-keys__input-row input { + width: 100%; + min-width: 0; + height: 31px; + padding: 0 9px; + border: 1px solid #303741; + border-radius: 5px; + color: #d0d5dc; + background: #101318; + font-size: 10px; +} + +.settings-keys__input-row input::placeholder { + color: #4f5864; +} + +.settings-keys__input-row input:disabled { + opacity: 0.55; +} From 61a14e6928e0a5f32cdfa0fb47e17743effa7972 Mon Sep 17 00:00:00 2001 From: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:19:46 -0700 Subject: [PATCH 04/12] feat(ui): add Agents panel for model roles Move role model configuration into an Agents drawer and show model-only role chips. --- src-tauri/src/omp_config.rs | 10 +- src-tauri/src/settings/mod.rs | 11 + ui/src/app/layout-store.ts | 5 + ui/src/app/palette.tsx | 1 + ui/src/app/rails.tsx | 11 + ui/src/app/role-model-picker.tsx | 288 +++--------------- ui/src/panels/agents-panel.tsx | 501 +++++++++++++++++++++++++++++++ ui/src/session/types.ts | 5 + ui/src/styles.css | 159 ++++++++++ 9 files changed, 734 insertions(+), 257 deletions(-) create mode 100644 ui/src/panels/agents-panel.tsx diff --git a/src-tauri/src/omp_config.rs b/src-tauri/src/omp_config.rs index f78c051..3cfe46b 100644 --- a/src-tauri/src/omp_config.rs +++ b/src-tauri/src/omp_config.rs @@ -83,17 +83,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/settings/mod.rs b/src-tauri/src/settings/mod.rs index ef689ce..f2afd13 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -23,6 +23,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 { @@ -36,6 +43,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 { @@ -48,6 +58,7 @@ impl Default for AppSettings { default_profile: None, theme: "dark".into(), onboarding_completed: false, + model_role_presets: Vec::new(), } } } diff --git a/ui/src/app/layout-store.ts b/ui/src/app/layout-store.ts index a0eb42e..ca262d2 100644 --- a/ui/src/app/layout-store.ts +++ b/ui/src/app/layout-store.ts @@ -3,6 +3,7 @@ import { create } from "zustand"; export type PanelId = | "sessions" | "project" + | "agents" | "settings" | "terminal" | "plan" @@ -19,18 +20,21 @@ interface LayoutStore { drawer: PanelId | null; pinned: PanelId[]; sessionsSidebarOpen: boolean; + agentsFocusRole: string | null; openDrawer: (panel: PanelId) => void; closeDrawer: () => void; togglePin: (panel: PanelId) => void; toggleDrawer: (panel: PanelId) => void; setSessionsSidebarOpen: (open: boolean) => void; toggleSessionsSidebar: () => void; + setAgentsFocusRole: (role: string | null) => void; } export const useLayoutStore = create()((set) => ({ drawer: null, pinned: [], sessionsSidebarOpen: false, + agentsFocusRole: null, openDrawer: (panel) => set({ drawer: panel }), closeDrawer: () => set({ drawer: null }), togglePin: (panel) => @@ -44,4 +48,5 @@ export const useLayoutStore = create()((set) => ({ setSessionsSidebarOpen: (open) => set({ sessionsSidebarOpen: open }), toggleSessionsSidebar: () => set((state) => ({ sessionsSidebarOpen: !state.sessionsSidebarOpen })), + setAgentsFocusRole: (role) => set({ agentsFocusRole: role }), })); diff --git a/ui/src/app/palette.tsx b/ui/src/app/palette.tsx index bd4bbee..cf002cc 100644 --- a/ui/src/app/palette.tsx +++ b/ui/src/app/palette.tsx @@ -30,6 +30,7 @@ interface AvailableModel { const panelActions: Array<{ id: PanelId; label: string }> = [ { id: "sessions", label: "Sessions" }, { id: "project", label: "Project" }, + { id: "agents", label: "Agents" }, { id: "plan", label: "Plan" }, { id: "activity", label: "Activity" }, { id: "subagents", label: "Subagents" }, diff --git a/ui/src/app/rails.tsx b/ui/src/app/rails.tsx index dd43bc7..c81a532 100644 --- a/ui/src/app/rails.tsx +++ b/ui/src/app/rails.tsx @@ -21,6 +21,7 @@ const leftItems: RailItem[] = [ { id: "chat", label: "Chat", shortcut: "⌘1" }, { id: "sessions", label: "Sessions", shortcut: "⌘2" }, { id: "project", label: "Project", shortcut: "⌘3" }, + { id: "agents", label: "Agents", shortcut: "⌘M" }, { id: "settings", label: "Settings", shortcut: "⌘," }, { id: "terminal", label: "Terminal", shortcut: "⌘J" }, ]; @@ -55,6 +56,16 @@ const Icon = ({ target }: { target: RailTarget }) => { case "project": shape = ; break; + case "agents": + shape = ( + <> + + + + + + ); + break; case "settings": shape = ( <> diff --git a/ui/src/app/role-model-picker.tsx b/ui/src/app/role-model-picker.tsx index 01cc2d7..067e4ec 100644 --- a/ui/src/app/role-model-picker.tsx +++ b/ui/src/app/role-model-picker.tsx @@ -1,241 +1,24 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo } from "react"; +import { useLayoutStore } from "./layout-store.ts"; import { PRIMARY_MODEL_ROLES, useSessionStore } from "../session/session-store.ts"; -import type { AvailableModel, ModelRoleAssignment } from "../session/types.ts"; - -const THINKING_LEVELS = [ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -] as const; - -const splitSelector = (selector: string) => { - const match = selector.match(/^(.*?)(?::(off|minimal|low|medium|high|xhigh|max|auto))?$/); - const base = match?.[1] ?? selector; - const thinking = match?.[2] ?? null; - const slash = base.indexOf("/"); - if (slash === -1) { - return { provider: null as string | null, modelId: base, thinking, base }; - } - return { - provider: base.slice(0, slash), - modelId: base.slice(slash + 1), - thinking, - base, - }; -}; - -const shortModel = (model: AvailableModel) => { - const label = model.name || model.id; - return label.length > 34 ? `${label.slice(0, 33)}…` : label; -}; - -const RolePicker = ({ - role, - assignment, - models, - modelsLoaded, - onEnsureModels, -}: { - role: string; - assignment?: ModelRoleAssignment; - models: AvailableModel[]; - modelsLoaded: boolean; - onEnsureModels: () => void; -}) => { - const setModelRole = useSessionStore((state) => state.setModelRole); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const [thinking, setThinking] = useState( - assignment?.thinking ?? "high", - ); - const [saving, setSaving] = useState(false); - const rootRef = useRef(null); - - useEffect(() => { - setThinking(assignment?.thinking ?? "high"); - }, [assignment?.selector, assignment?.thinking]); - - useEffect(() => { - if (!open) return; - onEnsureModels(); - const onPointer = (event: MouseEvent) => { - if (!rootRef.current?.contains(event.target as Node)) { - setOpen(false); - setQuery(""); - } - }; - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setOpen(false); - setQuery(""); - } - }; - window.addEventListener("mousedown", onPointer); - window.addEventListener("keydown", onKey); - return () => { - window.removeEventListener("mousedown", onPointer); - window.removeEventListener("keydown", onKey); - }; - }, [open, onEnsureModels]); - - const providers = useMemo(() => { - const set = new Set(models.map((model) => model.provider)); - return [...set].sort((a, b) => a.localeCompare(b)); - }, [models]); - - const filtered = useMemo(() => { - const needle = query.trim().toLowerCase(); - const list = !needle - ? models - : models.filter((model) => { - const hay = `${model.provider} ${model.id} ${model.name}`.toLowerCase(); - return hay.includes(needle); - }); - return list.slice(0, 120); - }, [models, query]); - - const grouped = useMemo(() => { - const map = new Map(); - for (const model of filtered) { - const bucket = map.get(model.provider) ?? []; - bucket.push(model); - map.set(model.provider, bucket); - } - return [...map.entries()]; - }, [filtered]); - - const currentLabel = assignment?.shortLabel ?? "not set"; - const currentBase = assignment ? splitSelector(assignment.selector).base : null; - - const choose = async (model: AvailableModel) => { - const efforts = model.thinkingEfforts; - const nextThinking = - efforts.length > 0 - ? efforts.includes(thinking) - ? thinking - : efforts.includes("high") - ? "high" - : efforts[efforts.length - 1] - : thinking; - const selector = - model.reasoning || efforts.length > 0 - ? `${model.selector}:${nextThinking}` - : model.selector; - setSaving(true); - try { - const ok = await setModelRole(role, selector); - if (ok) { - setOpen(false); - setQuery(""); - } - } finally { - setSaving(false); - } - }; - - return ( -
- - - {open ? ( -
-
- setQuery(event.target.value)} - placeholder="Filter provider or model…" - aria-label={`Filter models for ${role}`} - /> - -
- -
- {modelsLoaded - ? `${providers.length} providers · ${models.length} authenticated models` - : "Loading authenticated models…"} - {saving ? " · saving…" : ""} -
- -
- {!modelsLoaded ? ( -
Loading…
- ) : grouped.length === 0 ? ( -
- No authenticated models matched. Log in via OMP CLI first. -
- ) : ( - grouped.map(([provider, providerModels]) => ( -
-
{provider}
- {providerModels.map((model) => { - const selected = currentBase === model.selector; - return ( - - ); - })} -
- )) - )} -
-
- ) : null} -
- ); +import type { ModelRoleAssignment } from "../session/types.ts"; + +/** Chip label: model id (+ optional :thinking), no provider prefix. */ +const chipModelLabel = (role: ModelRoleAssignment) => { + const raw = role.shortLabel?.trim() || role.selector?.trim() || ""; + if (!raw || raw === "—") return "—"; + const base = raw.includes(":") ? raw.slice(0, raw.lastIndexOf(":")) : raw; + const thinking = raw.includes(":") ? raw.slice(raw.lastIndexOf(":")) : ""; + const model = base.includes("/") ? base.slice(base.lastIndexOf("/") + 1) : base; + const label = `${model}${thinking}`; + return label.length > 28 ? `${label.slice(0, 27)}…` : label; }; export const RoleModelStrip = () => { const modelRoles = useSessionStore((state) => state.modelRoles); - const availableModels = useSessionStore((state) => state.availableModels); - const availableModelsLoaded = useSessionStore( - (state) => state.availableModelsLoaded, - ); - const loadAvailableModels = useSessionStore((state) => state.loadAvailableModels); - const runtimeSnapshot = useSessionStore((state) => - state.activeSessionId ? state.states[state.activeSessionId] : undefined, - ); + const openDrawer = useLayoutStore((state) => state.openDrawer); + const setAgentsFocusRole = useLayoutStore((state) => state.setAgentsFocusRole); const roles = useMemo(() => { const preferred = new Set(PRIMARY_MODEL_ROLES); @@ -245,18 +28,16 @@ export const RoleModelStrip = () => { ...modelRoles.filter((role) => !preferred.has(role.role)), ] as ModelRoleAssignment[]; - // Always show core roles even if unset in config. for (const role of PRIMARY_MODEL_ROLES) { if (!ordered.some((item) => item.role === role)) { ordered.unshift({ role, selector: "", - shortLabel: "not set", + shortLabel: "—", }); } } - // de-dupe after unshift const seen = new Set(); return ordered .filter((role) => { @@ -267,25 +48,30 @@ export const RoleModelStrip = () => { .slice(0, 8); }, [modelRoles]); - // Keep selector stable by not depending on runtime snapshot object identity beyond active highlight. - void runtimeSnapshot; + const openAgents = (role: string) => { + setAgentsFocusRole(role); + openDrawer("agents"); + }; return (
- {roles.map((role) => ( - { - if (!availableModelsLoaded || availableModels.length === 0) { - void loadAvailableModels(); - } - }} - /> - ))} + {roles.map((role) => { + const unset = !role.selector; + const label = chipModelLabel(role); + const full = role.selector?.trim() || "configure in Agents"; + return ( + + ); + })}
); }; diff --git a/ui/src/panels/agents-panel.tsx b/ui/src/panels/agents-panel.tsx new file mode 100644 index 0000000..da9c415 --- /dev/null +++ b/ui/src/panels/agents-panel.tsx @@ -0,0 +1,501 @@ +import { useEffect, useMemo, useState } from "react"; + +import { useLayoutStore } from "../app/layout-store.ts"; +import { PRIMARY_MODEL_ROLES, useSessionStore } from "../session/session-store.ts"; +import type { AvailableModel, ModelRolePreset } from "../session/types.ts"; + +const FALLBACK_THINKING = ["off", "low", "medium", "high"] as const; + +const shortModel = (model: AvailableModel) => { + const label = model.name || model.id; + return label.length > 40 ? `${label.slice(0, 39)}…` : label; +}; + +const thinkingOptionsFor = (model: AvailableModel | null): string[] => { + if (!model) return []; + if (model.thinkingEfforts.length > 0) return model.thinkingEfforts; + if (model.reasoning) return [...FALLBACK_THINKING]; + return []; +}; + +const buildSelector = (model: AvailableModel, thinking: string | null) => { + const efforts = thinkingOptionsFor(model); + if (efforts.length === 0 || !thinking) return model.selector; + return `${model.selector}:${thinking}`; +}; + +const suggestBuiltinPresets = ( + models: AvailableModel[], +): ModelRolePreset[] => { + if (models.length === 0) return []; + const flat = models; + const pick = (index: number) => flat[Math.min(index, flat.length - 1)]; + const withThink = (model: AvailableModel, prefer: string) => { + const efforts = thinkingOptionsFor(model); + if (efforts.length === 0) return model.selector; + const level = efforts.includes(prefer) + ? prefer + : efforts.includes("high") + ? "high" + : efforts[efforts.length - 1]; + return `${model.selector}:${level}`; + }; + + const mid = pick(Math.floor(flat.length / 2)); + const fast = pick(0); + const deep = pick(flat.length - 1); + + return [ + { + name: "Balanced", + roles: { + default: withThink(mid, "medium"), + smol: withThink(fast, "low"), + slow: withThink(deep, "high"), + plan: withThink(mid, "high"), + }, + }, + { + name: "Fast", + roles: { + default: withThink(fast, "low"), + smol: withThink(fast, "off"), + slow: withThink(mid, "medium"), + plan: withThink(fast, "medium"), + }, + }, + { + name: "Deep", + roles: { + default: withThink(deep, "high"), + smol: withThink(mid, "low"), + slow: withThink(deep, "max"), + plan: withThink(deep, "high"), + }, + }, + ].map((preset) => { + // Clamp unknown thinking levels like "max" if unsupported. + const roles: Record = {}; + for (const [role, selector] of Object.entries(preset.roles)) { + const [base, thinking] = selector.includes(":") + ? (() => { + const i = selector.lastIndexOf(":"); + return [selector.slice(0, i), selector.slice(i + 1)] as const; + })() + : ([selector, null] as const); + const model = models.find((item) => item.selector === base); + if (!model) { + roles[role] = selector; + continue; + } + const efforts = thinkingOptionsFor(model); + if (!thinking || efforts.length === 0) { + roles[role] = model.selector; + continue; + } + const level = efforts.includes(thinking) + ? thinking + : efforts.includes("high") + ? "high" + : efforts[efforts.length - 1]; + roles[role] = `${model.selector}:${level}`; + } + return { name: preset.name, roles }; + }); +}; + +export const AgentsPanel = () => { + const modelRoles = useSessionStore((state) => state.modelRoles); + const availableModels = useSessionStore((state) => state.availableModels); + const availableModelsLoaded = useSessionStore( + (state) => state.availableModelsLoaded, + ); + const loadAvailableModels = useSessionStore((state) => state.loadAvailableModels); + const loadModelRoles = useSessionStore((state) => state.loadModelRoles); + const setModelRole = useSessionStore((state) => state.setModelRole); + const settings = useSessionStore((state) => state.settings); + const saveSettings = useSessionStore((state) => state.saveSettings); + const loadSettings = useSessionStore((state) => state.loadSettings); + + const agentsFocusRole = useLayoutStore((state) => state.agentsFocusRole); + const setAgentsFocusRole = useLayoutStore((state) => state.setAgentsFocusRole); + + const [activeRole, setActiveRole] = useState("default"); + const [query, setQuery] = useState(""); + const [selectedModel, setSelectedModel] = useState(null); + const [thinking, setThinking] = useState(null); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [presetName, setPresetName] = useState(""); + + useEffect(() => { + void loadSettings(); + void loadModelRoles(); + void loadAvailableModels(); + }, [loadSettings, loadModelRoles, loadAvailableModels]); + + useEffect(() => { + if (!agentsFocusRole) return; + setActiveRole(agentsFocusRole); + setAgentsFocusRole(null); + }, [agentsFocusRole, setAgentsFocusRole]); + + const roleAssignments = useMemo(() => { + const map = new Map(modelRoles.map((role) => [role.role, role])); + return PRIMARY_MODEL_ROLES.map((role) => { + const current = map.get(role); + return ( + current ?? { + role, + selector: "", + shortLabel: "—", + } + ); + }); + }, [modelRoles]); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + const list = !needle + ? availableModels + : availableModels.filter((model) => { + const hay = `${model.provider} ${model.id} ${model.name}`.toLowerCase(); + return hay.includes(needle); + }); + return list.slice(0, 160); + }, [availableModels, query]); + + const grouped = useMemo(() => { + const map = new Map(); + for (const model of filtered) { + const bucket = map.get(model.provider) ?? []; + bucket.push(model); + map.set(model.provider, bucket); + } + return [...map.entries()]; + }, [filtered]); + + const thinkingOptions = thinkingOptionsFor(selectedModel); + const userPresets = settings?.modelRolePresets ?? []; + const builtinPresets = useMemo( + () => suggestBuiltinPresets(availableModels), + [availableModels], + ); + + const selectModel = (model: AvailableModel) => { + setSelectedModel(model); + const efforts = thinkingOptionsFor(model); + if (efforts.length === 0) { + setThinking(null); + return; + } + const current = roleAssignments.find((item) => item.role === activeRole); + const fromRole = current?.thinking; + setThinking( + fromRole && efforts.includes(fromRole) + ? fromRole + : efforts.includes("high") + ? "high" + : efforts[efforts.length - 1], + ); + }; + + const applyRole = async () => { + if (!selectedModel) return; + setBusy(true); + setError(null); + setStatus(null); + try { + const selector = buildSelector(selectedModel, thinking); + const ok = await setModelRole(activeRole, selector); + if (ok) setStatus(`Set ${activeRole} → ${selector}`); + else setError(`Unable to set ${activeRole}`); + } finally { + setBusy(false); + } + }; + + const applyPreset = async (preset: ModelRolePreset) => { + setBusy(true); + setError(null); + setStatus(null); + try { + for (const [role, selector] of Object.entries(preset.roles)) { + if (!selector.trim()) continue; + const ok = await setModelRole(role, selector); + if (!ok) { + setError(`Failed applying ${role} from “${preset.name}”`); + return; + } + } + setStatus(`Applied preset “${preset.name}”`); + void loadModelRoles(); + } finally { + setBusy(false); + } + }; + + const saveCurrentAsPreset = async () => { + const name = presetName.trim(); + if (!name) { + setError("Enter a preset name"); + return; + } + if (!settings) { + setError("Settings not loaded"); + return; + } + const roles: Record = {}; + for (const role of roleAssignments) { + if (role.selector) roles[role.role] = role.selector; + } + if (Object.keys(roles).length === 0) { + setError("Assign at least one role before saving a preset"); + return; + } + const nextPresets = [ + ...userPresets.filter( + (preset) => preset.name.toLowerCase() !== name.toLowerCase(), + ), + { name, roles }, + ]; + setBusy(true); + setError(null); + try { + const ok = await saveSettings({ + ...settings, + modelRolePresets: nextPresets, + }); + if (ok) { + setPresetName(""); + setStatus(`Saved preset “${name}”`); + } else { + setError("Unable to save preset"); + } + } finally { + setBusy(false); + } + }; + + const deletePreset = async (name: string) => { + if (!settings) return; + setBusy(true); + setError(null); + try { + const ok = await saveSettings({ + ...settings, + modelRolePresets: userPresets.filter((preset) => preset.name !== name), + }); + if (ok) setStatus(`Deleted preset “${name}”`); + else setError("Unable to delete preset"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+

Roles

+

+ Assign models for default / smol / slow / plan. Thinking is chosen + per model from its supported efforts. +

+
+ +
+ {roleAssignments.map((role) => ( + + ))} +
+
+ +
+
+

Model · {activeRole}

+

+ {availableModelsLoaded + ? `${availableModels.length} authenticated models` + : "Loading models…"} +

+
+ + + +
+ {!availableModelsLoaded ? ( +

Loading…

+ ) : grouped.length === 0 ? ( +

+ No models. Sign in or add an API key in Settings. +

+ ) : ( + grouped.map(([provider, models]) => ( +
+
{provider}
+ {models.map((model) => { + const selected = selectedModel?.selector === model.selector; + return ( + + ); + })} +
+ )) + )} +
+ + {selectedModel && ( +
+
+ Thinking + {selectedModel.selector} +
+ {thinkingOptions.length === 0 ? ( +

No thinking levels for this model.

+ ) : ( +
+ {thinkingOptions.map((level) => ( + + ))} +
+ )} +
+ +
+
+ )} +
+ +
+
+

Presets

+

+ Apply a bundle of role assignments, or save the current map. +

+
+ +
+ {builtinPresets.map((preset) => ( +
+
+ {preset.name} + suggested +
+ +
+ ))} + + {userPresets.map((preset) => ( +
+
+ {preset.name} + saved +
+
+ + +
+
+ ))} +
+ +
+ + +
+
+ + {status &&

{status}

} + {error && ( +

+ {error} +

+ )} +
+ ); +}; diff --git a/ui/src/session/types.ts b/ui/src/session/types.ts index 46578b0..353829b 100644 --- a/ui/src/session/types.ts +++ b/ui/src/session/types.ts @@ -47,6 +47,11 @@ export interface SshProbeResult { remoteCwd?: string | null; } +export interface ModelRolePreset { + name: string; + roles: Record; +} + export interface AppSettings { approvalMode: ApprovalMode; ompBinary?: string | null; diff --git a/ui/src/styles.css b/ui/src/styles.css index 00a4c1d..9142730 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -3997,3 +3997,162 @@ button:disabled { .settings-keys__input-row input:disabled { opacity: 0.55; } + +.agents-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.agents-roles__row { + width: 100%; + appearance: none; + background: transparent; + text-align: left; + cursor: pointer; +} + +.agents-roles__row.is-active { + background: rgba(0, 180, 255, 0.08); +} + +.agents-roles__row.is-active .settings-list__title { + color: var(--ink); +} + +.agents-models { + max-height: min(240px, 34vh); + overflow: auto; + border-top: 1px solid var(--line-soft); +} + +.agents-models__provider { + position: sticky; + top: 0; + z-index: 1; + padding: 7px 4px 3px; + color: #69727e; + background: rgba(15, 18, 24, 0.96); + font-family: "SFMono-Regular", Menlo, monospace; + font-size: 7px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.agents-models__option { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + width: 100%; + margin: 0; + padding: 7px 4px; + border: 0; + border-bottom: 1px solid var(--line-soft); + background: transparent; + color: #c3c9d2; + text-align: left; + cursor: pointer; +} + +.agents-models__option span:last-child { + color: #4f5864; + font-family: "SFMono-Regular", Menlo, monospace; + font-size: 8px; +} + +.agents-models__option:hover { + background: rgba(255, 255, 255, 0.025); +} + +.agents-models__option.is-selected { + background: rgba(0, 180, 255, 0.1); + color: var(--ink); +} + +.agents-presets { + display: flex; + flex-direction: column; +} + +.agents-presets__row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 8px 4px; + border-bottom: 1px solid var(--line-soft); +} + +.agents-presets__save { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + align-items: end; +} + +.agents-thinking { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 4px; +} + +.agents-thinking__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + color: #929ba7; + font-family: "SFMono-Regular", Menlo, monospace; + font-size: 7px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.agents-thinking__head em { + color: #69727e; + font-style: normal; + text-transform: none; + letter-spacing: 0; + font-size: 9px; +} + +.agents-thinking__chips { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.agents-chip { + min-height: 26px; + padding: 0 9px; + border: 1px solid #303741; + border-radius: 5px; + color: #aeb5c0; + background: #151820; + font-size: 9px; + font-weight: 600; + cursor: pointer; +} + +.agents-chip:hover { + border-color: #4a5540; + color: #dce4d4; +} + +.agents-chip.is-active { + border-color: rgba(0, 180, 255, 0.45); + color: var(--ink); + background: rgba(0, 180, 255, 0.12); +} + +.role-chip:hover { + border-color: #4a5540; + color: #dce4d4; + background: #1a1e24; +} + +.role-chip.is-unset .role-chip__model { + color: #4f5864; +} From 53cf8d7fe50094457226d5d12e5e2bc349d84f69 Mon Sep 17 00:00:00 2001 From: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:19:55 -0700 Subject: [PATCH 05/12] feat: support image attachments in prompts Pass ImageContent through the Tauri prompt command into OMP RPC. --- src-tauri/src/commands/mod.rs | 29 ++++++++++++++++++++++++++++- src-tauri/src/session/mod.rs | 6 +++++- ui/src/lib/tauri.ts | 9 ++++++++- ui/src/session/types.ts | 7 +++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3593666..7245bf7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -766,18 +766,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 } diff --git a/src-tauri/src/session/mod.rs b/src-tauri/src/session/mod.rs index 5efabae..61f1039 100644 --- a/src-tauri/src/session/mod.rs +++ b/src-tauri/src/session/mod.rs @@ -177,6 +177,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)); @@ -186,6 +187,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 } @@ -492,7 +496,7 @@ input.on("line", (line) => { .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/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index ffd8aba..b7c6e62 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -23,6 +23,7 @@ import type { RoleScratchpad, SessionInfo, ExtensionUiResponse, + PromptImage, } from "../session/types.ts"; const getInternals = (): { invoke?: unknown } | null => { @@ -131,11 +132,17 @@ export const api = { closePty: (sessionId: string) => invoke("close_pty", { sessionId }), - prompt: (sessionId: string, message: string, streamingBehavior?: string) => + prompt: ( + sessionId: string, + message: string, + streamingBehavior?: string, + images?: PromptImage[], + ) => invoke("prompt", { sessionId, message, streamingBehavior: streamingBehavior ?? null, + images: images && images.length > 0 ? images : null, }), abort: (sessionId: string) => invoke("abort", { sessionId }), diff --git a/ui/src/session/types.ts b/ui/src/session/types.ts index 353829b..2bb3885 100644 --- a/ui/src/session/types.ts +++ b/ui/src/session/types.ts @@ -295,3 +295,10 @@ export interface LaunchRecipe { prompt: string; openPanel?: "browser" | "companion" | "launch" | "plan" | "terminal"; } +/** OMP RPC ImageContent payload (base64, no data: prefix). */ +export interface PromptImage { + type: "image"; + data: string; + mimeType: string; + detail?: "auto" | "low" | "high" | "original"; +} From 9ca584bb4420a3e963220652b6e7f75d319d32b6 Mon Sep 17 00:00:00 2001 From: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:20:22 -0700 Subject: [PATCH 06/12] feat(ui): collapse goal header by default Keep the task goal compact until the user expands it. --- ui/src/app/task-progress-strip.tsx | 60 +++++++++++++++++++++++- ui/src/styles.css | 73 ++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/ui/src/app/task-progress-strip.tsx b/ui/src/app/task-progress-strip.tsx index a99c6d2..e7f4d49 100644 --- a/ui/src/app/task-progress-strip.tsx +++ b/ui/src/app/task-progress-strip.tsx @@ -4,6 +4,7 @@ import { useTaskProgress } from "./use-task-progress.ts"; export const TaskProgressStrip = () => { const progress = useTaskProgress(); + const [expanded, setExpanded] = useState(false); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); const inputRef = useRef(null); @@ -20,6 +21,7 @@ export const TaskProgressStrip = () => { const percent = Math.round(progress.display * 100); const className = [ "task-progress", + expanded ? "task-progress--expanded" : "task-progress--collapsed", progress.crawling ? "task-progress--crawling" : "", progress.allDone ? "task-progress--done" : "", progress.empty ? "task-progress--empty" : "", @@ -28,6 +30,7 @@ export const TaskProgressStrip = () => { .join(" "); const beginEdit = () => { + setExpanded(true); setDraft(progress.goalIsPlaceholder ? "" : progress.goal); setEditing(true); }; @@ -42,22 +45,75 @@ export const TaskProgressStrip = () => { setDraft(""); }; + if (!expanded) { + return ( + + ); + } + return (
- + {editing ? ( Date: Mon, 20 Jul 2026 21:20:24 -0700 Subject: [PATCH 07/12] feat(ui): inline approval dock above the composer Replace the full-screen approval modal with a focused in-chat dock. --- ui/src/app/extension-ui-dialog.tsx | 321 ++++++++++++++++------------- ui/src/styles.css | 244 ++++++++++++++++++++++ 2 files changed, 421 insertions(+), 144 deletions(-) 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 ( -
-