diff --git a/crates/rustyclaw-core/src/engines/joshua.rs b/crates/rustyclaw-core/src/engines/joshua.rs index 1750cb69..43e06a86 100644 --- a/crates/rustyclaw-core/src/engines/joshua.rs +++ b/crates/rustyclaw-core/src/engines/joshua.rs @@ -370,7 +370,13 @@ impl LocalEngine for JoshuaEngine { let detected_endpoint = port .map(|port| format!("http://127.0.0.1:{}", port)) .unwrap_or(endpoint); - let loaded = detected.len() as u32; + // Count only servers on this engine's own port — servers on + // other ports belong to other engines and must not inflate + // this engine's loaded-model count. + let loaded = detected + .iter() + .filter(|(_, port)| *port == Some(configured_port)) + .count() as u32; EngineRunStatus::Running { endpoint: detected_endpoint, loaded_models: loaded, diff --git a/crates/rustyclaw-core/src/engines/llamacpp.rs b/crates/rustyclaw-core/src/engines/llamacpp.rs index 45ee2ffc..67349a6d 100644 --- a/crates/rustyclaw-core/src/engines/llamacpp.rs +++ b/crates/rustyclaw-core/src/engines/llamacpp.rs @@ -152,7 +152,13 @@ impl LocalEngine for LlamaCppEngine { let detected_endpoint = port .map(|port| format!("http://127.0.0.1:{}", port)) .unwrap_or(endpoint); - let loaded = detected.len() as u32; + // Count only servers on this engine's own port — servers on + // other ports belong to other engines and must not inflate + // this engine's loaded-model count. + let loaded = detected + .iter() + .filter(|(_, port)| *port == Some(configured_port)) + .count() as u32; EngineRunStatus::Running { endpoint: detected_endpoint, loaded_models: loaded, diff --git a/crates/rustyclaw-tui/src/app/app.rs b/crates/rustyclaw-tui/src/app/app.rs index 0b6c8422..fa497e28 100644 --- a/crates/rustyclaw-tui/src/app/app.rs +++ b/crates/rustyclaw-tui/src/app/app.rs @@ -492,6 +492,7 @@ impl App { } Ok(UserInput::FetchModelCompletions { provider }) => { let base_url = config.model.as_ref().and_then(|m| m.base_url.clone()); + let engine_configs = config.engines.clone(); let api_key = rustyclaw_core::providers::secret_key_for_provider(&provider) .and_then(|key_name| { secrets_manager @@ -502,28 +503,28 @@ impl App { }); let gw_tx2 = gw_tx.clone(); tokio::spawn(async move { - match rustyclaw_core::providers::fetch_models( + let fetched = rustyclaw_core::engines::provider_models_with_local_fallback( &provider, api_key.as_deref(), base_url.as_deref(), + &engine_configs, ) - .await - { - Ok(models) => { + .await; + let rustyclaw_core::engines::ProviderModels { models, error, .. } = fetched; + match error { + None => { gw_tx2 .send(GwEvent::ModelCompletionsLoaded { provider, models }) .ignore(); } - Err(e) => { + Some(e) => { gw_tx2 .send(GwEvent::Warning { - summary: format!( - "Failed to load model completions: {:#}", - e - ), - details: Some( - rustyclaw_core::error_details::render_extended(&e), - ), + summary: format!("Failed to load model completions: {e}"), + // The error string (built with the + // cause chain upstream) goes into the + // expandable detail view. + details: Some(e), }) .ignore(); } @@ -822,15 +823,22 @@ impl App { ); let gw_tx2 = gw_tx.clone(); let base = config.model.as_ref().and_then(|m| m.base_url.clone()); + let engine_configs = config.engines.clone(); tokio::spawn(async move { - match rustyclaw_core::providers::fetch_models( + let fetched = rustyclaw_core::engines::provider_models_with_local_fallback( &pid, None, base.as_deref(), + &engine_configs, ) - .await - { - Ok(models) => { + .await; + let rustyclaw_core::engines::ProviderModels { + models, + error, + .. + } = fetched; + match error { + None => { gw_tx2 .send(GwEvent::ShowModelSelector { provider: pid, @@ -839,15 +847,13 @@ impl App { }) .ignore(); } - Err(e) => { - gw_tx2.send(GwEvent::Error { - summary: format!("Failed to fetch models: {:#}", e), - details: Some( - rustyclaw_core::error_details::render_extended( - &e, - ), - ), - }).ignore(); + Some(e) => { + gw_tx2 + .send(GwEvent::Error { + summary: format!("Failed to fetch models: {e}"), + details: None, + }) + .ignore(); } } }); @@ -906,15 +912,22 @@ impl App { let gw_tx2 = gw_tx.clone(); let base = config.model.as_ref().and_then(|m| m.base_url.clone()); + let engine_configs = config.engines.clone(); tokio::spawn(async move { - match rustyclaw_core::providers::fetch_models( - &pid, - key.as_deref(), - base.as_deref(), - ) - .await - { - Ok(models) => { + let fetched = rustyclaw_core::engines::provider_models_with_local_fallback( + &pid, + key.as_deref(), + base.as_deref(), + &engine_configs, +) +.await; + let rustyclaw_core::engines::ProviderModels { + models, + error, + .. + } = fetched; + match error { + None => { gw_tx2 .send(GwEvent::ShowModelSelector { provider: pid, @@ -923,11 +936,15 @@ impl App { }) .ignore(); } - Err(e) => { - gw_tx2.send(GwEvent::Error { - summary: format!("Failed to fetch models: {:#}", e), - details: Some(rustyclaw_core::error_details::render_extended(&e)), - }).ignore(); + Some(e) => { + gw_tx2 + .send(GwEvent::Error { + summary: format!( + "Failed to fetch models: {e}" + ), + details: None, + }) + .ignore(); } } }); @@ -995,15 +1012,22 @@ impl App { let gw_tx2 = gw_tx.clone(); let base = config.model.as_ref().and_then(|m| m.base_url.clone()); + let engine_configs = config.engines.clone(); tokio::spawn(async move { - match rustyclaw_core::providers::fetch_models( - &pid, - token.as_deref(), - base.as_deref(), - ) - .await - { - Ok(models) => { + let fetched = rustyclaw_core::engines::provider_models_with_local_fallback( + &pid, + token.as_deref(), + base.as_deref(), + &engine_configs, +) +.await; + let rustyclaw_core::engines::ProviderModels { + models, + error, + .. + } = fetched; + match error { + None => { gw_tx2 .send(GwEvent::ShowModelSelector { provider: pid, @@ -1012,11 +1036,15 @@ impl App { }) .ignore(); } - Err(e) => { - gw_tx2.send(GwEvent::Error { - summary: format!("Failed to fetch models: {:#}", e), - details: Some(rustyclaw_core::error_details::render_extended(&e)), - }).ignore(); + Some(e) => { + gw_tx2 + .send(GwEvent::Error { + summary: format!( + "Failed to fetch models: {e}" + ), + details: None, + }) + .ignore(); } } }); @@ -1184,15 +1212,18 @@ impl App { let gw_tx2 = gw_tx.clone(); let api_key = Some(key); let base = config.model.as_ref().and_then(|m| m.base_url.clone()); + let engine_configs = config.engines.clone(); tokio::spawn(async move { - match rustyclaw_core::providers::fetch_models( + let fetched = rustyclaw_core::engines::provider_models_with_local_fallback( &pid, api_key.as_deref(), base.as_deref(), + &engine_configs, ) - .await - { - Ok(models) => { + .await; + let rustyclaw_core::engines::ProviderModels { models, error, .. } = fetched; + match error { + None => { gw_tx2 .send(GwEvent::ShowModelSelector { provider: pid, @@ -1201,13 +1232,11 @@ impl App { }) .ignore(); } - Err(e) => { + Some(e) => { gw_tx2 .send(GwEvent::Error { - summary: format!("Failed to fetch models: {:#}", e), - details: Some( - rustyclaw_core::error_details::render_extended(&e), - ), + summary: format!("Failed to fetch models: {e}"), + details: None, }) .ignore(); } diff --git a/crates/rustyclaw-tui/src/app/command_action.rs b/crates/rustyclaw-tui/src/app/command_action.rs index 3acb1d3e..ce15d316 100644 --- a/crates/rustyclaw-tui/src/app/command_action.rs +++ b/crates/rustyclaw-tui/src/app/command_action.rs @@ -283,11 +283,13 @@ pub(super) async fn handle_command_action( }); let gw_tx2 = gw_tx.clone(); + let engine_configs = config.engines.clone(); tokio::spawn(async move { - match rustyclaw_core::providers::fetch_models_detailed( + match rustyclaw_core::engines::provider_models_detailed_with_local_fallback( &provider_id, api_key.as_deref(), base_url.as_deref(), + &engine_configs, ) .await { @@ -311,7 +313,8 @@ pub(super) async fn handle_command_action( .ignore(); } Err(e) => { - gw_tx2.send(GwEvent::error_from_err(&e)).ignore(); + let err = anyhow_tracing::Error::from(e); + gw_tx2.send(GwEvent::error_from_err(&err)).ignore(); } } }); diff --git a/crates/rustyclaw-tui/src/app/events.rs b/crates/rustyclaw-tui/src/app/events.rs index 31fc3b70..f7dedebd 100644 --- a/crates/rustyclaw-tui/src/app/events.rs +++ b/crates/rustyclaw-tui/src/app/events.rs @@ -349,6 +349,11 @@ pub(crate) enum GwEvent { EngineListResult { engines: Vec, }, + /// Full per-engine configuration, keyed by engine id (arrives right + /// after `EngineListResult`; patches the engine entries' configs). + EngineConfigList { + configs: std::collections::HashMap, + }, /// Engine model list result received. EngineModelListResult { engine: String, diff --git a/crates/rustyclaw-tui/src/app/tui_component/events.rs b/crates/rustyclaw-tui/src/app/tui_component/events.rs index 59c14062..e72471ad 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/events.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/events.rs @@ -369,6 +369,11 @@ pub(super) fn apply_gw_event( mut show_engines_dialog, mut engines_data, mut engines_cursor, + mut engines_params_edit, + mut engines_params_cursor, + mut engines_params_drafts, + mut engines_action_result, + mut engines_configs_received, mut show_cron_dialog, mut cron_data, mut show_memory_dialog, @@ -1629,9 +1634,24 @@ pub(super) fn apply_gw_event( } // ── Engines ────────────────────────────────────────────────────── GwEvent::ShowEngines => { + // A fresh open starts in normal mode with no stale drafts and + // no leftover Load/Unload result row. + if !show_engines_dialog.get() { + engines_params_edit.set(false); + engines_params_drafts.write().clear(); + engines_action_result.set(None); + } show_engines_dialog.set(true); } GwEvent::EngineListResult { engines } => { + // A fresh exchange: the EngineConfigList snapshot that patches + // the panel's configs follows this frame and has not arrived + // yet — until it does, saving parameters must stay disabled. + engines_configs_received.set(false); + // The configs came from the gateway; drafts seeded from an older + // snapshot are stale, so drop them (p re-seeds from the fresh + // config when the user next enters edit mode). + engines_params_drafts.write().clear(); let mut data = engines_data.read().clone().unwrap_or_default(); // Fill in host resources from the last HostInfo snapshot. if let Some(host) = host_info.read().as_ref() { @@ -1648,6 +1668,18 @@ pub(super) fn apply_gw_event( data.selected_engine = data.engines.get(cursor).map(|e| e.id.clone()); engines_data.set(Some(data)); } + GwEvent::EngineConfigList { configs } => { + // The real config snapshot is here: the panel entries are no + // longer placeholders, so saving parameters is safe again. + engines_configs_received.set(true); + let mut data = engines_data.read().clone().unwrap_or_default(); + for engine in &mut data.engines { + if let Some(cfg) = configs.get(&engine.id) { + engine.config = cfg.clone(); + } + } + engines_data.set(Some(data)); + } GwEvent::EngineModelListResult { engine, models } => { let mut data = engines_data.read().clone().unwrap_or_default(); data.selected_engine = Some(engine.clone()); @@ -1703,10 +1735,16 @@ pub(super) fn apply_gw_event( } GwEvent::EngineActionResult { engine, + model, ok, message, - .. } => { + // Keep the outcome of model actions (Load/Unload) for the + // dialog's inline feedback; lifecycle actions surface as + // notices instead. + if model.is_some() { + engines_action_result.set(Some((engine.clone(), ok, message.clone()))); + } // Record the terminal outcome on the engine's install panel (so // the dialog shows "install complete/failed"), and also surface a // one-line notice in the chat. Only finish an install that's diff --git a/crates/rustyclaw-tui/src/app/tui_component/keyboard.rs b/crates/rustyclaw-tui/src/app/tui_component/keyboard.rs index 282fb7e5..7b024d9c 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/keyboard.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/keyboard.rs @@ -162,6 +162,11 @@ pub(super) fn apply_key_event( show_engines_dialog: _, engines_data: _, engines_cursor: _, + engines_params_edit: _, + engines_params_cursor: _, + engines_params_drafts: _, + engines_action_result: _, + engines_configs_received: _, show_cron_dialog: _, cron_data: _, show_memory_dialog: _, diff --git a/crates/rustyclaw-tui/src/app/tui_component/keyboard_normal.rs b/crates/rustyclaw-tui/src/app/tui_component/keyboard_normal.rs index b2fec82b..7e25a903 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/keyboard_normal.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/keyboard_normal.rs @@ -177,6 +177,11 @@ pub(super) fn handle_normal_key( mut show_engines_dialog, mut engines_data, mut engines_cursor, + mut engines_params_edit, + mut engines_params_cursor, + mut engines_params_drafts, + mut engines_action_result, + mut engines_configs_received, mut show_cron_dialog, mut cron_data, mut show_memory_dialog, @@ -632,6 +637,142 @@ pub(super) fn handle_normal_key( .read() .as_ref() .and_then(|d| d.engines.get(engines_cursor.get()).cloned()); + + // ── Parameter-edit mode (p toggles) ───────────────────────── + if engines_params_edit.get() { + if let Some(engine) = selected_engine { + let fields = crate::components::engines_params::fields_for(&engine); + if !fields.is_empty() { + // The active engine can change under the editor: an + // EngineModelListResult for another engine moves + // `engines_cursor`, so the focused field index may now + // exceed this engine's field list. Clamp before use — + // indexing past the end would panic the terminal app. + let focused = engines_params_cursor.get().min(fields.len() - 1); + engines_params_cursor.set(focused); + match code { + KeyCode::Esc => { + // Discard the draft: without this, the dialog + // keeps showing the cancelled values (it renders + // the draft even outside edit mode). + engines_params_drafts.write().remove(&engine.id); + engines_params_edit.set(false); + } + // ←/→ (and Tab) move the focused field. + KeyCode::Left | KeyCode::Up => { + let cur = engines_params_cursor.get(); + engines_params_cursor.set(if cur == 0 { + fields.len() - 1 + } else { + cur - 1 + }); + } + KeyCode::Right | KeyCode::Down | KeyCode::Tab => { + let cur = engines_params_cursor.get(); + engines_params_cursor.set((cur + 1).min(fields.len() - 1)); + } + // +/- adjust; x clears back to default. + KeyCode::Char('+') | KeyCode::Char('=') => { + let field = fields[focused]; + // Only cycle through the edited engine's own + // model list: the panel's `models` belongs to + // whatever engine was last inspected, and + // assigning another engine's model to this one + // would configure it to serve a name it lacks. + let models: Vec = engines_data + .read() + .as_ref() + .filter(|d| { + d.selected_engine.as_deref() == Some(engine.id.as_str()) + }) + .map(|d| d.models.iter().map(|m| m.name.clone()).collect()) + .unwrap_or_default(); + let mut drafts = engines_params_drafts.write(); + let draft = drafts + .entry(engine.id.clone()) + .or_insert_with(|| engine.config.clone()); + crate::components::engines_params::adjust(&field, draft, 1, &models); + } + KeyCode::Char('-') | KeyCode::Char('_') => { + let field = fields[focused]; + let models: Vec = engines_data + .read() + .as_ref() + .filter(|d| { + d.selected_engine.as_deref() == Some(engine.id.as_str()) + }) + .map(|d| d.models.iter().map(|m| m.name.clone()).collect()) + .unwrap_or_default(); + let mut drafts = engines_params_drafts.write(); + let draft = drafts + .entry(engine.id.clone()) + .or_insert_with(|| engine.config.clone()); + crate::components::engines_params::adjust(&field, draft, -1, &models); + } + KeyCode::Char('x') => { + let field = fields[focused]; + let mut drafts = engines_params_drafts.write(); + let draft = drafts + .entry(engine.id.clone()) + .or_insert_with(|| engine.config.clone()); + crate::components::engines_params::clear(&field, draft); + } + KeyCode::Enter => { + if !engines_configs_received.get() { + // The EngineConfigList snapshot has not + // arrived: the base config here is a + // placeholder, so saving would overwrite the + // engine's real endpoint/port/models_dir/ + // extra_args with blanks. Refuse, keep the + // draft, and say why. + engines_action_result.set(Some(( + engine.id.clone(), + false, + "Engine settings not loaded from the gateway yet; \ + saving disabled (Esc to cancel)" + .into(), + ))); + return; + } + // Save the draft and refresh engine + model lists. + let config = engines_params_drafts + .read() + .get(&engine.id) + .cloned() + .unwrap_or_else(|| engine.config.clone()); + engines_params_drafts.write().remove(&engine.id); + engines_params_edit.set(false); + // Keep the client's copy of the config in sync + // with what the gateway just persisted: a later + // edit seeds its draft from `engine.config`, so a + // stale copy would let an unrelated save silently + // revert this one. (The EngineRefresh round-trip + // below re-confirms it from the gateway.) + let mut data = engines_data.read().clone().unwrap_or_default(); + if let Some(e) = data.engines.iter_mut().find(|e| e.id == engine.id) { + e.config = config.clone(); + } + engines_data.set(Some(data)); + send_input(UserInput::MessengerCommand( + rustyclaw_core::gateway::client_types::GatewayCommand::EngineConfigSet { + engine: engine.id.clone(), + config, + }, + )); + send_input(UserInput::EngineRefresh); + send_input(UserInput::EngineSelect(engine.id)); + } + _ => {} + } + } else { + engines_params_edit.set(false); + } + } else { + engines_params_edit.set(false); + } + return; + } + match code { KeyCode::Esc => { show_engines_dialog.set(false); @@ -659,6 +800,20 @@ pub(super) fn handle_normal_key( send_input(UserInput::EngineSelect(engine.id)); } } + KeyCode::Char('p') => { + // Enter parameter-edit mode for the active engine, seeded + // from the config the gateway last reported. + if let Some(engine) = selected_engine { + let fields = crate::components::engines_params::fields_for(&engine); + if !fields.is_empty() { + engines_params_drafts + .write() + .insert(engine.id.clone(), engine.config.clone()); + engines_params_cursor.set(0); + engines_params_edit.set(true); + } + } + } KeyCode::Char('s') => { if let Some(engine) = selected_engine { let action = if engine.running { diff --git a/crates/rustyclaw-tui/src/app/tui_component/mod.rs b/crates/rustyclaw-tui/src/app/tui_component/mod.rs index e3d9646c..7b8becad 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/mod.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/mod.rs @@ -261,6 +261,13 @@ pub fn TuiRoot(props: &TuiRootProps, mut hooks: Hooks) -> impl Into> = hooks.use_state(|| None); let engines_cursor = hooks.use_state(|| 0usize); + let engines_params_edit = hooks.use_state(|| false); + let engines_params_cursor = hooks.use_state(|| 0usize); + let engines_params_drafts: State< + std::collections::HashMap, + > = hooks.use_state(std::collections::HashMap::new); + let engines_action_result: State> = hooks.use_state(|| None); + let engines_configs_received = hooks.use_state(|| false); let show_cron_dialog = hooks.use_state(|| false); let cron_data: State> = hooks.use_state(|| None); let show_memory_dialog = hooks.use_state(|| false); @@ -433,6 +440,11 @@ pub fn TuiRoot(props: &TuiRootProps, mut hooks: Hooks) -> impl Into impl Into, pub engines_data: State>, pub engines_cursor: State, + /// Parameter-edit mode for the engines dialog (p toggles). + pub engines_params_edit: State, + /// Focused field index within the active engine's parameter list. + pub engines_params_cursor: State, + /// In-progress parameter edits per engine id, seeded from the config the + /// gateway last reported; Enter saves the active engine's draft. + pub engines_params_drafts: + State>, + /// Outcome of the last engine model action (engine, ok, message), shown + /// in the engines dialog so Load/Unload always answer visibly. + pub engines_action_result: State>, + /// Whether the gateway's `EngineConfigList` snapshot has arrived for the + /// current connection. Until it does, the panel's engine configs are + /// placeholders, so saving parameters would overwrite the real + /// endpoint/port/models_dir/extra_args with blanks. + pub engines_configs_received: State, pub show_cron_dialog: State, pub cron_data: State>, pub show_memory_dialog: State, diff --git a/crates/rustyclaw-tui/src/components/engines_dialog.rs b/crates/rustyclaw-tui/src/components/engines_dialog.rs index 314becb3..a8f46a3f 100644 --- a/crates/rustyclaw-tui/src/components/engines_dialog.rs +++ b/crates/rustyclaw-tui/src/components/engines_dialog.rs @@ -4,20 +4,34 @@ // every detected engine (active one highlighted), and the body shows the // active engine's status, models, live install output, and pull progress. // ←/→ (or Tab) switches engines. +// +// The body also renders the engine's parameter fields (context window, +// device, huge pages, …). Pressing p enters parameter-edit mode: ←/→ cycles +// the focused field, +/- adjusts it (x clears it back to default), Enter +// saves the draft via EngineConfigSet, Esc discards it. + +use std::collections::HashMap; +use crate::components::engines_params; use crate::theme; use iocraft::prelude::*; -#[allow(dead_code)] #[derive(Default, Props)] pub struct EnginesDialogProps { pub data: Option, + pub params_edit: bool, + pub params_cursor: usize, + pub params_drafts: HashMap, + /// Outcome of the last engine model action (engine, ok, message). + pub action_result: Option<(String, bool, String)>, } /// One label/value detail row in the active engine's body. struct Row { label: String, value: String, + /// Highlight the row (the focused parameter field in edit mode). + highlighted: bool, } impl Row { @@ -25,8 +39,14 @@ impl Row { Self { label: label.into(), value: value.into(), + highlighted: false, } } + + fn highlight(mut self) -> Self { + self.highlighted = true; + self + } } #[component] @@ -101,6 +121,38 @@ pub fn EnginesDialog(props: &EnginesDialogProps) -> impl Into = data.models.iter().map(|m| m.name.clone()).collect(); + rows.push(Row::new( + "Parameters", + if props.params_edit { + "(edit mode \u{2014} see footer)" + } else { + "p to edit" + } + .to_string(), + )); + for (i, field) in fields.iter().enumerate() { + let focused = props.params_edit && i == props.params_cursor; + let marker = if focused { "\u{2192}" } else { " " }; + let mut row = Row::new( + format!(" {marker} {}", field.label), + engines_params::field_value(field, draft, &models), + ); + if focused { + row = row.highlight(); + } + rows.push(row); + } + } + // Models for the active (selected) engine. if data.selected_engine.as_deref() == Some(engine.id.as_str()) { rows.push(Row::new("", "")); @@ -123,6 +175,18 @@ pub fn EnginesDialog(props: &EnginesDialogProps) -> impl Into impl Into impl Into Vec { + let mut fields = Vec::new(); + if engine.supports_context_length() { + fields.push(CONTEXT_LENGTH); + } + if engine.supports_joshua_parameters() { + fields.push(DEVICE); + fields.push(HUGE_PAGES); + fields.push(MMAP); + fields.push(LAZY_WEIGHTS); + fields.push(MAX_OUTPUT_TOKENS); + fields.push(MAX_CONCURRENCY); + } + if engine.supports_default_model() { + fields.push(DEFAULT_MODEL); + } + if engine.can("start") { + fields.push(AUTO_START); + } + fields +} + +/// Render a field's current value for the dialog row. +pub fn field_value(field: &ParamField, cfg: &EngineConfig, models: &[String]) -> String { + let value = match field.key { + "context_length" => cfg + .context_length + .map(|v| v.to_string()) + .unwrap_or_else(|| "default".into()), + "device" => cfg + .device + .clone() + .unwrap_or_else(|| "default (auto)".into()), + "huge_pages" => cfg + .huge_pages + .clone() + .unwrap_or_else(|| "default (off)".into()), + "mmap" => on_off(cfg.mmap), + "lazy_weights" => on_off(cfg.lazy_weights), + "max_output_tokens" => cfg + .max_output_tokens + .map(|v| v.to_string()) + .unwrap_or_else(|| "default (4096)".into()), + "max_concurrency" => cfg + .max_concurrency + .map(|v| v.to_string()) + .unwrap_or_else(|| "default (CPU count)".into()), + "default_model" => cfg.default_model.clone().unwrap_or_else(|| "none".into()), + "auto_start" => on_off(cfg.auto_start), + _ => String::new(), + }; + // `default_model` cycles through the loaded model names; point at the + // + key when it is unset and models exist. + if field.key == "default_model" && cfg.default_model.is_none() && !models.is_empty() { + format!("{value} (+ to pick)") + } else { + value + } +} + +fn on_off(v: bool) -> String { + if v { "on".into() } else { "off".into() } +} + +/// Adjust a field by `delta` (+1 / -1 keypresses). Numeric fields step by +/// `field.step`; selects cycle through their options (None included); toggles +/// set on (+) or off (-); `default_model` cycles through `models` (None +/// included, and a no-op while the model list is empty so the saved choice +/// is never erased by a pick with nothing to pick). +pub fn adjust(field: &ParamField, cfg: &mut EngineConfig, delta: i32, models: &[String]) { + match field.key { + "context_length" => adjust_num(&mut cfg.context_length, delta, field.step), + "device" => cfg.device = cycle(&cfg.device, &["auto", "cpu", "metal", "cuda"], delta), + "huge_pages" => { + cfg.huge_pages = cycle( + &cfg.huge_pages, + &["off", "transparent", "2mb", "1gb", "huge"], + delta, + ); + } + "mmap" => bool_adjust(&mut cfg.mmap, delta), + "lazy_weights" => bool_adjust(&mut cfg.lazy_weights, delta), + "max_output_tokens" => adjust_num(&mut cfg.max_output_tokens, delta, field.step), + "max_concurrency" => adjust_num(&mut cfg.max_concurrency, delta, field.step), + "default_model" => { + // With no model list there is nothing to pick from: cycling + // would land on the None-only option and silently erase the + // saved choice, so changing the key is a no-op instead. + if models.is_empty() { + return; + } + let mut options: Vec> = vec![None]; + options.extend(models.iter().cloned().map(Some)); + let idx = options + .iter() + .position(|o| *o == cfg.default_model) + .unwrap_or(0); + let next = (idx as i32 + delta).rem_euclid(options.len() as i32) as usize; + cfg.default_model = options[next].clone(); + } + "auto_start" => bool_adjust(&mut cfg.auto_start, delta), + _ => {} + } +} + +/// Clear a field back to its default (None / off). +pub fn clear(field: &ParamField, cfg: &mut EngineConfig) { + match field.key { + "context_length" => cfg.context_length = None, + "device" => cfg.device = None, + "huge_pages" => cfg.huge_pages = None, + "mmap" => cfg.mmap = false, + "lazy_weights" => cfg.lazy_weights = false, + "max_output_tokens" => cfg.max_output_tokens = None, + "max_concurrency" => cfg.max_concurrency = None, + "default_model" => cfg.default_model = None, + "auto_start" => cfg.auto_start = false, + _ => {} + } +} + +fn adjust_num(slot: &mut Option, delta: i32, step: u32) { + // First +/- from "default" lands on a sensible base. + let base = match step { + 256 => 4096, + 128 => 4096, + _ => 1, + }; + match slot { + Some(current) => { + let next = if delta > 0 { + current.saturating_add(step) + } else { + current.saturating_sub(step).max(step.min(*current)) + }; + *slot = Some(next); + } + None => *slot = Some(base), + } +} + +fn bool_adjust(slot: &mut bool, delta: i32) { + *slot = delta > 0; +} + +fn cycle(current: &Option, options: &[&str], delta: i32) -> Option { + // Index 0 is "unset"; the remaining slots are the options in order. + let idx = match current { + Some(v) => options + .iter() + .position(|o| o == v) + .map(|i| i + 1) + .unwrap_or(0), + None => 0, + }; + let total = options.len() + 1; + let next = (idx as i32 + delta).rem_euclid(total as i32) as usize; + if next == 0 { + None + } else { + Some(options[next - 1].to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn engine(id: &str, can_start: bool) -> LocalEngineData { + LocalEngineData { + id: id.into(), + display_name: id.into(), + installed: false, + running: false, + version: None, + endpoint: None, + available_models: 0, + loaded_models: 0, + caps: rustyclaw_view::EngineCapsData { + can_start, + ..Default::default() + }, + config: EngineConfig::default(), + } + } + + #[test] + fn fields_are_engine_specific() { + let joshua = engine("joshua", true); + let keys: Vec<&str> = fields_for(&joshua).iter().map(|f| f.key).collect(); + assert_eq!( + keys, + vec![ + "context_length", + "device", + "huge_pages", + "mmap", + "lazy_weights", + "max_output_tokens", + "max_concurrency", + "default_model", + "auto_start", + ] + ); + + let ollama = engine("ollama", true); + let keys: Vec<&str> = fields_for(&ollama).iter().map(|f| f.key).collect(); + assert_eq!(keys, vec!["context_length", "auto_start"]); + + let lmstudio = engine("lmstudio", false); + assert!(fields_for(&lmstudio).is_empty()); + } + + #[test] + fn numeric_adjust_steps_from_default() { + let mut cfg = EngineConfig::default(); + adjust(&CONTEXT_LENGTH, &mut cfg, 1, &[]); + assert_eq!(cfg.context_length, Some(4096)); + adjust(&CONTEXT_LENGTH, &mut cfg, 1, &[]); + assert_eq!(cfg.context_length, Some(4352)); + adjust(&CONTEXT_LENGTH, &mut cfg, -1, &[]); + assert_eq!(cfg.context_length, Some(4096)); + } + + #[test] + fn selects_cycle_including_unset() { + let mut cfg = EngineConfig::default(); + adjust(&DEVICE, &mut cfg, 1, &[]); + assert_eq!(cfg.device.as_deref(), Some("auto")); + adjust(&DEVICE, &mut cfg, 1, &[]); + assert_eq!(cfg.device.as_deref(), Some("cpu")); + // Back past the start wraps to the last option. + adjust(&DEVICE, &mut cfg, -4, &[]); + assert_eq!(cfg.device.as_deref(), Some("metal")); + adjust(&DEVICE, &mut cfg, 1, &[]); + assert_eq!(cfg.device.as_deref(), Some("cuda")); + adjust(&DEVICE, &mut cfg, 1, &[]); + assert_eq!(cfg.device, None); + } + + #[test] + fn default_model_cycles_through_local_models() { + let models = vec!["a.gguf".to_string(), "b.gguf".to_string()]; + let mut cfg = EngineConfig::default(); + adjust(&DEFAULT_MODEL, &mut cfg, 1, &models); + assert_eq!(cfg.default_model.as_deref(), Some("a.gguf")); + adjust(&DEFAULT_MODEL, &mut cfg, 1, &models); + assert_eq!(cfg.default_model.as_deref(), Some("b.gguf")); + adjust(&DEFAULT_MODEL, &mut cfg, 1, &models); + assert_eq!(cfg.default_model, None); + // -1 from unset wraps to the last model. + adjust(&DEFAULT_MODEL, &mut cfg, -1, &models); + assert_eq!(cfg.default_model.as_deref(), Some("b.gguf")); + } + + #[test] + fn default_model_is_a_noop_with_an_empty_model_list() { + // With nothing to pick, +/- must not erase the saved choice (the + // cycle would otherwise land on the None-only option). + let mut cfg = EngineConfig { + default_model: Some("a.gguf".into()), + ..Default::default() + }; + adjust(&DEFAULT_MODEL, &mut cfg, 1, &[]); + assert_eq!(cfg.default_model.as_deref(), Some("a.gguf")); + adjust(&DEFAULT_MODEL, &mut cfg, -1, &[]); + assert_eq!(cfg.default_model.as_deref(), Some("a.gguf")); + } + + #[test] + fn clear_resets_to_defaults() { + let mut cfg = EngineConfig { + context_length: Some(8192), + mmap: true, + device: Some("cuda".into()), + ..Default::default() + }; + clear(&CONTEXT_LENGTH, &mut cfg); + clear(&MMAP, &mut cfg); + clear(&DEVICE, &mut cfg); + assert_eq!(cfg.context_length, None); + assert!(!cfg.mmap); + assert_eq!(cfg.device, None); + } +} diff --git a/crates/rustyclaw-tui/src/components/mod.rs b/crates/rustyclaw-tui/src/components/mod.rs index 56a1c988..b2bef484 100644 --- a/crates/rustyclaw-tui/src/components/mod.rs +++ b/crates/rustyclaw-tui/src/components/mod.rs @@ -11,6 +11,7 @@ pub mod details_dialog; pub mod device_flow_dialog; pub mod downloads_dialog; pub mod engines_dialog; +pub mod engines_params; pub mod hatching_dialog; pub mod input_bar; pub mod logs_dialog; diff --git a/crates/rustyclaw-tui/src/components/root.rs b/crates/rustyclaw-tui/src/components/root.rs index 3a33f933..379ef1b0 100644 --- a/crates/rustyclaw-tui/src/components/root.rs +++ b/crates/rustyclaw-tui/src/components/root.rs @@ -189,6 +189,15 @@ pub struct RootProps { // engines dialog overlay (/engines) pub show_engines_dialog: bool, pub engines_data: Option, + /// Parameter-edit mode for the engines dialog (p toggles). + pub engines_params_edit: bool, + /// Focused field index within the active engine's parameter list. + pub engines_params_cursor: usize, + /// In-progress parameter edits per engine id. + pub engines_params_drafts: + std::collections::HashMap, + /// Outcome of the last engine model action (engine, ok, message). + pub engines_action_result: Option<(String, bool, String)>, // gateway panel overlays (/cron, /memory, /mcp, /channels) pub show_cron_dialog: bool, @@ -287,6 +296,10 @@ pub fn Root(props: &mut RootProps) -> impl Into> { // Engines dialog state let show_engines = props.show_engines_dialog; let engines = props.engines_data.clone(); + let engines_params_edit = props.engines_params_edit; + let engines_params_cursor = props.engines_params_cursor; + let engines_params_drafts = props.engines_params_drafts.clone(); + let engines_action_result = props.engines_action_result.clone(); // Gateway panel dialog state let show_cron = props.show_cron_dialog; @@ -774,6 +787,10 @@ pub fn Root(props: &mut RootProps) -> impl Into> { ) { EnginesDialog( data: engines, + params_edit: engines_params_edit, + params_cursor: engines_params_cursor, + params_drafts: engines_params_drafts, + action_result: engines_action_result, ) } }.into_any() diff --git a/crates/rustyclaw-tui/src/gateway_client.rs b/crates/rustyclaw-tui/src/gateway_client.rs index 3b147e2e..03d24143 100644 --- a/crates/rustyclaw-tui/src/gateway_client.rs +++ b/crates/rustyclaw-tui/src/gateway_client.rs @@ -444,9 +444,10 @@ pub(crate) fn gateway_event_to_gw_event( // (`CommandAction::FetchModels`), so gateway-fetched lists are not // surfaced here — they exist for remote clients like the desktop app. E::ProviderModelListResult { .. } => return None, - // The enriched payloads (loaded markers, full engine configs) ride - // in their own frames; the TUI consumes them where it renders them. - E::ProviderModelLoadedList { .. } | E::EngineConfigList { .. } => return None, + // Loaded markers aren't surfaced by the TUI (it fetches provider + // models beside the vault), but the engine configs patch the panel. + E::ProviderModelLoadedList { .. } => return None, + E::EngineConfigList { configs } => GwEvent::EngineConfigList { configs }, E::EngineModelListResult { engine, models } => GwEvent::EngineModelListResult { engine: engine.clone(), models: models