agent_ui: Make input fields in Bedrock settings keyboard navigable (#42916)

Danilo Leal created

Closes https://github.com/zed-industries/zed/issues/36587

This PR enables jumping from one input to the other, in the Bedrock
settings section, with tab.

Release Notes:

- N/A

Change summary

crates/language_models/src/provider/bedrock.rs | 74 +++++++++++++++----
crates/ui_input/src/input_field.rs             | 27 +++++++
crates/zed/src/zed.rs                          |  1 
3 files changed, 87 insertions(+), 15 deletions(-)

Detailed changes

crates/language_models/src/provider/bedrock.rs 🔗

@@ -24,7 +24,10 @@ use bedrock::{
 use collections::{BTreeMap, HashMap};
 use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream};
-use gpui::{AnyView, App, AsyncApp, Context, Entity, FontWeight, Subscription, Task};
+use gpui::{
+    AnyView, App, AsyncApp, Context, Entity, FocusHandle, FontWeight, Subscription, Task, Window,
+    actions,
+};
 use gpui_tokio::Tokio;
 use http_client::HttpClient;
 use language_model::{
@@ -47,6 +50,8 @@ use util::ResultExt;
 
 use crate::AllLanguageModelSettings;
 
+actions!(bedrock, [Tab, TabPrev]);
+
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("amazon-bedrock");
 const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Amazon Bedrock");
 
@@ -1012,6 +1017,7 @@ struct ConfigurationView {
     region_editor: Entity<InputField>,
     state: Entity<State>,
     load_credentials_task: Option<Task<()>>,
+    focus_handle: FocusHandle,
 }
 
 impl ConfigurationView {
@@ -1022,11 +1028,41 @@ impl ConfigurationView {
     const PLACEHOLDER_REGION: &'static str = "us-east-1";
 
     fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let focus_handle = cx.focus_handle();
+
         cx.observe(&state, |_, _, cx| {
             cx.notify();
         })
         .detach();
 
+        let access_key_id_editor = cx.new(|cx| {
+            InputField::new(window, cx, Self::PLACEHOLDER_ACCESS_KEY_ID_TEXT)
+                .label("Access Key ID")
+                .tab_index(0)
+                .tab_stop(true)
+        });
+
+        let secret_access_key_editor = cx.new(|cx| {
+            InputField::new(window, cx, Self::PLACEHOLDER_SECRET_ACCESS_KEY_TEXT)
+                .label("Secret Access Key")
+                .tab_index(1)
+                .tab_stop(true)
+        });
+
+        let session_token_editor = cx.new(|cx| {
+            InputField::new(window, cx, Self::PLACEHOLDER_SESSION_TOKEN_TEXT)
+                .label("Session Token (Optional)")
+                .tab_index(2)
+                .tab_stop(true)
+        });
+
+        let region_editor = cx.new(|cx| {
+            InputField::new(window, cx, Self::PLACEHOLDER_REGION)
+                .label("Region")
+                .tab_index(3)
+                .tab_stop(true)
+        });
+
         let load_credentials_task = Some(cx.spawn({
             let state = state.clone();
             async move |this, cx| {
@@ -1046,22 +1082,13 @@ impl ConfigurationView {
         }));
 
         Self {
-            access_key_id_editor: cx.new(|cx| {
-                InputField::new(window, cx, Self::PLACEHOLDER_ACCESS_KEY_ID_TEXT)
-                    .label("Access Key ID")
-            }),
-            secret_access_key_editor: cx.new(|cx| {
-                InputField::new(window, cx, Self::PLACEHOLDER_SECRET_ACCESS_KEY_TEXT)
-                    .label("Secret Access Key")
-            }),
-            session_token_editor: cx.new(|cx| {
-                InputField::new(window, cx, Self::PLACEHOLDER_SESSION_TOKEN_TEXT)
-                    .label("Session Token (Optional)")
-            }),
-            region_editor: cx
-                .new(|cx| InputField::new(window, cx, Self::PLACEHOLDER_REGION).label("Region")),
+            access_key_id_editor,
+            secret_access_key_editor,
+            session_token_editor,
+            region_editor,
             state,
             load_credentials_task,
+            focus_handle,
         }
     }
 
@@ -1141,6 +1168,19 @@ impl ConfigurationView {
     fn should_render_editor(&self, cx: &Context<Self>) -> bool {
         self.state.read(cx).is_authenticated()
     }
+
+    fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context<Self>) {
+        window.focus_next();
+    }
+
+    fn on_tab_prev(
+        &mut self,
+        _: &menu::SelectPrevious,
+        window: &mut Window,
+        _: &mut Context<Self>,
+    ) {
+        window.focus_prev();
+    }
 }
 
 impl Render for ConfigurationView {
@@ -1190,6 +1230,9 @@ impl Render for ConfigurationView {
 
         v_flex()
             .size_full()
+            .track_focus(&self.focus_handle)
+            .on_action(cx.listener(Self::on_tab))
+            .on_action(cx.listener(Self::on_tab_prev))
             .on_action(cx.listener(ConfigurationView::save_credentials))
             .child(Label::new("To use Zed's agent with Bedrock, you can set a custom authentication strategy through the settings.json, or use static credentials."))
             .child(Label::new("But, to access models on AWS, you need to:").mt_1())
@@ -1234,6 +1277,7 @@ impl ConfigurationView {
     fn render_static_credentials_ui(&self) -> impl IntoElement {
         v_flex()
             .my_2()
+            .tab_group()
             .gap_1p5()
             .child(
                 Label::new("Static Keys")

crates/ui_input/src/input_field.rs 🔗

@@ -37,6 +37,10 @@ pub struct InputField {
     disabled: bool,
     /// The minimum width of for the input
     min_width: Length,
+    /// The tab index for keyboard navigation order.
+    tab_index: Option<isize>,
+    /// Whether this field is a tab stop (can be focused via Tab key).
+    tab_stop: bool,
 }
 
 impl Focusable for InputField {
@@ -63,6 +67,8 @@ impl InputField {
             start_icon: None,
             disabled: false,
             min_width: px(192.).into(),
+            tab_index: None,
+            tab_stop: true,
         }
     }
 
@@ -86,6 +92,16 @@ impl InputField {
         self
     }
 
+    pub fn tab_index(mut self, index: isize) -> Self {
+        self.tab_index = Some(index);
+        self
+    }
+
+    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
+        self.tab_stop = tab_stop;
+        self
+    }
+
     pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
         self.disabled = disabled;
         self.editor
@@ -151,6 +167,16 @@ impl Render for InputField {
             ..Default::default()
         };
 
+        let focus_handle = self.editor.focus_handle(cx);
+
+        let configured_handle = if let Some(tab_index) = self.tab_index {
+            focus_handle.tab_index(tab_index).tab_stop(self.tab_stop)
+        } else if !self.tab_stop {
+            focus_handle.tab_stop(false)
+        } else {
+            focus_handle
+        };
+
         v_flex()
             .id(self.placeholder.clone())
             .w_full()
@@ -168,6 +194,7 @@ impl Render for InputField {
             })
             .child(
                 h_flex()
+                    .track_focus(&configured_handle)
                     .min_w(self.min_width)
                     .min_h_8()
                     .w_full()

crates/zed/src/zed.rs 🔗

@@ -4702,6 +4702,7 @@ mod tests {
                 "assistant",
                 "assistant2",
                 "auto_update",
+                "bedrock",
                 "branches",
                 "buffer_search",
                 "channel_modal",