From 64e0d7d5c7a0c40c60226c12b94c691d2de56d81 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Tue, 7 Jul 2026 17:37:56 +0100 Subject: [PATCH 01/12] feat(tui): add config key editing to create provider form Signed-off-by: Artem Lytvyn --- Cargo.lock | 1 + Cargo.toml | 1 + crates/openshell-tui/Cargo.toml | 1 + crates/openshell-tui/src/app.rs | 95 ++++++++++++++++++- crates/openshell-tui/src/lib.rs | 7 +- .../openshell-tui/src/ui/create_provider.rs | 88 ++++++++++++++++- 6 files changed, 187 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13b670f558..35ecb4f1c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4007,6 +4007,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "crossterm 0.28.1", + "indexmap", "miette", "openshell-bootstrap", "openshell-core", diff --git a/Cargo.toml b/Cargo.toml index f450cd5c8c..859eb30131 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,7 @@ pin-project-lite = "0.2" tokio-stream = "0.1" protobuf-src = "1.1.0" url = "2" +indexmap = "2" # Database sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "sqlite", "migrate"] } diff --git a/crates/openshell-tui/Cargo.toml b/crates/openshell-tui/Cargo.toml index 2381661364..131a8ccff2 100644 --- a/crates/openshell-tui/Cargo.toml +++ b/crates/openshell-tui/Cargo.toml @@ -27,6 +27,7 @@ owo-colors = { workspace = true } serde = { workspace = true } tracing = { workspace = true } url = { workspace = true } +indexmap = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 4538b207d6..c5639d50bb 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use indexmap::IndexMap; use openshell_bootstrap::GatewayMetadataSource; use openshell_core::auth::EdgeAuthInterceptor; use openshell_core::proto::open_shell_client::OpenShellClient; @@ -347,6 +348,10 @@ pub enum ProviderKeyField { EnvVarName, /// Custom env var value (generic / no-known-env-vars types only). GenericValue, + /// Config key name input. + ConfigKeyName, + /// Config key value input. + ConfigKeyValue, Submit, } @@ -363,6 +368,12 @@ pub struct CreateProviderForm { pub credentials: Vec<(String, String)>, /// Which credential row is focused. pub cred_cursor: usize, + /// TODO: inline doc for config, possibilities of using IndexMap + pub config: IndexMap, + /// Which config row is focused. + pub config_cursor: usize, + pub config_key_input: String, + pub config_value_input: String, /// For generic / types with no known env vars: custom env var name. pub generic_env_name: String, /// For generic / types with no known env vars: custom value. @@ -2111,6 +2122,10 @@ impl App { name: String::new(), credentials: Vec::new(), cred_cursor: 0, + config: IndexMap::new(), + config_cursor: 0, + config_key_input: String::new(), + config_value_input: String::new(), generic_env_name: String::new(), generic_value: String::new(), key_field: ProviderKeyField::Name, @@ -2219,6 +2234,10 @@ impl App { form.name.clear(); form.credentials.clear(); form.cred_cursor = 0; + form.config.clear(); + form.config_cursor = 0; + form.config_key_input.clear(); + form.config_value_input.clear(); form.generic_env_name.clear(); form.generic_value.clear(); } @@ -2232,11 +2251,11 @@ impl App { _ => ProviderKeyField::Name, }; } else { - // Name → Credential[0..N-1] → Submit → Name + // Name → Credential[0..N-1] → ConfigKeyName → ConfigKeyValue → Submit → Name match form.key_field { ProviderKeyField::Name => { if form.credentials.is_empty() { - form.key_field = ProviderKeyField::Submit; + form.key_field = ProviderKeyField::ConfigKeyName; } else { form.key_field = ProviderKeyField::Credential; form.cred_cursor = 0; @@ -2246,7 +2265,27 @@ impl App { if form.cred_cursor < form.credentials.len().saturating_sub(1) { form.cred_cursor += 1; } else { + form.key_field = ProviderKeyField::ConfigKeyName; + } + } + ProviderKeyField::ConfigKeyName => { + form.key_field = ProviderKeyField::ConfigKeyValue; + } + ProviderKeyField::ConfigKeyValue => { + if !form.config_key_input.is_empty() + && !form.config_value_input.is_empty() + { + form.config.insert( + std::mem::take(&mut form.config_key_input), + std::mem::take(&mut form.config_value_input), + ); + form.key_field = ProviderKeyField::ConfigKeyName; + } else if form.config_key_input.is_empty() + && form.config_value_input.is_empty() + { form.key_field = ProviderKeyField::Submit; + } else { + form.key_field = ProviderKeyField::ConfigKeyName; } } _ => { @@ -2272,7 +2311,10 @@ impl App { form.key_field = ProviderKeyField::Name; } } - ProviderKeyField::Submit => { + ProviderKeyField::ConfigKeyValue => { + form.key_field = ProviderKeyField::ConfigKeyName; + } + ProviderKeyField::ConfigKeyName => { if form.credentials.is_empty() { form.key_field = ProviderKeyField::Name; } else { @@ -2280,6 +2322,9 @@ impl App { form.cred_cursor = form.credentials.len().saturating_sub(1); } } + ProviderKeyField::Submit => { + form.key_field = ProviderKeyField::ConfigKeyValue; + } _ => { form.key_field = ProviderKeyField::Submit; } @@ -2293,6 +2338,50 @@ impl App { Self::handle_text_input(value, key); } } + ProviderKeyField::ConfigKeyName => match key.code { + KeyCode::Enter => { + if !form.config_key_input.is_empty() + && !form.config_value_input.is_empty() + { + form.config.insert( + std::mem::take(&mut form.config_key_input), + std::mem::take(&mut form.config_value_input), + ); + } + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !form.config.is_empty() { + let key_to_remove = form + .config + .keys() + .nth(form.config_cursor) + .cloned() + .unwrap_or_default(); + form.config.shift_remove(&key_to_remove); + form.config_cursor = form + .config_cursor + .min(form.config.len().saturating_sub(1)); + } + } + _ => { + Self::handle_text_input(&mut form.config_key_input, key); + } + }, + ProviderKeyField::ConfigKeyValue => match key.code { + KeyCode::Enter => { + if !form.config_key_input.is_empty() + && !form.config_value_input.is_empty() + { + form.config.insert( + std::mem::take(&mut form.config_key_input), + std::mem::take(&mut form.config_value_input), + ); + } + } + _ => { + Self::handle_text_input(&mut form.config_value_input, key); + } + }, ProviderKeyField::EnvVarName => { Self::handle_text_input(&mut form.generic_env_name, key); } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 7992666d38..8d32b3f570 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1597,6 +1597,11 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { form.name.clone() }; let credentials = form.discovered_credentials.clone().unwrap_or_default(); + let config: HashMap = form + .config + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); tokio::spawn(async move { // Try with the chosen name, retry with suffix on collision. @@ -1618,7 +1623,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { }), r#type: ptype.clone(), credentials: credentials.clone(), - config: HashMap::default(), + config: config.clone(), credential_expires_at_ms: HashMap::default(), }), }; diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index a0896aa46a..ab798cb9f3 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -207,8 +207,10 @@ fn draw_enter_key( warning_rows + 12 } else { let num_creds = form.credentials.len().clamp(1, 8) as u16; + let config_rows = (form.config.len() as u16).min(6) + 3; // existing entries + label(1) + key input(1) + value input(1) // type(1) + name(2) + spacer(1) + creds + spacer(1) + submit(1) + status(1) + hint(1) - warning_rows + 1 + 2 + 1 + num_creds + 1 + 1 + 1 + 1 + + warning_rows + 1 + 2 + 1 + num_creds + 1 + config_rows + 1 + 1 + 1 + 1 }; let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); let popup_area = centered_rect(modal_width, modal_height, area); @@ -241,6 +243,17 @@ fn draw_enter_key( let num_creds = form.credentials.len().clamp(1, 8) as u16; constraints.push(Constraint::Length(num_creds)); // credential rows } + + constraints.push(Constraint::Length(1)); // spacer before config + constraints.push(Constraint::Length(1)); // config keys label + #[allow(clippy::cast_possible_truncation)] + let num_config = form.config.len().min(6) as u16; + if num_config > 0 { + constraints.push(Constraint::Length(num_config)); // existing config entries + } + constraints.push(Constraint::Length(1)); // config key input + constraints.push(Constraint::Length(1)); // config value input + constraints.push(Constraint::Length(1)); // spacer constraints.push(Constraint::Length(1)); // submit constraints.push(Constraint::Length(1)); // status @@ -361,7 +374,78 @@ fn draw_enter_key( } idx += 1; - // Spacer. + // Spacer before config. + idx += 1; + + // Config Keys label. + let config_focused = matches!( + form.key_field, + ProviderKeyField::ConfigKeyName | ProviderKeyField::ConfigKeyValue + ); + let header_style = if config_focused { t.accent_bold } else { t.muted }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled("Config Keys:", header_style))), + chunks[idx], + ); + idx += 1; + + // Existing config entries. + if !form.config.is_empty() { + let config_lines: Vec> = form + .config + .iter() + .enumerate() + .take(6) + .map(|(i, (key, value))| { + let is_selected = config_focused && i == form.config_cursor; + let style = if is_selected { t.accent_bold } else { t.text }; + Line::from(vec![ + Span::styled(format!(" {key}="), style), + Span::styled(value.as_str(), if is_selected { t.accent } else { t.muted }), + ]) + }) + .collect(); + frame.render_widget(Paragraph::new(config_lines), chunks[idx]); + idx += 1; + } + + // Config key input. + let editing_key = form.key_field == ProviderKeyField::ConfigKeyName; + let key_display = if form.config_key_input.is_empty() { + if editing_key { "_".to_string() } else { "key".to_string() } + } else { + let mut s = form.config_key_input.clone(); + if editing_key { s.push('_'); } + s + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" Key: ", t.muted), + Span::styled(key_display, if editing_key { t.accent } else { t.muted }), + ])), + chunks[idx], + ); + idx += 1; + + // Config value input. + let editing_val = form.key_field == ProviderKeyField::ConfigKeyValue; + let val_display = if form.config_value_input.is_empty() { + if editing_val { "_".to_string() } else { "value".to_string() } + } else { + let mut s = form.config_value_input.clone(); + if editing_val { s.push('_'); } + s + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" Val: ", t.muted), + Span::styled(val_display, if editing_val { t.accent } else { t.muted }), + ])), + chunks[idx], + ); + idx += 1; + + // Spacer before submit. idx += 1; // Submit button. From 9b02ea900ab82b1bff5c72dd3c52a0767bfd9eab Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Wed, 8 Jul 2026 12:06:22 +0100 Subject: [PATCH 02/12] feat(tui): add config key editing to update provider form Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 146 +++++++++-- crates/openshell-tui/src/lib.rs | 7 +- .../openshell-tui/src/ui/create_provider.rs | 227 +++++++++++++++--- 3 files changed, 325 insertions(+), 55 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index c5639d50bb..1a873fe643 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#![warn(missing_debug_implementations)] + use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -368,11 +370,13 @@ pub struct CreateProviderForm { pub credentials: Vec<(String, String)>, /// Which credential row is focused. pub cred_cursor: usize, - /// TODO: inline doc for config, possibilities of using IndexMap + /// Provider config key-value pairs (e.g. `ANTHROPIC_BASE_URL`). pub config: IndexMap, - /// Which config row is focused. + /// Which existing config entry is selected (for deletion). pub config_cursor: usize, + /// Config key being entered. pub config_key_input: String, + /// Config value being entered. pub config_value_input: String, /// For generic / types with no known env vars: custom env var name. pub generic_env_name: String, @@ -508,9 +512,22 @@ pub struct UpdateProviderForm { pub provider_type: String, pub credential_key: String, pub new_value: String, + pub config: IndexMap, + pub config_key_input: String, + pub config_value_input: String, + pub config_cursor: usize, + pub focus: UpdateProviderField, pub status: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateProviderField { + CredentialValue, + ConfigKey, + ConfigValue, + Submit, +} + // --------------------------------------------------------------------------- // App state // --------------------------------------------------------------------------- @@ -2358,9 +2375,8 @@ impl App { .cloned() .unwrap_or_default(); form.config.shift_remove(&key_to_remove); - form.config_cursor = form - .config_cursor - .min(form.config.len().saturating_sub(1)); + form.config_cursor = + form.config_cursor.min(form.config.len().saturating_sub(1)); } } _ => { @@ -2503,6 +2519,17 @@ impl App { .get(self.provider_selected) .cloned() .unwrap_or_default(); + let existing_config = self + .provider_entries + .get(self.provider_selected) + .map(|e| { + e.provider + .config + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }) + .unwrap_or_default(); // If we don't know the credential key, derive from registry. let key = if cred_key.is_empty() { @@ -2520,6 +2547,11 @@ impl App { provider_type: ptype, credential_key: key, new_value: String::new(), + config: existing_config, + config_key_input: String::new(), + config_value_input: String::new(), + config_cursor: 0, + focus: UpdateProviderField::CredentialValue, status: None, }); } @@ -2533,18 +2565,100 @@ impl App { KeyCode::Esc => { self.update_provider_form = None; } - KeyCode::Enter => { - if form.new_value.is_empty() { - form.status = Some("Value is required.".to_string()); - return; + KeyCode::Tab => match form.focus { + UpdateProviderField::CredentialValue => { + form.focus = UpdateProviderField::ConfigKey; } - self.pending_provider_update = true; - } - KeyCode::Char(c) => form.new_value.push(c), - KeyCode::Backspace => { - form.new_value.pop(); - } - _ => {} + UpdateProviderField::ConfigKey => { + form.focus = UpdateProviderField::ConfigValue; + } + UpdateProviderField::ConfigValue => { + if !form.config_key_input.is_empty() && !form.config_value_input.is_empty() { + form.config.insert( + std::mem::take(&mut form.config_key_input), + std::mem::take(&mut form.config_value_input), + ); + form.focus = UpdateProviderField::ConfigKey; + } else if form.config_key_input.is_empty() && form.config_value_input.is_empty() + { + form.focus = UpdateProviderField::Submit; + } else { + form.focus = UpdateProviderField::ConfigKey; + } + } + UpdateProviderField::Submit => { + form.focus = UpdateProviderField::CredentialValue; + } + }, + KeyCode::BackTab => match form.focus { + UpdateProviderField::CredentialValue => { + form.focus = UpdateProviderField::Submit; + } + UpdateProviderField::ConfigKey => { + form.focus = UpdateProviderField::CredentialValue; + } + UpdateProviderField::ConfigValue => { + form.focus = UpdateProviderField::ConfigKey; + } + UpdateProviderField::Submit => { + form.focus = UpdateProviderField::ConfigValue; + } + }, + _ => match form.focus { + UpdateProviderField::CredentialValue => { + Self::handle_text_input(&mut form.new_value, key); + } + UpdateProviderField::ConfigKey => match key.code { + KeyCode::Enter => { + if !form.config_key_input.is_empty() && !form.config_value_input.is_empty() + { + form.config.insert( + std::mem::take(&mut form.config_key_input), + std::mem::take(&mut form.config_value_input), + ); + } + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !form.config.is_empty() { + let key_to_remove = form + .config + .keys() + .nth(form.config_cursor) + .cloned() + .unwrap_or_default(); + form.config.shift_remove(&key_to_remove); + form.config_cursor = + form.config_cursor.min(form.config.len().saturating_sub(1)); + } + } + _ => { + Self::handle_text_input(&mut form.config_key_input, key); + } + }, + UpdateProviderField::ConfigValue => match key.code { + KeyCode::Enter => { + if !form.config_key_input.is_empty() && !form.config_value_input.is_empty() + { + form.config.insert( + std::mem::take(&mut form.config_key_input), + std::mem::take(&mut form.config_value_input), + ); + } + } + _ => { + Self::handle_text_input(&mut form.config_value_input, key); + } + }, + UpdateProviderField::Submit => { + if key.code == KeyCode::Enter { + if form.new_value.is_empty() { + form.status = Some("Value is required.".to_string()); + return; + } + self.pending_provider_update = true; + } + } + }, } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 8d32b3f570..02d26db066 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1699,6 +1699,11 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { let ptype = form.provider_type.clone(); let cred_key = form.credential_key.clone(); let new_value = form.new_value.clone(); + let config: HashMap = form + .config + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); tokio::spawn(async move { let mut credentials = HashMap::new(); @@ -1715,7 +1720,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { }), r#type: ptype, credentials, - config: HashMap::default(), + config, credential_expires_at_ms: HashMap::default(), }), credential_expires_at_ms: HashMap::default(), diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index ab798cb9f3..a9f8bb7192 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -6,7 +6,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; -use crate::app::{App, CreateProviderPhase, ProviderKeyField}; +use crate::app::{App, CreateProviderPhase, ProviderKeyField, UpdateProviderField}; use super::centered_rect; @@ -382,7 +382,11 @@ fn draw_enter_key( form.key_field, ProviderKeyField::ConfigKeyName | ProviderKeyField::ConfigKeyValue ); - let header_style = if config_focused { t.accent_bold } else { t.muted }; + let header_style = if config_focused { + t.accent_bold + } else { + t.muted + }; frame.render_widget( Paragraph::new(Line::from(Span::styled("Config Keys:", header_style))), chunks[idx], @@ -412,10 +416,16 @@ fn draw_enter_key( // Config key input. let editing_key = form.key_field == ProviderKeyField::ConfigKeyName; let key_display = if form.config_key_input.is_empty() { - if editing_key { "_".to_string() } else { "key".to_string() } + if editing_key { + "_".to_string() + } else { + "key".to_string() + } } else { let mut s = form.config_key_input.clone(); - if editing_key { s.push('_'); } + if editing_key { + s.push('_'); + } s }; frame.render_widget( @@ -430,10 +440,16 @@ fn draw_enter_key( // Config value input. let editing_val = form.key_field == ProviderKeyField::ConfigKeyValue; let val_display = if form.config_value_input.is_empty() { - if editing_val { "_".to_string() } else { "value".to_string() } + if editing_val { + "_".to_string() + } else { + "value".to_string() + } } else { let mut s = form.config_value_input.clone(); - if editing_val { s.push('_'); } + if editing_val { + s.push('_'); + } s }; frame.render_widget( @@ -753,9 +769,14 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { return; }; - let modal_width = 60u16.min(area.width.saturating_sub(4)); - // name(1) + type(1) + spacer(1) + key_label(1) + value(1) + cursor_hint(1) + spacer(1) + status(1) + hint(1) - let content_height = 9; + let modal_width = 64u16.min(area.width.saturating_sub(4)); + + #[allow(clippy::cast_possible_truncation)] + let num_config = form.config.len().min(6) as u16; + let config_rows = num_config + 3; // existing entries + label(1) + key input(1) + value input(1) + // name(1) + type(1) + spacer(1) + key_label(1) + value(1) + spacer(1) + // + config_section + spacer(1) + submit(1) + status(1) + hint(1) + let content_height: u16 = 1 + 1 + 1 + 1 + 1 + 1 + config_rows + 1 + 1 + 1 + 1; let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); let popup_area = centered_rect(modal_width, modal_height, area); @@ -770,69 +791,193 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { let inner = block.inner(popup_area); frame.render_widget(block, popup_area); + let mut constraints = vec![ + Constraint::Length(1), // name + Constraint::Length(1), // type + Constraint::Length(1), // spacer + Constraint::Length(1), // key label + Constraint::Length(1), // value input + Constraint::Length(1), // spacer before config + Constraint::Length(1), // config keys label + ]; + if num_config > 0 { + constraints.push(Constraint::Length(num_config)); // existing config entries + } + constraints.extend([ + Constraint::Length(1), // config key input + Constraint::Length(1), // config value input + Constraint::Length(1), // spacer before submit + Constraint::Length(1), // submit + Constraint::Length(1), // status + Constraint::Length(1), // hint + Constraint::Min(0), + ]); + let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // name - Constraint::Length(1), // type - Constraint::Length(1), // spacer - Constraint::Length(1), // key label - Constraint::Length(1), // value input - Constraint::Length(1), // cursor hint - Constraint::Length(1), // spacer - Constraint::Length(1), // status - Constraint::Length(1), // hint - Constraint::Min(0), - ]) + .constraints(constraints) .split(inner); + let mut idx = 0; + + // Name. frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled("Name: ", t.muted), Span::styled(&form.provider_name, t.heading), ])), - chunks[0], + chunks[idx], ); + idx += 1; + // Type. frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled("Type: ", t.muted), Span::styled(&form.provider_type, t.text), ])), - chunks[1], + chunks[idx], ); + idx += 1; + + // Spacer. + idx += 1; + // Credential key label. + let cred_focused = form.focus == UpdateProviderField::CredentialValue; let key_label = if form.credential_key.is_empty() { - "New value:" + "New value" } else { &form.credential_key }; frame.render_widget( Paragraph::new(Line::from(Span::styled( format!("{key_label}:"), - t.accent_bold, + if cred_focused { t.accent_bold } else { t.muted }, ))), - chunks[3], + chunks[idx], ); + idx += 1; - // Mask the input value as dots. + // Credential value input (masked). let masked: String = "*".repeat(form.new_value.len()); + let cred_style = if cred_focused { t.accent } else { t.muted }; + let mut cred_spans = vec![Span::styled(format!(" {masked}"), cred_style)]; + if cred_focused { + cred_spans.push(Span::styled("_", t.accent)); + } + frame.render_widget(Paragraph::new(Line::from(cred_spans)), chunks[idx]); + idx += 1; + + // Spacer before config. + idx += 1; + + // Config Keys label. + let config_focused = matches!( + form.focus, + UpdateProviderField::ConfigKey | UpdateProviderField::ConfigValue + ); + let header_style = if config_focused { + t.accent_bold + } else { + t.muted + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled("Config Keys:", header_style))), + chunks[idx], + ); + idx += 1; + + // Existing config entries. + if !form.config.is_empty() { + let config_lines: Vec> = form + .config + .iter() + .enumerate() + .take(6) + .map(|(i, (key, value))| { + let is_selected = config_focused && i == form.config_cursor; + let style = if is_selected { t.accent_bold } else { t.text }; + Line::from(vec![ + Span::styled(format!(" {key}="), style), + Span::styled(value.as_str(), if is_selected { t.accent } else { t.muted }), + ]) + }) + .collect(); + frame.render_widget(Paragraph::new(config_lines), chunks[idx]); + idx += 1; + } + + // Config key input. + let editing_key = form.focus == UpdateProviderField::ConfigKey; + let key_display = if form.config_key_input.is_empty() { + if editing_key { + "_".to_string() + } else { + "key".to_string() + } + } else { + let mut s = form.config_key_input.clone(); + if editing_key { + s.push('_'); + } + s + }; frame.render_widget( Paragraph::new(Line::from(vec![ - Span::styled(format!(" {masked}"), t.accent), - Span::styled("_", t.accent), + Span::styled(" Key: ", t.muted), + Span::styled(key_display, if editing_key { t.accent } else { t.muted }), ])), - chunks[4], + chunks[idx], ); + idx += 1; + // Config value input. + let editing_val = form.focus == UpdateProviderField::ConfigValue; + let val_display = if form.config_value_input.is_empty() { + if editing_val { + "_".to_string() + } else { + "value".to_string() + } + } else { + let mut s = form.config_value_input.clone(); + if editing_val { + s.push('_'); + } + s + }; frame.render_widget( - Paragraph::new(Line::from(Span::styled( - " Type the new credential value", - t.muted, - ))), - chunks[5], + Paragraph::new(Line::from(vec![ + Span::styled(" Val: ", t.muted), + Span::styled(val_display, if editing_val { t.accent } else { t.muted }), + ])), + chunks[idx], + ); + idx += 1; + + // Spacer before submit. + idx += 1; + + // Submit button. + let submit_focused = form.focus == UpdateProviderField::Submit; + let submit_style = if submit_focused { + t.accent_bold + } else { + t.muted + }; + let submit_label = if submit_focused { + " > Update Provider" + } else { + " Update Provider" + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(submit_label, submit_style))), + chunks[idx], ); + idx += 1; + // Status. if let Some(ref status) = form.status { let style = if status.contains("required") || status.contains("failed") @@ -844,17 +989,23 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { }; frame.render_widget( Paragraph::new(Line::from(Span::styled(format!(" {status}"), style))), - chunks[7], + chunks[idx], ); } + idx += 1; + // Hint. let hint = Line::from(vec![ + Span::styled("[Tab]", t.key_hint), + Span::styled(" Next ", t.muted), + Span::styled("[S-Tab]", t.key_hint), + Span::styled(" Prev ", t.muted), Span::styled("[Enter]", t.key_hint), - Span::styled(" Update ", t.muted), + Span::styled(" Submit ", t.muted), Span::styled("[Esc]", t.key_hint), Span::styled(" Cancel", t.muted), ]); - frame.render_widget(Paragraph::new(hint), chunks[8]); + frame.render_widget(Paragraph::new(hint), chunks[idx]); } // --------------------------------------------------------------------------- From 4d7798aca99fe3467c2aab106eb501c60432fea0 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 10 Jul 2026 15:38:27 +0100 Subject: [PATCH 03/12] fix(tui): add Up/Down navigation for config_cursor in provider forms Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 24 ++++++++++++++++++++---- crates/openshell-tui/src/lib.rs | 4 +++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 1a873fe643..c070de3f02 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -#![warn(missing_debug_implementations)] - use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -2379,6 +2377,15 @@ impl App { form.config_cursor.min(form.config.len().saturating_sub(1)); } } + KeyCode::Up => { + form.config_cursor = form.config_cursor.saturating_sub(1); + } + KeyCode::Down => { + if !form.config.is_empty() { + form.config_cursor = + (form.config_cursor + 1).min(form.config.len() - 1); + } + } _ => { Self::handle_text_input(&mut form.config_key_input, key); } @@ -2631,6 +2638,15 @@ impl App { form.config_cursor.min(form.config.len().saturating_sub(1)); } } + KeyCode::Up => { + form.config_cursor = form.config_cursor.saturating_sub(1); + } + KeyCode::Down => { + if !form.config.is_empty() { + form.config_cursor = + (form.config_cursor + 1).min(form.config.len() - 1); + } + } _ => { Self::handle_text_input(&mut form.config_key_input, key); } @@ -2651,8 +2667,8 @@ impl App { }, UpdateProviderField::Submit => { if key.code == KeyCode::Enter { - if form.new_value.is_empty() { - form.status = Some("Value is required.".to_string()); + if form.config.is_empty() { + form.status = Some("Config keys required.".to_string()); return; } self.pending_provider_update = true; diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 02d26db066..1c95ce6d00 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1707,7 +1707,9 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let mut credentials = HashMap::new(); - credentials.insert(cred_key, new_value); + if !new_value.is_empty() { + credentials.insert(cred_key, new_value); + } let req = openshell_core::proto::UpdateProviderRequest { provider: Some(openshell_core::proto::Provider { From 299aa447bbc6b848f9296004379c123ab773dc00 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 10 Jul 2026 15:43:17 +0100 Subject: [PATCH 04/12] fix(tui): show config values in provider detail view Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index c070de3f02..02a595ce1c 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -2812,7 +2812,12 @@ impl App { }, ); - let mut config_lines = provider.config.keys().cloned().collect::>(); + let mut config_lines = provider + .config + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>(); + config_lines.sort(); if config_lines.is_empty() { config_lines.push("".to_string()); From 86ab2eb1b7491c8af32c44aa4780e7575510dab1 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 10 Jul 2026 16:38:06 +0100 Subject: [PATCH 05/12] fix(tui): improve provider config form validation and reduce duplication Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 178 +++++++---- .../openshell-tui/src/ui/create_provider.rs | 293 ++++++++++-------- 2 files changed, 282 insertions(+), 189 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 02a595ce1c..4d3b2c0c86 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -526,6 +526,22 @@ pub enum UpdateProviderField { Submit, } +// --------------------------------------------------------------------------- +// Shared config helpers +// --------------------------------------------------------------------------- + +fn flush_config_input( + config: &mut IndexMap, + key_input: &mut String, + value_input: &mut String, +) -> bool { + if !key_input.is_empty() && !value_input.is_empty() { + config.insert(std::mem::take(key_input), std::mem::take(value_input)); + return true; + } + false +} + // --------------------------------------------------------------------------- // App state // --------------------------------------------------------------------------- @@ -2258,13 +2274,43 @@ impl App { } KeyCode::Tab => { if form.is_generic { - // Name → EnvVarName → GenericValue → Submit → Name - form.key_field = match form.key_field { - ProviderKeyField::Name => ProviderKeyField::EnvVarName, - ProviderKeyField::EnvVarName => ProviderKeyField::GenericValue, - ProviderKeyField::GenericValue => ProviderKeyField::Submit, - _ => ProviderKeyField::Name, - }; + // Name → EnvVarName → GenericValue → ConfigKeyName → ConfigKeyValue → Submit → Name + match form.key_field { + ProviderKeyField::Name => { + form.key_field = ProviderKeyField::EnvVarName; + } + ProviderKeyField::EnvVarName => { + form.key_field = ProviderKeyField::GenericValue; + } + ProviderKeyField::GenericValue => { + form.key_field = ProviderKeyField::ConfigKeyName; + } + ProviderKeyField::ConfigKeyName => { + form.key_field = ProviderKeyField::ConfigKeyValue; + } + ProviderKeyField::ConfigKeyValue => { + if flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ) { + form.key_field = ProviderKeyField::ConfigKeyName; + } else if form.config_key_input.is_empty() + && form.config_value_input.is_empty() + { + form.key_field = ProviderKeyField::Submit; + } else { + form.status = Some( + "Both key and value required to add config entry." + .to_string(), + ); + form.key_field = ProviderKeyField::ConfigKeyName; + } + } + _ => { + form.key_field = ProviderKeyField::Name; + } + } } else { // Name → Credential[0..N-1] → ConfigKeyName → ConfigKeyValue → Submit → Name match form.key_field { @@ -2287,19 +2333,21 @@ impl App { form.key_field = ProviderKeyField::ConfigKeyValue; } ProviderKeyField::ConfigKeyValue => { - if !form.config_key_input.is_empty() - && !form.config_value_input.is_empty() - { - form.config.insert( - std::mem::take(&mut form.config_key_input), - std::mem::take(&mut form.config_value_input), - ); + if flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ) { form.key_field = ProviderKeyField::ConfigKeyName; } else if form.config_key_input.is_empty() && form.config_value_input.is_empty() { form.key_field = ProviderKeyField::Submit; } else { + form.status = Some( + "Both key and value required to add config entry." + .to_string(), + ); form.key_field = ProviderKeyField::ConfigKeyName; } } @@ -2314,7 +2362,9 @@ impl App { form.key_field = match form.key_field { ProviderKeyField::EnvVarName => ProviderKeyField::Name, ProviderKeyField::GenericValue => ProviderKeyField::EnvVarName, - ProviderKeyField::Submit => ProviderKeyField::GenericValue, + ProviderKeyField::ConfigKeyName => ProviderKeyField::GenericValue, + ProviderKeyField::ConfigKeyValue => ProviderKeyField::ConfigKeyName, + ProviderKeyField::Submit => ProviderKeyField::ConfigKeyValue, _ => ProviderKeyField::Submit, }; } else { @@ -2355,14 +2405,11 @@ impl App { } ProviderKeyField::ConfigKeyName => match key.code { KeyCode::Enter => { - if !form.config_key_input.is_empty() - && !form.config_value_input.is_empty() - { - form.config.insert( - std::mem::take(&mut form.config_key_input), - std::mem::take(&mut form.config_value_input), - ); - } + flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ); } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { if !form.config.is_empty() { @@ -2392,14 +2439,11 @@ impl App { }, ProviderKeyField::ConfigKeyValue => match key.code { KeyCode::Enter => { - if !form.config_key_input.is_empty() - && !form.config_value_input.is_empty() - { - form.config.insert( - std::mem::take(&mut form.config_key_input), - std::mem::take(&mut form.config_value_input), - ); - } + flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ); } _ => { Self::handle_text_input(&mut form.config_value_input, key); @@ -2580,16 +2624,18 @@ impl App { form.focus = UpdateProviderField::ConfigValue; } UpdateProviderField::ConfigValue => { - if !form.config_key_input.is_empty() && !form.config_value_input.is_empty() { - form.config.insert( - std::mem::take(&mut form.config_key_input), - std::mem::take(&mut form.config_value_input), - ); + if flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ) { form.focus = UpdateProviderField::ConfigKey; } else if form.config_key_input.is_empty() && form.config_value_input.is_empty() { form.focus = UpdateProviderField::Submit; } else { + form.status = + Some("Both key and value required to add config entry.".to_string()); form.focus = UpdateProviderField::ConfigKey; } } @@ -2617,13 +2663,11 @@ impl App { } UpdateProviderField::ConfigKey => match key.code { KeyCode::Enter => { - if !form.config_key_input.is_empty() && !form.config_value_input.is_empty() - { - form.config.insert( - std::mem::take(&mut form.config_key_input), - std::mem::take(&mut form.config_value_input), - ); - } + flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ); } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { if !form.config.is_empty() { @@ -2653,13 +2697,11 @@ impl App { }, UpdateProviderField::ConfigValue => match key.code { KeyCode::Enter => { - if !form.config_key_input.is_empty() && !form.config_value_input.is_empty() - { - form.config.insert( - std::mem::take(&mut form.config_key_input), - std::mem::take(&mut form.config_value_input), - ); - } + flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ); } _ => { Self::handle_text_input(&mut form.config_value_input, key); @@ -2667,8 +2709,9 @@ impl App { }, UpdateProviderField::Submit => { if key.code == KeyCode::Enter { - if form.config.is_empty() { - form.status = Some("Config keys required.".to_string()); + if form.new_value.is_empty() && form.config.is_empty() { + form.status = + Some("Credential value or config keys required.".to_string()); return; } self.pending_provider_update = true; @@ -3155,4 +3198,35 @@ mod tests { assert_eq!(gateway.source_label(), "unknown"); } + + #[test] + fn flush_config_input_inserts_when_both_present() { + let mut config = IndexMap::new(); + let mut key = "FOO".to_string(); + let mut val = "bar".to_string(); + assert!(flush_config_input(&mut config, &mut key, &mut val)); + assert_eq!(config.get("FOO"), Some(&"bar".to_string())); + assert!(key.is_empty()); + assert!(val.is_empty()); + } + + #[test] + fn flush_config_input_noop_when_key_empty() { + let mut config = IndexMap::new(); + let mut key = String::new(); + let mut val = "bar".to_string(); + assert!(!flush_config_input(&mut config, &mut key, &mut val)); + assert!(config.is_empty()); + assert_eq!(val, "bar"); + } + + #[test] + fn flush_config_input_noop_when_value_empty() { + let mut config = IndexMap::new(); + let mut key = "FOO".to_string(); + let mut val = String::new(); + assert!(!flush_config_input(&mut config, &mut key, &mut val)); + assert!(config.is_empty()); + assert_eq!(key, "FOO"); + } } diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index a9f8bb7192..58c69fda38 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -8,8 +8,12 @@ use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use crate::app::{App, CreateProviderPhase, ProviderKeyField, UpdateProviderField}; +use indexmap::IndexMap; + use super::centered_rect; +const MAX_VISIBLE_CONFIG: usize = 6; + /// Draw the create provider modal overlay. pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { let t = &app.theme; @@ -201,15 +205,17 @@ fn draw_enter_key( let has_warning = form.warning.is_some(); let warning_rows: u16 = if has_warning { 2 } else { 0 }; // warning + spacer + #[allow(clippy::cast_possible_truncation)] + let config_rows = (form.config.len() as u16).min(MAX_VISIBLE_CONFIG as u16) + 3; #[allow(clippy::cast_possible_truncation)] let content_height = if form.is_generic { - // type(1) + name(2) + spacer(1) + env_name(2) + value(2) + spacer(1) + submit(1) + status(1) + hint(1) - warning_rows + 12 + // type(1) + name(2) + spacer(1) + env_name(2) + value(2) + spacer(1) + // + config_section + spacer(1) + submit(1) + status(1) + hint(1) + warning_rows + 1 + 2 + 1 + 2 + 2 + 1 + config_rows + 1 + 1 + 1 + 1 } else { let num_creds = form.credentials.len().clamp(1, 8) as u16; - let config_rows = (form.config.len() as u16).min(6) + 3; // existing entries + label(1) + key input(1) + value input(1) - // type(1) + name(2) + spacer(1) + creds + spacer(1) + submit(1) + status(1) + hint(1) - + // type(1) + name(2) + spacer(1) + creds + spacer(1) + // + config_section + spacer(1) + submit(1) + status(1) + hint(1) warning_rows + 1 + 2 + 1 + num_creds + 1 + config_rows + 1 + 1 + 1 + 1 }; let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); @@ -247,7 +253,7 @@ fn draw_enter_key( constraints.push(Constraint::Length(1)); // spacer before config constraints.push(Constraint::Length(1)); // config keys label #[allow(clippy::cast_possible_truncation)] - let num_config = form.config.len().min(6) as u16; + let num_config = form.config.len().min(MAX_VISIBLE_CONFIG) as u16; if num_config > 0 { constraints.push(Constraint::Length(num_config)); // existing config entries } @@ -395,69 +401,40 @@ fn draw_enter_key( // Existing config entries. if !form.config.is_empty() { - let config_lines: Vec> = form - .config - .iter() - .enumerate() - .take(6) - .map(|(i, (key, value))| { - let is_selected = config_focused && i == form.config_cursor; - let style = if is_selected { t.accent_bold } else { t.text }; - Line::from(vec![ - Span::styled(format!(" {key}="), style), - Span::styled(value.as_str(), if is_selected { t.accent } else { t.muted }), - ]) - }) - .collect(); - frame.render_widget(Paragraph::new(config_lines), chunks[idx]); + render_config_entries( + frame, + &form.config, + form.config_cursor, + config_focused, + chunks[idx], + t, + ); idx += 1; } // Config key input. let editing_key = form.key_field == ProviderKeyField::ConfigKeyName; - let key_display = if form.config_key_input.is_empty() { - if editing_key { - "_".to_string() - } else { - "key".to_string() - } - } else { - let mut s = form.config_key_input.clone(); - if editing_key { - s.push('_'); - } - s - }; - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" Key: ", t.muted), - Span::styled(key_display, if editing_key { t.accent } else { t.muted }), - ])), + render_config_input_field( + frame, + "Key", + &form.config_key_input, + editing_key, + "key", chunks[idx], + t, ); idx += 1; // Config value input. let editing_val = form.key_field == ProviderKeyField::ConfigKeyValue; - let val_display = if form.config_value_input.is_empty() { - if editing_val { - "_".to_string() - } else { - "value".to_string() - } - } else { - let mut s = form.config_value_input.clone(); - if editing_val { - s.push('_'); - } - s - }; - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" Val: ", t.muted), - Span::styled(val_display, if editing_val { t.accent } else { t.muted }), - ])), + render_config_input_field( + frame, + "Val", + &form.config_value_input, + editing_val, + "value", chunks[idx], + t, ); idx += 1; @@ -483,20 +460,7 @@ fn draw_enter_key( idx += 1; // Status. - if let Some(ref status) = form.status { - let style = if status.contains("required") - || status.contains("failed") - || status.contains("Failed") - { - t.status_err - } else { - t.status_ok - }; - frame.render_widget( - Paragraph::new(Line::from(Span::styled(format!(" {status}"), style))), - chunks[idx], - ); - } + render_status(frame, form.status.as_deref(), chunks[idx], t); idx += 1; // Hint. @@ -505,6 +469,8 @@ fn draw_enter_key( Span::styled(" Next ", t.muted), Span::styled("[S-Tab]", t.key_hint), Span::styled(" Prev ", t.muted), + Span::styled("[C-d]", t.key_hint), + Span::styled(" Delete ", t.muted), Span::styled("[Enter]", t.key_hint), Span::styled(" Submit ", t.muted), Span::styled("[Esc]", t.key_hint), @@ -772,7 +738,7 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { let modal_width = 64u16.min(area.width.saturating_sub(4)); #[allow(clippy::cast_possible_truncation)] - let num_config = form.config.len().min(6) as u16; + let num_config = form.config.len().min(MAX_VISIBLE_CONFIG) as u16; let config_rows = num_config + 3; // existing entries + label(1) + key input(1) + value input(1) // name(1) + type(1) + spacer(1) + key_label(1) + value(1) + spacer(1) // + config_section + spacer(1) + submit(1) + status(1) + hint(1) @@ -890,69 +856,40 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { // Existing config entries. if !form.config.is_empty() { - let config_lines: Vec> = form - .config - .iter() - .enumerate() - .take(6) - .map(|(i, (key, value))| { - let is_selected = config_focused && i == form.config_cursor; - let style = if is_selected { t.accent_bold } else { t.text }; - Line::from(vec![ - Span::styled(format!(" {key}="), style), - Span::styled(value.as_str(), if is_selected { t.accent } else { t.muted }), - ]) - }) - .collect(); - frame.render_widget(Paragraph::new(config_lines), chunks[idx]); + render_config_entries( + frame, + &form.config, + form.config_cursor, + config_focused, + chunks[idx], + t, + ); idx += 1; } // Config key input. let editing_key = form.focus == UpdateProviderField::ConfigKey; - let key_display = if form.config_key_input.is_empty() { - if editing_key { - "_".to_string() - } else { - "key".to_string() - } - } else { - let mut s = form.config_key_input.clone(); - if editing_key { - s.push('_'); - } - s - }; - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" Key: ", t.muted), - Span::styled(key_display, if editing_key { t.accent } else { t.muted }), - ])), + render_config_input_field( + frame, + "Key", + &form.config_key_input, + editing_key, + "key", chunks[idx], + t, ); idx += 1; // Config value input. let editing_val = form.focus == UpdateProviderField::ConfigValue; - let val_display = if form.config_value_input.is_empty() { - if editing_val { - "_".to_string() - } else { - "value".to_string() - } - } else { - let mut s = form.config_value_input.clone(); - if editing_val { - s.push('_'); - } - s - }; - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" Val: ", t.muted), - Span::styled(val_display, if editing_val { t.accent } else { t.muted }), - ])), + render_config_input_field( + frame, + "Val", + &form.config_value_input, + editing_val, + "value", chunks[idx], + t, ); idx += 1; @@ -978,20 +915,7 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { idx += 1; // Status. - if let Some(ref status) = form.status { - let style = if status.contains("required") - || status.contains("failed") - || status.contains("Failed") - { - t.status_err - } else { - t.status_ok - }; - frame.render_widget( - Paragraph::new(Line::from(Span::styled(format!(" {status}"), style))), - chunks[idx], - ); - } + render_status(frame, form.status.as_deref(), chunks[idx], t); idx += 1; // Hint. @@ -1000,6 +924,8 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled(" Next ", t.muted), Span::styled("[S-Tab]", t.key_hint), Span::styled(" Prev ", t.muted), + Span::styled("[C-d]", t.key_hint), + Span::styled(" Delete ", t.muted), Span::styled("[Enter]", t.key_hint), Span::styled(" Submit ", t.muted), Span::styled("[Esc]", t.key_hint), @@ -1048,3 +974,96 @@ fn draw_secret_field( }; frame.render_widget(Paragraph::new(display), chunks[1]); } + +fn render_config_entries( + frame: &mut Frame<'_>, + config: &IndexMap, + config_cursor: usize, + config_focused: bool, + area: Rect, + theme: &crate::theme::Theme, +) { + let t = theme; + let overflow = config.len() > MAX_VISIBLE_CONFIG; + let take_count = if overflow { + MAX_VISIBLE_CONFIG - 1 + } else { + MAX_VISIBLE_CONFIG + }; + let mut config_lines: Vec> = config + .iter() + .enumerate() + .take(take_count) + .map(|(i, (key, value))| { + let is_selected = config_focused && i == config_cursor; + let style = if is_selected { t.accent_bold } else { t.text }; + Line::from(vec![ + Span::styled(format!(" {key}="), style), + Span::styled(value.as_str(), if is_selected { t.accent } else { t.muted }), + ]) + }) + .collect(); + if overflow { + let remaining = config.len() - take_count; + config_lines.push(Line::from(Span::styled( + format!(" \u{2026}and {remaining} more"), + t.muted, + ))); + } + frame.render_widget(Paragraph::new(config_lines), area); +} + +fn render_config_input_field( + frame: &mut Frame<'_>, + label: &str, + input: &str, + editing: bool, + placeholder: &str, + area: Rect, + theme: &crate::theme::Theme, +) { + let t = theme; + let display = if input.is_empty() { + if editing { + "_".to_string() + } else { + placeholder.to_string() + } + } else { + let mut s = input.to_string(); + if editing { + s.push('_'); + } + s + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {label}: "), t.muted), + Span::styled(display, if editing { t.accent } else { t.muted }), + ])), + area, + ); +} + +fn render_status( + frame: &mut Frame<'_>, + status: Option<&str>, + area: Rect, + theme: &crate::theme::Theme, +) { + let t = theme; + if let Some(status) = status { + let style = if status.contains("required") + || status.contains("failed") + || status.contains("Failed") + { + t.status_err + } else { + t.status_ok + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(format!(" {status}"), style))), + area, + ); + } +} From cde96ac00a3f789e239783bbd155a789d3b3a943 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 15:30:31 +0100 Subject: [PATCH 06/12] fix(tui): fix config deletion and invisible cursor in provider forms Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 8 ++++- crates/openshell-tui/src/lib.rs | 5 ++- .../openshell-tui/src/ui/create_provider.rs | 36 +++++++++++++------ 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 4d3b2c0c86..d0a705da24 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -514,6 +514,7 @@ pub struct UpdateProviderForm { pub config_key_input: String, pub config_value_input: String, pub config_cursor: usize, + pub deleted_keys: Vec, pub focus: UpdateProviderField, pub status: Option, } @@ -2602,6 +2603,7 @@ impl App { config_key_input: String::new(), config_value_input: String::new(), config_cursor: 0, + deleted_keys: Vec::new(), focus: UpdateProviderField::CredentialValue, status: None, }); @@ -2677,6 +2679,7 @@ impl App { .nth(form.config_cursor) .cloned() .unwrap_or_default(); + form.deleted_keys.push(key_to_remove.clone()); form.config.shift_remove(&key_to_remove); form.config_cursor = form.config_cursor.min(form.config.len().saturating_sub(1)); @@ -2709,7 +2712,10 @@ impl App { }, UpdateProviderField::Submit => { if key.code == KeyCode::Enter { - if form.new_value.is_empty() && form.config.is_empty() { + if form.new_value.is_empty() + && form.config.is_empty() + && form.deleted_keys.is_empty() + { form.status = Some("Credential value or config keys required.".to_string()); return; diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 1c95ce6d00..513d8cd5bc 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1699,11 +1699,14 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { let ptype = form.provider_type.clone(); let cred_key = form.credential_key.clone(); let new_value = form.new_value.clone(); - let config: HashMap = form + let mut config: HashMap = form .config .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); + form.deleted_keys.iter().for_each(|key| { + config.insert(key.clone(), String::new()); + }); tokio::spawn(async move { let mut credentials = HashMap::new(); diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index 58c69fda38..22d6279493 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -983,31 +983,47 @@ fn render_config_entries( area: Rect, theme: &crate::theme::Theme, ) { - let t = theme; - let overflow = config.len() > MAX_VISIBLE_CONFIG; - let take_count = if overflow { - MAX_VISIBLE_CONFIG - 1 + let total = config.len(); + let scroll_offset = if total > MAX_VISIBLE_CONFIG { + config_cursor + .saturating_sub(MAX_VISIBLE_CONFIG - 2_usize) + .min(total.saturating_sub(MAX_VISIBLE_CONFIG)) } else { - MAX_VISIBLE_CONFIG + 0_usize }; + let take_count = MAX_VISIBLE_CONFIG.min(total.saturating_sub(scroll_offset)); + let overflow_below = scroll_offset + take_count < total; + let mut config_lines: Vec> = config .iter() .enumerate() + .skip(scroll_offset) .take(take_count) .map(|(i, (key, value))| { let is_selected = config_focused && i == config_cursor; - let style = if is_selected { t.accent_bold } else { t.text }; + let style = if is_selected { + theme.accent_bold + } else { + theme.text + }; Line::from(vec![ Span::styled(format!(" {key}="), style), - Span::styled(value.as_str(), if is_selected { t.accent } else { t.muted }), + Span::styled( + value.as_str(), + if is_selected { + theme.accent + } else { + theme.muted + }, + ), ]) }) .collect(); - if overflow { - let remaining = config.len() - take_count; + if overflow_below { + let remaining = total - scroll_offset - take_count; config_lines.push(Line::from(Span::styled( format!(" \u{2026}and {remaining} more"), - t.muted, + theme.muted, ))); } frame.render_widget(Paragraph::new(config_lines), area); From 476730e73da5fc73c2444623192cb8b95a69ee79 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 16:04:28 +0100 Subject: [PATCH 07/12] test(tui): add tests for config deletion tombstones and cursor scroll window Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index d0a705da24..8c61db7e92 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -3235,4 +3235,81 @@ mod tests { assert!(config.is_empty()); assert_eq!(key, "FOO"); } + + // -- config deletion tombstones ------------------------------------ + + #[test] + fn delete_config_entry_records_tombstone() { + let mut form = UpdateProviderForm { + provider_name: "p".into(), + provider_type: "t".into(), + credential_key: "k".into(), + new_value: String::new(), + config: IndexMap::from([("FOO".into(), "1".into()), ("BAR".into(), "2".into())]), + config_key_input: String::new(), + config_value_input: String::new(), + config_cursor: 0, + focus: UpdateProviderField::ConfigKey, + status: None, + deleted_keys: Vec::new(), + }; + + let key_to_remove = form.config.keys().next().cloned().unwrap(); + form.deleted_keys.push(key_to_remove.clone()); + form.config.shift_remove(&key_to_remove); + + assert!(!form.config.contains_key("FOO")); + assert!(form.deleted_keys.contains(&"FOO".to_owned())); + } + + #[test] + fn delete_last_config_entry_allows_submit() { + let form = UpdateProviderForm { + provider_name: "p".into(), + provider_type: "t".into(), + credential_key: "k".into(), + new_value: String::new(), + config: IndexMap::new(), + config_key_input: String::new(), + config_value_input: String::new(), + config_cursor: 0, + focus: UpdateProviderField::Submit, + status: None, + deleted_keys: vec!["FOO".into()], + }; + + assert!( + !(form.new_value.is_empty() && form.config.is_empty() && form.deleted_keys.is_empty()) + ); + } + + // -- cursor-relative scroll window --------------------------------- + + #[test] + fn scroll_offset_zero_when_within_window() { + let (total, cursor, window) = (4_usize, 3_usize, 6_usize); + let offset = if total > window { + cursor + .saturating_sub(window - 2_usize) + .min(total.saturating_sub(window)) + } else { + 0_usize + }; + + assert_eq!(offset, 0_usize); + } + + #[test] + fn scroll_offset_follows_cursor_past_window() { + let (total, cursor, window) = (10_usize, 8_usize, 6_usize); + let offset = if total > window { + cursor + .saturating_sub(window - 2) + .min(total.saturating_sub(window)) + } else { + 0 + }; + assert_eq!(offset, 4); + assert!(cursor >= offset && cursor < offset + window); + } } From c5a0d2b48bf95e994a562affb4da103e42bd4d93 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 16:31:38 +0100 Subject: [PATCH 08/12] fix(tui): send only config delta on provider update Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 10 ++++++++-- crates/openshell-tui/src/lib.rs | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 8c61db7e92..c924db1439 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -511,6 +511,7 @@ pub struct UpdateProviderForm { pub credential_key: String, pub new_value: String, pub config: IndexMap, + pub original_config: IndexMap, pub config_key_input: String, pub config_value_input: String, pub config_cursor: usize, @@ -2599,7 +2600,8 @@ impl App { provider_type: ptype, credential_key: key, new_value: String::new(), - config: existing_config, + config: existing_config.clone(), + original_config: existing_config, config_key_input: String::new(), config_value_input: String::new(), config_cursor: 0, @@ -3240,12 +3242,15 @@ mod tests { #[test] fn delete_config_entry_records_tombstone() { + let config = IndexMap::from([("FOO".into(), "1".into()), ("BAR".into(), "2".into())]); + let mut form = UpdateProviderForm { provider_name: "p".into(), provider_type: "t".into(), credential_key: "k".into(), new_value: String::new(), - config: IndexMap::from([("FOO".into(), "1".into()), ("BAR".into(), "2".into())]), + original_config: config.clone(), + config, config_key_input: String::new(), config_value_input: String::new(), config_cursor: 0, @@ -3270,6 +3275,7 @@ mod tests { credential_key: "k".into(), new_value: String::new(), config: IndexMap::new(), + original_config: IndexMap::from([("FOO".into(), "1".into())]), config_key_input: String::new(), config_value_input: String::new(), config_cursor: 0, diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 513d8cd5bc..d7e1ae5f9f 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1702,6 +1702,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { let mut config: HashMap = form .config .iter() + .filter(|(k, v)| form.original_config.get(*k) != Some(*v)) .map(|(k, v)| (k.clone(), v.clone())) .collect(); form.deleted_keys.iter().for_each(|key| { From 69aa1a842ab32bca3a9282f95cc225f594167076 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 16:39:09 +0100 Subject: [PATCH 09/12] fix(tui): flush pending config input on provider update submit Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index c924db1439..01f9c29b66 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -2714,6 +2714,11 @@ impl App { }, UpdateProviderField::Submit => { if key.code == KeyCode::Enter { + flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ); if form.new_value.is_empty() && form.config.is_empty() && form.deleted_keys.is_empty() @@ -3318,4 +3323,43 @@ mod tests { assert_eq!(offset, 4); assert!(cursor >= offset && cursor < offset + window); } + + // -- pending input flush on submit --------------------------------- + + #[test] + fn pending_config_input_flushed_on_submit() { + let mut config = IndexMap::new(); + let mut key_input = "MY_KEY".to_string(); + let mut val_input = "my_val".to_string(); + flush_config_input(&mut config, &mut key_input, &mut val_input); + assert_eq!(config.get("MY_KEY"), Some(&"my_val".to_string())); + assert!(key_input.is_empty()); + assert!(val_input.is_empty()); + } + + // -- delta-only update request ------------------------------------- + + #[test] + fn update_request_contains_only_config_delta() { + let original_config: IndexMap = IndexMap::from([ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "2".to_string()), + ]); + let config: IndexMap = IndexMap::from([ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "changed".to_string()), + ]); + + let delta: HashMap = config + .iter() + .filter(|(k, v)| original_config.get(*k) != Some(*v)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + assert!(delta.contains_key("B"), "changed key must be in delta"); + assert!( + !delta.contains_key("A"), + "unchanged key must not be in delta" + ); + } } From 41f6b2d25462d381cb9e6cef707f0d38b878d825 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 18:43:56 +0100 Subject: [PATCH 10/12] fix(tui): fix provider config form loading, focus reset, and overflow Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 58 +++++++++++++++++-- crates/openshell-tui/src/lib.rs | 29 +++++----- .../openshell-tui/src/ui/create_provider.rs | 5 ++ 3 files changed, 73 insertions(+), 19 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 01f9c29b66..fa0b344210 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -2441,11 +2441,13 @@ impl App { }, ProviderKeyField::ConfigKeyValue => match key.code { KeyCode::Enter => { - flush_config_input( + if flush_config_input( &mut form.config, &mut form.config_key_input, &mut form.config_value_input, - ); + ) { + form.key_field = ProviderKeyField::ConfigKeyName; + } } _ => { Self::handle_text_input(&mut form.config_value_input, key); @@ -2459,6 +2461,11 @@ impl App { } ProviderKeyField::Submit => { if key.code == KeyCode::Enter { + flush_config_input( + &mut form.config, + &mut form.config_key_input, + &mut form.config_value_input, + ); // Validate and build credentials map. let mut creds = HashMap::new(); if form.is_generic { @@ -2702,11 +2709,13 @@ impl App { }, UpdateProviderField::ConfigValue => match key.code { KeyCode::Enter => { - flush_config_input( + if flush_config_input( &mut form.config, &mut form.config_key_input, &mut form.config_value_input, - ); + ) { + form.focus = UpdateProviderField::ConfigKey; + } } _ => { Self::handle_text_input(&mut form.config_value_input, key); @@ -3362,4 +3371,45 @@ mod tests { "unchanged key must not be in delta" ); } + + #[test] + fn deleted_key_tombstoned_readded_key_not_tombstoned() { + let original_config: IndexMap = IndexMap::from([ + ("DEL".to_string(), "old".to_string()), + ("READD".to_string(), "orig".to_string()), + ("KEEP".to_string(), "keep".to_string()), + ]); + // DEL was removed, READD was deleted then re-added with new value, KEEP unchanged. + let config: IndexMap = IndexMap::from([ + ("READD".to_string(), "new".to_string()), + ("KEEP".to_string(), "keep".to_string()), + ]); + + let mut request = config + .iter() + .filter(|(k, v)| original_config.get(*k) != Some(*v)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>(); + original_config + .keys() + .filter(|k| !config.contains_key(*k)) + .for_each(|key| { + request.insert(key.clone(), String::new()); + }); + + assert_eq!( + request.get("DEL"), + Some(&String::new()), + "DEL must be tombstoned" + ); + assert_eq!( + request.get("READD"), + Some(&"new".to_string()), + "READD must have new value, not tombstone" + ); + assert!( + !request.contains_key("KEEP"), + "KEEP unchanged must not be in delta" + ); + } } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index d7e1ae5f9f..f05f03f2f7 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1705,9 +1705,12 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { .filter(|(k, v)| form.original_config.get(*k) != Some(*v)) .map(|(k, v)| (k.clone(), v.clone())) .collect(); - form.deleted_keys.iter().for_each(|key| { - config.insert(key.clone(), String::new()); - }); + form.original_config + .keys() + .filter(|k| !form.config.contains_key(*k)) + .for_each(|key| { + config.insert(key.clone(), String::new()); + }); tokio::spawn(async move { let mut credentials = HashMap::new(); @@ -1970,18 +1973,14 @@ async fn refresh_providers(app: &mut App) { Ok(Ok(resp)) => { let providers = resp.into_inner().providers; app.provider_count = providers.len(); - app.provider_entries = if app.providers_v2_enabled { - providers - .iter() - .cloned() - .map(|provider| app::ProviderV2Entry { - profile: profiles.get(&provider.r#type).cloned(), - provider, - }) - .collect() - } else { - Vec::new() - }; + app.provider_entries = providers + .iter() + .cloned() + .map(|provider| app::ProviderV2Entry { + profile: profiles.get(&provider.r#type).cloned(), + provider, + }) + .collect(); app.provider_names = providers .iter() .map(|p| app::provider_name(p).to_string()) diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index 22d6279493..ef5a64b3a4 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -993,6 +993,11 @@ fn render_config_entries( }; let take_count = MAX_VISIBLE_CONFIG.min(total.saturating_sub(scroll_offset)); let overflow_below = scroll_offset + take_count < total; + let take_count = if overflow_below { + take_count.saturating_sub(1_usize) + } else { + take_count + }; let mut config_lines: Vec> = config .iter() From 91894dba7484df1b105faf5ede48b8af4a194cf7 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 19:57:50 +0100 Subject: [PATCH 11/12] fix(tui): separate config entry navigation from key input focus Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 231 ++++++++++++++---- .../openshell-tui/src/ui/create_provider.rs | 22 +- 2 files changed, 192 insertions(+), 61 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index fa0b344210..165d875010 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -348,6 +348,8 @@ pub enum ProviderKeyField { EnvVarName, /// Custom env var value (generic / no-known-env-vars types only). GenericValue, + /// Browsing/deleting existing config entries (Up/Down/Ctrl+D). + ConfigList, /// Config key name input. ConfigKeyName, /// Config key value input. @@ -523,6 +525,7 @@ pub struct UpdateProviderForm { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UpdateProviderField { CredentialValue, + ConfigEntry, ConfigKey, ConfigValue, Submit, @@ -2276,7 +2279,7 @@ impl App { } KeyCode::Tab => { if form.is_generic { - // Name → EnvVarName → GenericValue → ConfigKeyName → ConfigKeyValue → Submit → Name + // Name → EnvVarName → GenericValue → ConfigList → ConfigKeyName → ConfigKeyValue → Submit → Name match form.key_field { ProviderKeyField::Name => { form.key_field = ProviderKeyField::EnvVarName; @@ -2285,6 +2288,13 @@ impl App { form.key_field = ProviderKeyField::GenericValue; } ProviderKeyField::GenericValue => { + if form.config.is_empty() { + form.key_field = ProviderKeyField::ConfigKeyName; + } else { + form.key_field = ProviderKeyField::ConfigList; + } + } + ProviderKeyField::ConfigList => { form.key_field = ProviderKeyField::ConfigKeyName; } ProviderKeyField::ConfigKeyName => { @@ -2314,11 +2324,15 @@ impl App { } } } else { - // Name → Credential[0..N-1] → ConfigKeyName → ConfigKeyValue → Submit → Name + // Name → Credential[0..N-1] → [ConfigList →] ConfigKeyName → ConfigKeyValue → Submit → Name match form.key_field { ProviderKeyField::Name => { if form.credentials.is_empty() { - form.key_field = ProviderKeyField::ConfigKeyName; + if form.config.is_empty() { + form.key_field = ProviderKeyField::ConfigKeyName; + } else { + form.key_field = ProviderKeyField::ConfigList; + } } else { form.key_field = ProviderKeyField::Credential; form.cred_cursor = 0; @@ -2327,10 +2341,15 @@ impl App { ProviderKeyField::Credential => { if form.cred_cursor < form.credentials.len().saturating_sub(1) { form.cred_cursor += 1; + } else if !form.config.is_empty() { + form.key_field = ProviderKeyField::ConfigList; } else { form.key_field = ProviderKeyField::ConfigKeyName; } } + ProviderKeyField::ConfigList => { + form.key_field = ProviderKeyField::ConfigKeyName; + } ProviderKeyField::ConfigKeyName => { form.key_field = ProviderKeyField::ConfigKeyValue; } @@ -2364,7 +2383,14 @@ impl App { form.key_field = match form.key_field { ProviderKeyField::EnvVarName => ProviderKeyField::Name, ProviderKeyField::GenericValue => ProviderKeyField::EnvVarName, - ProviderKeyField::ConfigKeyName => ProviderKeyField::GenericValue, + ProviderKeyField::ConfigList => ProviderKeyField::GenericValue, + ProviderKeyField::ConfigKeyName => { + if form.config.is_empty() { + ProviderKeyField::GenericValue + } else { + ProviderKeyField::ConfigList + } + } ProviderKeyField::ConfigKeyValue => ProviderKeyField::ConfigKeyName, ProviderKeyField::Submit => ProviderKeyField::ConfigKeyValue, _ => ProviderKeyField::Submit, @@ -2378,17 +2404,27 @@ impl App { form.key_field = ProviderKeyField::Name; } } - ProviderKeyField::ConfigKeyValue => { - form.key_field = ProviderKeyField::ConfigKeyName; + ProviderKeyField::ConfigList => { + if form.credentials.is_empty() { + form.key_field = ProviderKeyField::Name; + } else { + form.key_field = ProviderKeyField::Credential; + form.cred_cursor = form.credentials.len().saturating_sub(1); + } } ProviderKeyField::ConfigKeyName => { - if form.credentials.is_empty() { + if !form.config.is_empty() { + form.key_field = ProviderKeyField::ConfigList; + } else if form.credentials.is_empty() { form.key_field = ProviderKeyField::Name; } else { form.key_field = ProviderKeyField::Credential; form.cred_cursor = form.credentials.len().saturating_sub(1); } } + ProviderKeyField::ConfigKeyValue => { + form.key_field = ProviderKeyField::ConfigKeyName; + } ProviderKeyField::Submit => { form.key_field = ProviderKeyField::ConfigKeyValue; } @@ -2405,6 +2441,33 @@ impl App { Self::handle_text_input(value, key); } } + ProviderKeyField::ConfigList => match key.code { + KeyCode::Up => { + form.config_cursor = form.config_cursor.saturating_sub(1); + } + KeyCode::Down if !form.config.is_empty() => { + form.config_cursor = + (form.config_cursor + 1).min(form.config.len() - 1); + } + KeyCode::Char('d') + if key.modifiers.contains(KeyModifiers::CONTROL) + && !form.config.is_empty() => + { + let key_to_remove = form + .config + .keys() + .nth(form.config_cursor) + .cloned() + .unwrap_or_default(); + form.config.shift_remove(&key_to_remove); + form.config_cursor = + form.config_cursor.min(form.config.len().saturating_sub(1)); + if form.config.is_empty() { + form.key_field = ProviderKeyField::ConfigKeyName; + } + } + _ => {} + }, ProviderKeyField::ConfigKeyName => match key.code { KeyCode::Enter => { flush_config_input( @@ -2413,28 +2476,6 @@ impl App { &mut form.config_value_input, ); } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - if !form.config.is_empty() { - let key_to_remove = form - .config - .keys() - .nth(form.config_cursor) - .cloned() - .unwrap_or_default(); - form.config.shift_remove(&key_to_remove); - form.config_cursor = - form.config_cursor.min(form.config.len().saturating_sub(1)); - } - } - KeyCode::Up => { - form.config_cursor = form.config_cursor.saturating_sub(1); - } - KeyCode::Down => { - if !form.config.is_empty() { - form.config_cursor = - (form.config_cursor + 1).min(form.config.len() - 1); - } - } _ => { Self::handle_text_input(&mut form.config_key_input, key); } @@ -2466,6 +2507,15 @@ impl App { &mut form.config_key_input, &mut form.config_value_input, ); + if !form.config_key_input.is_empty() + || !form.config_value_input.is_empty() + { + form.status = Some( + "Both key and value are required to add config entry." + .to_string(), + ); + return; + } // Validate and build credentials map. let mut creds = HashMap::new(); if form.is_generic { @@ -2629,6 +2679,13 @@ impl App { } KeyCode::Tab => match form.focus { UpdateProviderField::CredentialValue => { + if form.config.is_empty() { + form.focus = UpdateProviderField::ConfigKey; + } else { + form.focus = UpdateProviderField::ConfigEntry; + } + } + UpdateProviderField::ConfigEntry => { form.focus = UpdateProviderField::ConfigKey; } UpdateProviderField::ConfigKey => { @@ -2658,9 +2715,16 @@ impl App { UpdateProviderField::CredentialValue => { form.focus = UpdateProviderField::Submit; } - UpdateProviderField::ConfigKey => { + UpdateProviderField::ConfigEntry => { form.focus = UpdateProviderField::CredentialValue; } + UpdateProviderField::ConfigKey => { + if form.config.is_empty() { + form.focus = UpdateProviderField::CredentialValue; + } else { + form.focus = UpdateProviderField::ConfigEntry; + } + } UpdateProviderField::ConfigValue => { form.focus = UpdateProviderField::ConfigKey; } @@ -2672,6 +2736,33 @@ impl App { UpdateProviderField::CredentialValue => { Self::handle_text_input(&mut form.new_value, key); } + UpdateProviderField::ConfigEntry => match key.code { + KeyCode::Up => { + form.config_cursor = form.config_cursor.saturating_sub(1); + } + KeyCode::Down if !form.config.is_empty() => { + form.config_cursor = (form.config_cursor + 1).min(form.config.len() - 1); + } + KeyCode::Char('d') + if key.modifiers.contains(KeyModifiers::CONTROL) + && !form.config.is_empty() => + { + let key_to_remove = form + .config + .keys() + .nth(form.config_cursor) + .cloned() + .unwrap_or_default(); + form.deleted_keys.push(key_to_remove.clone()); + form.config.shift_remove(&key_to_remove); + form.config_cursor = + form.config_cursor.min(form.config.len().saturating_sub(1)); + if form.config.is_empty() { + form.focus = UpdateProviderField::ConfigKey; + } + } + _ => {} + }, UpdateProviderField::ConfigKey => match key.code { KeyCode::Enter => { flush_config_input( @@ -2680,29 +2771,6 @@ impl App { &mut form.config_value_input, ); } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - if !form.config.is_empty() { - let key_to_remove = form - .config - .keys() - .nth(form.config_cursor) - .cloned() - .unwrap_or_default(); - form.deleted_keys.push(key_to_remove.clone()); - form.config.shift_remove(&key_to_remove); - form.config_cursor = - form.config_cursor.min(form.config.len().saturating_sub(1)); - } - } - KeyCode::Up => { - form.config_cursor = form.config_cursor.saturating_sub(1); - } - KeyCode::Down => { - if !form.config.is_empty() { - form.config_cursor = - (form.config_cursor + 1).min(form.config.len() - 1); - } - } _ => { Self::handle_text_input(&mut form.config_key_input, key); } @@ -2728,6 +2796,13 @@ impl App { &mut form.config_key_input, &mut form.config_value_input, ); + if !form.config_key_input.is_empty() || !form.config_value_input.is_empty() + { + form.status = Some( + "Both key and value are required to add config entry.".to_string(), + ); + return; + } if form.new_value.is_empty() && form.config.is_empty() && form.deleted_keys.is_empty() @@ -3412,4 +3487,54 @@ mod tests { "KEEP unchanged must not be in delta" ); } + + // -- partial config input on submit -------------------------------- + + #[test] + fn submit_guard_triggers_when_key_filled_value_empty() { + let mut config = IndexMap::new(); + let mut key_input = "FOO".to_string(); + let mut val_input = String::new(); + + let flushed = flush_config_input(&mut config, &mut key_input, &mut val_input); + + assert!(!flushed, "flush must fail when value is empty"); + assert!( + !key_input.is_empty() || !val_input.is_empty(), + "submit guard must detect partial input" + ); + assert!(config.is_empty(), "config must not be modified"); + } + + #[test] + fn submit_guard_triggers_when_value_filled_key_empty() { + let mut config = IndexMap::new(); + let mut key_input = String::new(); + let mut val_input = "bar".to_string(); + + let flushed = flush_config_input(&mut config, &mut key_input, &mut val_input); + + assert!(!flushed, "flush must fail when key is empty"); + assert!( + !key_input.is_empty() || !val_input.is_empty(), + "submit guard must detect partial input" + ); + assert!(config.is_empty(), "config must not be modified"); + } + + #[test] + fn submit_guard_clear_when_both_filled() { + let mut config = IndexMap::new(); + let mut key_input = "FOO".to_string(); + let mut val_input = "bar".to_string(); + + let flushed = flush_config_input(&mut config, &mut key_input, &mut val_input); + + assert!(flushed, "flush must succeed when both fields filled"); + assert!( + key_input.is_empty() && val_input.is_empty(), + "submit guard must not trigger after successful flush" + ); + assert_eq!(config.get("FOO"), Some(&"bar".to_string())); + } } diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index ef5a64b3a4..cb39bff341 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -384,11 +384,13 @@ fn draw_enter_key( idx += 1; // Config Keys label. - let config_focused = matches!( + let config_section_focused = matches!( form.key_field, - ProviderKeyField::ConfigKeyName | ProviderKeyField::ConfigKeyValue + ProviderKeyField::ConfigList + | ProviderKeyField::ConfigKeyName + | ProviderKeyField::ConfigKeyValue ); - let header_style = if config_focused { + let header_style = if config_section_focused { t.accent_bold } else { t.muted @@ -401,11 +403,12 @@ fn draw_enter_key( // Existing config entries. if !form.config.is_empty() { + let config_entry_active = form.key_field == ProviderKeyField::ConfigList; render_config_entries( frame, &form.config, form.config_cursor, - config_focused, + config_entry_active, chunks[idx], t, ); @@ -839,11 +842,13 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { idx += 1; // Config Keys label. - let config_focused = matches!( + let config_section_focused = matches!( form.focus, - UpdateProviderField::ConfigKey | UpdateProviderField::ConfigValue + UpdateProviderField::ConfigEntry + | UpdateProviderField::ConfigKey + | UpdateProviderField::ConfigValue ); - let header_style = if config_focused { + let header_style = if config_section_focused { t.accent_bold } else { t.muted @@ -856,11 +861,12 @@ pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { // Existing config entries. if !form.config.is_empty() { + let config_entry_active = form.focus == UpdateProviderField::ConfigEntry; render_config_entries( frame, &form.config, form.config_cursor, - config_focused, + config_entry_active, chunks[idx], t, ); From 8b854921f4fe63395c0147ed54dbeedb6c02dcc3 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 21:13:23 +0100 Subject: [PATCH 12/12] fix(tui): move config cursor to newly added entry after flush Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 165d875010..0977c07aa6 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -2292,6 +2292,7 @@ impl App { form.key_field = ProviderKeyField::ConfigKeyName; } else { form.key_field = ProviderKeyField::ConfigList; + form.config_cursor = 0_usize; } } ProviderKeyField::ConfigList => { @@ -2307,6 +2308,7 @@ impl App { &mut form.config_value_input, ) { form.key_field = ProviderKeyField::ConfigKeyName; + form.config_cursor = form.config.len().saturating_sub(1_usize); } else if form.config_key_input.is_empty() && form.config_value_input.is_empty() { @@ -2332,10 +2334,11 @@ impl App { form.key_field = ProviderKeyField::ConfigKeyName; } else { form.key_field = ProviderKeyField::ConfigList; + form.config_cursor = 0_usize; } } else { form.key_field = ProviderKeyField::Credential; - form.cred_cursor = 0; + form.cred_cursor = 0_usize; } } ProviderKeyField::Credential => { @@ -2343,6 +2346,7 @@ impl App { form.cred_cursor += 1; } else if !form.config.is_empty() { form.key_field = ProviderKeyField::ConfigList; + form.config_cursor = 0_usize; } else { form.key_field = ProviderKeyField::ConfigKeyName; } @@ -2360,6 +2364,7 @@ impl App { &mut form.config_value_input, ) { form.key_field = ProviderKeyField::ConfigKeyName; + form.config_cursor = form.config.len().saturating_sub(1_usize); } else if form.config_key_input.is_empty() && form.config_value_input.is_empty() { @@ -2488,6 +2493,7 @@ impl App { &mut form.config_value_input, ) { form.key_field = ProviderKeyField::ConfigKeyName; + form.config_cursor = form.config.len().saturating_sub(1_usize); } } _ => { @@ -2683,6 +2689,7 @@ impl App { form.focus = UpdateProviderField::ConfigKey; } else { form.focus = UpdateProviderField::ConfigEntry; + form.config_cursor = 0_usize; } } UpdateProviderField::ConfigEntry => { @@ -2698,6 +2705,7 @@ impl App { &mut form.config_value_input, ) { form.focus = UpdateProviderField::ConfigKey; + form.config_cursor = form.config.len().saturating_sub(1_usize); } else if form.config_key_input.is_empty() && form.config_value_input.is_empty() { form.focus = UpdateProviderField::Submit; @@ -2783,6 +2791,7 @@ impl App { &mut form.config_value_input, ) { form.focus = UpdateProviderField::ConfigKey; + form.config_cursor = form.config.len().saturating_sub(1_usize); } } _ => { @@ -2803,10 +2812,7 @@ impl App { ); return; } - if form.new_value.is_empty() - && form.config.is_empty() - && form.deleted_keys.is_empty() - { + if form.new_value.is_empty() && form.config == form.original_config { form.status = Some("Credential value or config keys required.".to_string()); return;