From a2f4bec52e738c31706426905b3318861d43252b Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Mon, 17 Aug 2026 23:28:04 -0700 Subject: [PATCH 1/5] fix(tui): address Devin review findings - Esc in parameter-edit mode now discards the draft for the active engine, so cancelling an edit no longer leaves the dialog showing the cancelled values (it rendered the draft even outside edit mode). - Engine configs arrive in their own EngineConfigList frame (protocol change on the backend): the TUI surfaces it as a GwEvent and patches the engines panel's entries, instead of expecting the config inside EngineInfoDto. - Formatting fix for a wrapped line in the desktop handler (parent branch). --- crates/rustyclaw-tui/src/app/app.rs | 146 ++++---- .../rustyclaw-tui/src/app/command_action.rs | 7 +- crates/rustyclaw-tui/src/app/events.rs | 5 + .../src/app/tui_component/events.rs | 30 +- .../src/app/tui_component/keyboard.rs | 4 + .../src/app/tui_component/keyboard_normal.rs | 109 ++++++ .../src/app/tui_component/mod.rs | 14 + .../src/app/tui_component/state.rs | 11 + .../src/components/engines_dialog.rs | 115 +++++- .../src/components/engines_params.rs | 333 ++++++++++++++++++ crates/rustyclaw-tui/src/components/mod.rs | 1 + crates/rustyclaw-tui/src/components/root.rs | 17 + crates/rustyclaw-tui/src/gateway_client.rs | 7 +- 13 files changed, 720 insertions(+), 79 deletions(-) create mode 100644 crates/rustyclaw-tui/src/components/engines_params.rs diff --git a/crates/rustyclaw-tui/src/app/app.rs b/crates/rustyclaw-tui/src/app/app.rs index 0b6c8422..efa537f7 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,25 @@ 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}"), + details: None, }) .ignore(); } @@ -822,15 +820,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 +844,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 +909,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 +933,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 +1009,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 +1033,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 +1209,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 +1229,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..221d5eca 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/events.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/events.rs @@ -369,6 +369,10 @@ 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 show_cron_dialog, mut cron_data, mut show_memory_dialog, @@ -1629,9 +1633,18 @@ pub(super) fn apply_gw_event( } // ── Engines ────────────────────────────────────────────────────── GwEvent::ShowEngines => { + // A fresh open starts in normal mode with no stale drafts. + if !show_engines_dialog.get() { + engines_params_edit.set(false); + engines_params_drafts.write().clear(); + } show_engines_dialog.set(true); } GwEvent::EngineListResult { engines } => { + // 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 +1661,15 @@ 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 } => { + 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 +1725,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..41285868 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/keyboard.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/keyboard.rs @@ -162,6 +162,10 @@ 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: _, 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..28b7b316 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,10 @@ 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, + engines_action_result: _, mut show_cron_dialog, mut cron_data, mut show_memory_dialog, @@ -632,6 +636,97 @@ 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() { + 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[engines_params_cursor.get()]; + let models: Vec = engines_data + .read() + .as_ref() + .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[engines_params_cursor.get()]; + let models: Vec = engines_data + .read() + .as_ref() + .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[engines_params_cursor.get()]; + 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 => { + // 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); + 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 +754,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..5c0e0296 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/mod.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/mod.rs @@ -261,6 +261,12 @@ 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 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 +439,10 @@ 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>, 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). +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" => { + 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 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 From 765edbaf5e462df59063a62f866ed94557a3afa4 Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Mon, 17 Aug 2026 23:37:35 -0700 Subject: [PATCH 2/5] fix(tui): address second-round Devin review findings - Parameter-edit mode clamps the focused-field index against the active engine's field list before every use, so the editor can no longer panic when a model-list reply for another engine moves the selection under it. - Opening the engines dialog clears the previous Load/Unload result row, so a stale message no longer lingers after a close/reopen. - The public engine-parameter field constants gain the doc comments the style guide requires. --- .../rustyclaw-tui/src/app/tui_component/events.rs | 4 +++- .../src/app/tui_component/keyboard_normal.rs | 13 ++++++++++--- .../rustyclaw-tui/src/components/engines_params.rs | 11 +++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/rustyclaw-tui/src/app/tui_component/events.rs b/crates/rustyclaw-tui/src/app/tui_component/events.rs index 221d5eca..38785773 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/events.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/events.rs @@ -1633,10 +1633,12 @@ pub(super) fn apply_gw_event( } // ── Engines ────────────────────────────────────────────────────── GwEvent::ShowEngines => { - // A fresh open starts in normal mode with no stale drafts. + // 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); } 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 28b7b316..e716b67a 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/keyboard_normal.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/keyboard_normal.rs @@ -642,6 +642,13 @@ pub(super) fn handle_normal_key( 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 @@ -665,7 +672,7 @@ pub(super) fn handle_normal_key( } // +/- adjust; x clears back to default. KeyCode::Char('+') | KeyCode::Char('=') => { - let field = fields[engines_params_cursor.get()]; + let field = fields[focused]; let models: Vec = engines_data .read() .as_ref() @@ -678,7 +685,7 @@ pub(super) fn handle_normal_key( crate::components::engines_params::adjust(&field, draft, 1, &models); } KeyCode::Char('-') | KeyCode::Char('_') => { - let field = fields[engines_params_cursor.get()]; + let field = fields[focused]; let models: Vec = engines_data .read() .as_ref() @@ -691,7 +698,7 @@ pub(super) fn handle_normal_key( crate::components::engines_params::adjust(&field, draft, -1, &models); } KeyCode::Char('x') => { - let field = fields[engines_params_cursor.get()]; + let field = fields[focused]; let mut drafts = engines_params_drafts.write(); let draft = drafts .entry(engine.id.clone()) diff --git a/crates/rustyclaw-tui/src/components/engines_params.rs b/crates/rustyclaw-tui/src/components/engines_params.rs index f70a36bf..40accb73 100644 --- a/crates/rustyclaw-tui/src/components/engines_params.rs +++ b/crates/rustyclaw-tui/src/components/engines_params.rs @@ -20,46 +20,57 @@ pub struct ParamField { pub step: u32, } +/// Context window in tokens (engine flag: `--n-ctx` / `--ctx-size` / +/// `--num-ctx`). First +/- from unset lands on 4096; steps by 256. pub const CONTEXT_LENGTH: ParamField = ParamField { key: "context_length", label: "Context window", step: 256, }; +/// Compute backend (`--device`): auto, cpu, metal, cuda. pub const DEVICE: ParamField = ParamField { key: "device", label: "Device", step: 1, }; +/// Huge-page strategy (`--huge-pages`): off, transparent, 2mb, 1gb, huge. pub const HUGE_PAGES: ParamField = ParamField { key: "huge_pages", label: "Huge pages", step: 1, }; +/// Require the model file to be memory-mappable (`--mmap`). pub const MMAP: ParamField = ParamField { key: "mmap", label: "Mmap", step: 1, }; +/// Optimise mapping for a model far larger than RAM (`--lazy-weights`). pub const LAZY_WEIGHTS: ParamField = ParamField { key: "lazy_weights", label: "Lazy weights", step: 1, }; +/// Cap on generated tokens per request (`--max-output-tokens`). First +/// +/- from unset lands on 4096; steps by 128. pub const MAX_OUTPUT_TOKENS: ParamField = ParamField { key: "max_output_tokens", label: "Max output tokens", step: 128, }; +/// Max concurrent generations (`--max-concurrency`). Steps by 1. pub const MAX_CONCURRENCY: ParamField = ParamField { key: "max_concurrency", label: "Max concurrency", step: 1, }; +/// The model served at startup; cycles through the local model list. pub const DEFAULT_MODEL: ParamField = ParamField { key: "default_model", label: "Default model", step: 1, }; +/// Start the engine with the gateway. pub const AUTO_START: ParamField = ParamField { key: "auto_start", label: "Auto-start", From 36cedea7dd9e771f74f35b63dd544b48a3fa6b3f Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Tue, 18 Aug 2026 00:10:37 -0700 Subject: [PATCH 3/5] fix(tui): address third-round Devin review findings - Changing the startup model (+/-) with an empty local model list no longer erases the saved choice: with nothing to pick from, the cycle previously landed on its None-only option and blanked default_model. It is now a no-op while the model list is empty. --- .../src/components/engines_params.rs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/rustyclaw-tui/src/components/engines_params.rs b/crates/rustyclaw-tui/src/components/engines_params.rs index 40accb73..d2cd7824 100644 --- a/crates/rustyclaw-tui/src/components/engines_params.rs +++ b/crates/rustyclaw-tui/src/components/engines_params.rs @@ -145,7 +145,8 @@ fn on_off(v: bool) -> String { /// 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). +/// 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), @@ -162,6 +163,12 @@ pub fn adjust(field: &ParamField, cfg: &mut EngineConfig, delta: i32, models: &[ "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 @@ -326,6 +333,20 @@ mod tests { 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 { From 559720ab3446f3be1a92f4558f9a3dfb1149576c Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Tue, 18 Aug 2026 02:08:44 -0700 Subject: [PATCH 4/5] fix(tui): address fifth-round Devin review findings - The engines panel's loaded-model count no longer sums every server on the host: status() counts only servers on the engine's own configured port (joshua + llama.cpp), so an unrelated server elsewhere cannot inflate the count for an engine serving one model. - Saving engine parameters from the terminal is gated on the EngineConfigList snapshot (new engines_configs_received state, reset on EngineListResult): until the real configs arrive, Enter refuses to save with an inline explanation and keeps the draft, instead of writing the placeholder config over the engine's stored endpoint/port/models_dir/ extra_args. - The default-model picker only cycles through the edited engine's own model list (the panel's models belong to whatever engine was last inspected), so a saved default can no longer be pointed at another engine's model. --- crates/rustyclaw-core/src/engines/joshua.rs | 8 ++++- crates/rustyclaw-core/src/engines/llamacpp.rs | 8 ++++- .../src/app/tui_component/events.rs | 8 +++++ .../src/app/tui_component/keyboard.rs | 1 + .../src/app/tui_component/keyboard_normal.rs | 30 ++++++++++++++++++- .../src/app/tui_component/mod.rs | 2 ++ .../src/app/tui_component/state.rs | 5 ++++ 7 files changed, 59 insertions(+), 3 deletions(-) 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/tui_component/events.rs b/crates/rustyclaw-tui/src/app/tui_component/events.rs index 38785773..e72471ad 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/events.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/events.rs @@ -373,6 +373,7 @@ pub(super) fn apply_gw_event( 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, @@ -1643,6 +1644,10 @@ pub(super) fn apply_gw_event( 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). @@ -1664,6 +1669,9 @@ pub(super) fn apply_gw_event( 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) { diff --git a/crates/rustyclaw-tui/src/app/tui_component/keyboard.rs b/crates/rustyclaw-tui/src/app/tui_component/keyboard.rs index 41285868..7b024d9c 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/keyboard.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/keyboard.rs @@ -166,6 +166,7 @@ pub(super) fn apply_key_event( 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 e716b67a..2a153962 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/keyboard_normal.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/keyboard_normal.rs @@ -180,7 +180,8 @@ pub(super) fn handle_normal_key( mut engines_params_edit, mut engines_params_cursor, mut engines_params_drafts, - engines_action_result: _, + mut engines_action_result, + mut engines_configs_received, mut show_cron_dialog, mut cron_data, mut show_memory_dialog, @@ -673,9 +674,17 @@ pub(super) fn handle_normal_key( // +/- 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(); @@ -689,6 +698,9 @@ pub(super) fn handle_normal_key( 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(); @@ -706,6 +718,22 @@ pub(super) fn handle_normal_key( 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() diff --git a/crates/rustyclaw-tui/src/app/tui_component/mod.rs b/crates/rustyclaw-tui/src/app/tui_component/mod.rs index 5c0e0296..7b8becad 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/mod.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/mod.rs @@ -267,6 +267,7 @@ pub fn TuiRoot(props: &TuiRootProps, mut hooks: Hooks) -> impl Into, > = 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); @@ -443,6 +444,7 @@ pub fn TuiRoot(props: &TuiRootProps, mut hooks: Hooks) -> impl Into>, + /// 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, From c216b6cc23202d686038f555507518959a2cc344 Mon Sep 17 00:00:00 2001 From: Erica Stith Date: Tue, 18 Aug 2026 02:37:55 -0700 Subject: [PATCH 5/5] fix(tui): address sixth-round Devin review findings - Restore the expandable error detail for provider model-list failures: the error string (with its cause chain) is attached as the Warning's details again instead of dropping it, so the detail popup works. - The engines panel now updates its local copy of an engine's config when parameters are saved, so a later unrelated setting change can no longer silently revert the earlier save (the EngineRefresh round-trip re-confirms it from the gateway). --- crates/rustyclaw-tui/src/app/app.rs | 5 ++++- .../src/app/tui_component/keyboard_normal.rs | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/rustyclaw-tui/src/app/app.rs b/crates/rustyclaw-tui/src/app/app.rs index efa537f7..fa497e28 100644 --- a/crates/rustyclaw-tui/src/app/app.rs +++ b/crates/rustyclaw-tui/src/app/app.rs @@ -521,7 +521,10 @@ impl App { gw_tx2 .send(GwEvent::Warning { summary: format!("Failed to load model completions: {e}"), - details: None, + // The error string (built with the + // cause chain upstream) goes into the + // expandable detail view. + details: Some(e), }) .ignore(); } 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 2a153962..7e25a903 100644 --- a/crates/rustyclaw-tui/src/app/tui_component/keyboard_normal.rs +++ b/crates/rustyclaw-tui/src/app/tui_component/keyboard_normal.rs @@ -742,6 +742,17 @@ pub(super) fn handle_normal_key( .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(),