From b3e31606855634244ffed850147143af39c76709 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Tue, 7 Jul 2026 17:37:56 +0100 Subject: [PATCH 01/13] 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 b412467610..268da3982d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4354,6 +4354,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 7081fe97e2..4ec6a0d44f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,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 caa2ec1ab4..c1c64d83cb 100644 --- a/crates/openshell-tui/Cargo.toml +++ b/crates/openshell-tui/Cargo.toml @@ -26,6 +26,7 @@ miette = { workspace = true } owo-colors = { workspace = true } serde = { 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 6db3ac14f2..2ce8d42939 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; @@ -348,6 +349,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, } @@ -364,6 +369,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. @@ -2285,6 +2296,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, @@ -2393,6 +2408,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(); } @@ -2406,11 +2425,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; @@ -2420,7 +2439,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; } } _ => { @@ -2446,7 +2485,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 { @@ -2454,6 +2496,9 @@ impl App { form.cred_cursor = form.credentials.len().saturating_sub(1); } } + ProviderKeyField::Submit => { + form.key_field = ProviderKeyField::ConfigKeyValue; + } _ => { form.key_field = ProviderKeyField::Submit; } @@ -2467,6 +2512,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 0d9d9fe613..2657f7fabd 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1647,6 +1647,11 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { }; let credentials = form.discovered_credentials.clone().unwrap_or_default(); let workspace = app.current_workspace.clone(); + 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. @@ -1671,7 +1676,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(), profile_workspace: workspace.clone(), }), 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 c83258bfef07bcfa06d693c68ad66743c9300ede Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Wed, 8 Jul 2026 12:06:22 +0100 Subject: [PATCH 02/13] 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 2ce8d42939..dea50b8fd6 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}; @@ -369,11 +371,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, @@ -509,9 +513,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 // --------------------------------------------------------------------------- @@ -2532,9 +2549,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)); } } _ => { @@ -2677,6 +2693,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() { @@ -2694,6 +2721,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, }); } @@ -2707,18 +2739,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 2657f7fabd..72391a2bb5 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1756,6 +1756,11 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { let cred_key = form.credential_key.clone(); let new_value = form.new_value.clone(); let workspace = app.selected_provider_workspace(); + let config: HashMap = form + .config + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); tokio::spawn(async move { let mut credentials = HashMap::new(); @@ -1775,7 +1780,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { }), r#type: ptype, credentials, - config: HashMap::default(), + config, credential_expires_at_ms: HashMap::default(), profile_workspace: String::new(), }), 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 574fded41c0a494ade89d838474c88a0a04b47dd Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 10 Jul 2026 15:38:27 +0100 Subject: [PATCH 03/13] 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 dea50b8fd6..9d1aa01f5e 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}; @@ -2553,6 +2551,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); } @@ -2805,6 +2812,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); } @@ -2825,8 +2841,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 72391a2bb5..3297e246dd 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1764,7 +1764,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 4a3020aef567b7a6a934ed763b7213d2a1ca20ba Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 10 Jul 2026 15:43:17 +0100 Subject: [PATCH 04/13] 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 9d1aa01f5e..531cf515a4 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -3005,7 +3005,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 64f88179e097df00d1be99b1a693bd63dfac5464 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 10 Jul 2026 16:38:06 +0100 Subject: [PATCH 05/13] 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 531cf515a4..a4674516f1 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -527,6 +527,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 // --------------------------------------------------------------------------- @@ -2432,13 +2448,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 { @@ -2461,19 +2507,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; } } @@ -2488,7 +2536,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 { @@ -2529,14 +2579,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() { @@ -2566,14 +2613,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); @@ -2754,16 +2798,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; } } @@ -2791,13 +2837,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() { @@ -2827,13 +2871,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); @@ -2841,8 +2883,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; @@ -3395,4 +3438,35 @@ mod tests { let result = workspaces.get(selected).unwrap_or(¤t); assert_eq!(*result, "default"); } + + #[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 6b4514a225963c39c87583c4ad67194c0f3de782 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 15:30:31 +0100 Subject: [PATCH 06/13] 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 a4674516f1..820afe444f 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -515,6 +515,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, } @@ -2776,6 +2777,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, }); @@ -2851,6 +2853,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)); @@ -2883,7 +2886,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 3297e246dd..0c70598be5 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1756,11 +1756,14 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { let cred_key = form.credential_key.clone(); let new_value = form.new_value.clone(); let workspace = app.selected_provider_workspace(); - 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 b24dc5fa7fda0156a58490d7651bd277c2b7ecff Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 16:04:28 +0100 Subject: [PATCH 07/13] 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 820afe444f..eab293eb40 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -3475,4 +3475,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 b07a933d21fa7606f5ebca323657f7b6f1a22724 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 16:31:38 +0100 Subject: [PATCH 08/13] 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 eab293eb40..e1b412944a 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -512,6 +512,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, @@ -2773,7 +2774,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, @@ -3480,12 +3482,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, @@ -3510,6 +3515,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 0c70598be5..9f576af8b3 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1759,6 +1759,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 f89bdf7abd01b39f63bca688c879c9db069c1437 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 16:39:09 +0100 Subject: [PATCH 09/13] 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 e1b412944a..ccce414408 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -2888,6 +2888,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() @@ -3558,4 +3563,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 5e1fdd8a757ebcc4388b07a774334e246315c324 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 18:43:56 +0100 Subject: [PATCH 10/13] 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 | 9 ++- .../openshell-tui/src/ui/create_provider.rs | 5 ++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index ccce414408..06ff8be3e6 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -2615,11 +2615,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); @@ -2633,6 +2635,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 { @@ -2876,11 +2883,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); @@ -3602,4 +3611,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 9f576af8b3..227a7e7615 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1762,9 +1762,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(); 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 a208dd84a8a49c3856657ad62b25fe45f39d50e6 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 19:57:50 +0100 Subject: [PATCH 11/13] 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 06ff8be3e6..5a6436ec9b 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -349,6 +349,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. @@ -524,6 +526,7 @@ pub struct UpdateProviderForm { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UpdateProviderField { CredentialValue, + ConfigEntry, ConfigKey, ConfigValue, Submit, @@ -2450,7 +2453,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; @@ -2459,6 +2462,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 => { @@ -2488,11 +2498,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; @@ -2501,10 +2515,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; } @@ -2538,7 +2557,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, @@ -2552,17 +2578,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; } @@ -2579,6 +2615,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( @@ -2587,28 +2650,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); } @@ -2640,6 +2681,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 { @@ -2803,6 +2853,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 => { @@ -2832,9 +2889,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; } @@ -2846,6 +2910,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( @@ -2854,29 +2945,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); } @@ -2902,6 +2970,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() @@ -3652,4 +3727,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 dac55cbb154ee591ab2a92b67578630a85ba7fde Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Fri, 17 Jul 2026 21:13:23 +0100 Subject: [PATCH 12/13] fix(tui): move config cursor to newly added entry after flush Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 83 +++++++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 20 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 5a6436ec9b..a117e1d0f7 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -2466,10 +2466,15 @@ impl App { form.key_field = ProviderKeyField::ConfigKeyName; } else { form.key_field = ProviderKeyField::ConfigList; + form.config_cursor = 0_usize; } } ProviderKeyField::ConfigList => { - form.key_field = ProviderKeyField::ConfigKeyName; + if form.config_cursor < form.config.len().saturating_sub(1) { + form.config_cursor += 1; + } else { + form.key_field = ProviderKeyField::ConfigKeyName; + } } ProviderKeyField::ConfigKeyName => { form.key_field = ProviderKeyField::ConfigKeyValue; @@ -2481,6 +2486,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() { @@ -2506,10 +2512,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 => { @@ -2517,12 +2524,17 @@ 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; } } ProviderKeyField::ConfigList => { - form.key_field = ProviderKeyField::ConfigKeyName; + if form.config_cursor < form.config.len().saturating_sub(1) { + form.config_cursor += 1_usize; + } else { + form.key_field = ProviderKeyField::ConfigKeyName; + } } ProviderKeyField::ConfigKeyName => { form.key_field = ProviderKeyField::ConfigKeyValue; @@ -2534,6 +2546,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() { @@ -2554,21 +2567,38 @@ impl App { } KeyCode::BackTab => { if form.is_generic { - form.key_field = match form.key_field { - ProviderKeyField::EnvVarName => ProviderKeyField::Name, - ProviderKeyField::GenericValue => ProviderKeyField::EnvVarName, - ProviderKeyField::ConfigList => ProviderKeyField::GenericValue, + match form.key_field { + ProviderKeyField::EnvVarName => { + form.key_field = ProviderKeyField::Name; + } + ProviderKeyField::GenericValue => { + form.key_field = ProviderKeyField::EnvVarName; + } + ProviderKeyField::ConfigList => { + if form.config_cursor > 0 { + form.config_cursor -= 1_usize; + } else { + form.key_field = ProviderKeyField::GenericValue; + } + } ProviderKeyField::ConfigKeyName => { if form.config.is_empty() { - ProviderKeyField::GenericValue + form.key_field = ProviderKeyField::GenericValue; } else { - ProviderKeyField::ConfigList + form.config_cursor = form.config.len().saturating_sub(1); + form.key_field = ProviderKeyField::ConfigList; } } - ProviderKeyField::ConfigKeyValue => ProviderKeyField::ConfigKeyName, - ProviderKeyField::Submit => ProviderKeyField::ConfigKeyValue, - _ => ProviderKeyField::Submit, - }; + ProviderKeyField::ConfigKeyValue => { + form.key_field = ProviderKeyField::ConfigKeyName; + } + ProviderKeyField::Submit => { + form.key_field = ProviderKeyField::ConfigKeyValue; + } + _ => { + form.key_field = ProviderKeyField::Submit; + } + } } else { match form.key_field { ProviderKeyField::Credential => { @@ -2579,7 +2609,9 @@ impl App { } } ProviderKeyField::ConfigList => { - if form.credentials.is_empty() { + if form.config_cursor > 0 { + form.config_cursor -= 1; + } else if form.credentials.is_empty() { form.key_field = ProviderKeyField::Name; } else { form.key_field = ProviderKeyField::Credential; @@ -2588,6 +2620,7 @@ impl App { } ProviderKeyField::ConfigKeyName => { if !form.config.is_empty() { + form.config_cursor = form.config.len().saturating_sub(1); form.key_field = ProviderKeyField::ConfigList; } else if form.credentials.is_empty() { form.key_field = ProviderKeyField::Name; @@ -2662,6 +2695,7 @@ impl App { &mut form.config_value_input, ) { form.key_field = ProviderKeyField::ConfigKeyName; + form.config_cursor = form.config.len().saturating_sub(1_usize); } } _ => { @@ -2857,10 +2891,15 @@ impl App { form.focus = UpdateProviderField::ConfigKey; } else { form.focus = UpdateProviderField::ConfigEntry; + form.config_cursor = 0_usize; } } UpdateProviderField::ConfigEntry => { - form.focus = UpdateProviderField::ConfigKey; + if form.config_cursor < form.config.len().saturating_sub(1) { + form.config_cursor += 1_usize; + } else { + form.focus = UpdateProviderField::ConfigKey; + } } UpdateProviderField::ConfigKey => { form.focus = UpdateProviderField::ConfigValue; @@ -2872,6 +2911,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; @@ -2890,13 +2930,18 @@ impl App { form.focus = UpdateProviderField::Submit; } UpdateProviderField::ConfigEntry => { - form.focus = UpdateProviderField::CredentialValue; + if form.config_cursor > 0 { + form.config_cursor -= 1_usize; + } else { + form.focus = UpdateProviderField::CredentialValue; + } } UpdateProviderField::ConfigKey => { if form.config.is_empty() { form.focus = UpdateProviderField::CredentialValue; } else { form.focus = UpdateProviderField::ConfigEntry; + form.config_cursor = form.config.len().saturating_sub(1); } } UpdateProviderField::ConfigValue => { @@ -2957,6 +3002,7 @@ impl App { &mut form.config_value_input, ) { form.focus = UpdateProviderField::ConfigKey; + form.config_cursor = form.config.len().saturating_sub(1_usize); } } _ => { @@ -2977,10 +3023,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; From c34a3cb08fe2c00ccb4cb927b90ec8dd9056b56f Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Tue, 21 Jul 2026 16:13:38 +0100 Subject: [PATCH 13/13] fix(tui): always populate provider_entries regardless of providers_v2_enabled Signed-off-by: Artem Lytvyn --- crates/openshell-tui/src/app.rs | 6 +++--- crates/openshell-tui/src/lib.rs | 24 ++++++++++-------------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index a117e1d0f7..9e3b8575b5 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -426,12 +426,12 @@ pub struct ProviderDetailView { } #[derive(Clone)] -pub struct ProviderV2Entry { +pub struct ProviderListEntry { pub provider: openshell_core::proto::Provider, pub profile: Option, } -impl ProviderV2Entry { +impl ProviderListEntry { pub fn name(&self) -> &str { provider_name(&self.provider) } @@ -585,7 +585,7 @@ pub struct App { // Provider list pub providers_v2_enabled: bool, - pub provider_entries: Vec, + pub provider_entries: Vec, pub provider_names: Vec, pub provider_types: Vec, pub provider_cred_keys: Vec, diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 227a7e7615..b3937e2b90 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -2078,20 +2078,16 @@ async fn refresh_providers(app: &mut App) { }; app.provider_count = providers.len(); - app.provider_entries = if app.providers_v2_enabled { - providers - .iter() - .cloned() - .map(|provider| app::ProviderV2Entry { - profile: profiles - .get(&(provider.profile_workspace.clone(), provider.r#type.clone())) - .cloned(), - provider, - }) - .collect() - } else { - Vec::new() - }; + app.provider_entries = providers + .iter() + .cloned() + .map(|provider| app::ProviderListEntry { + profile: profiles + .get(&(provider.profile_workspace.clone(), provider.r#type.clone())) + .cloned(), + provider, + }) + .collect(); app.provider_names = providers .iter() .map(|p| app::provider_name(p).to_string())