From 64e0d7d5c7a0c40c60226c12b94c691d2de56d81 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Tue, 7 Jul 2026 17:37:56 +0100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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, + ); + } +}