Vectrum
Sign In

Polish the settings: narrow providers, custom model names, Esc

On narrow windows the provider list sits over the editor, and the
editor's rows stack by its own width. Custom models can be renamed.
Esc clears a settings search before it leaves settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent fb6b7f6commit29cc77e

Showing 3 changed files with 108 additions and 20 deletions.

+10−6src/ui/settings/mod.rsView File
@@ -427,17 +427,13 @@ impl SettingsView {
427427 .gap(px(8.))
428428 .rounded(px(12.))
429429 .well(p)
430- .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
430+ .capture_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| {
431431 if this.query(cx).is_empty() {
432432 return;
433433 }
434434 match event.keystroke.key.as_str() {
435435 "down" => this.move_result(1, cx),
436436 "up" => this.move_result(-1, cx),
437- "escape" => {
438- this.clear_search(window, cx);
439- cx.notify();
440- }
441437 _ => return,
442438 }
443439 cx.stop_propagation();
@@ -740,7 +736,15 @@ impl Render for SettingsView {
740736 .track_focus(&self.focus_handle)
741737 .size_full()
742738 .relative()
743- .on_action(cx.listener(|this, _: &CloseSettings, _, cx| this.close(cx)))
739+ // Esc clears a search first, then goes back to the threads.
740+ .on_action(cx.listener(|this, _: &CloseSettings, window, cx| {
741+ if this.query(cx).is_empty() {
742+ this.close(cx);
743+ } else {
744+ this.clear_search(window, cx);
745+ cx.notify();
746+ }
747+ }))
744748 .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
745749 let keystroke = &event.keystroke;
746750 let plain = !keystroke.modifiers.control && !keystroke.modifiers.alt && !keystroke.modifiers.platform;
+92−13src/ui/settings/providers.rsView File
@@ -17,7 +17,7 @@ use gpui_kit::prelude::FluentBuilder as _;
1717 use gpui_kit::*;
1818
1919 use super::SettingsView;
20- use super::widgets::{Section, SettingRow, WIDE_WIDTH, ago, code, focus_later, note, reset, search_field, stepper, text_field};
20+ use super::widgets::{Section, SettingRow, WIDE_WIDTH, ago, code, content_width, focus_later, note, reset, search_field, stepper, text_field};
2121 use crate::claude;
2222 use crate::models::{self, Catalog, InstanceState, ModelEntry, ProbeStatus, Provider};
2323 use crate::settings::{self, CustomModel, EnvVar, ProviderInstance};
@@ -52,6 +52,8 @@ struct ProviderForm {
5252 custom_slug: Entity<InputState>,
5353 adding_custom: bool,
5454 custom_error: Option<SharedString>,
55+ /// The custom model whose display name is being edited, and the field.
56+ editing_custom: Option<(String, Entity<InputState>)>,
5557 accent_open: bool,
5658 auto_compact_error: bool,
5759 _subscriptions: Vec<Subscription>,
@@ -237,6 +239,7 @@ impl SettingsView {
237239 custom_slug,
238240 adding_custom: false,
239241 custom_error: None,
242+ editing_custom: None,
240243 accent_open: false,
241244 auto_compact_error: false,
242245 _subscriptions: subscriptions,
@@ -448,6 +451,42 @@ impl SettingsView {
448451 self.edit_instance(&id, window, cx, |instance| instance.custom_models.push(CustomModel { slug, name: String::new() }));
449452 }
450453
454+ /// Opens the display name field under a custom model.
455+ fn edit_custom_model(&mut self, slug: String, window: &mut Window, cx: &mut Context<Self>) {
456+ let Some(id) = self.providers.form.as_ref().map(|form| form.id.clone()) else { return };
457+ let name = settings::get(|settings| {
458+ settings
459+ .instance(&id)
460+ .and_then(|instance| instance.custom_models.iter().find(|custom| custom.slug == slug))
461+ .map(|custom| custom.name.clone())
462+ })
463+ .unwrap_or_default();
464+ let input = self.instance_input(&name, &slug, window, cx);
465+ let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, window, cx| {
466+ if matches!(event, InputEvent::PressEnter { .. }) {
467+ this.save_custom_name(window, cx);
468+ }
469+ });
470+ focus_later(&input, window);
471+ if let Some(form) = self.providers.form.as_mut() {
472+ form.editing_custom = Some((slug, input));
473+ form._subscriptions.push(subscription);
474+ }
475+ cx.notify();
476+ }
477+
478+ fn save_custom_name(&mut self, window: &mut Window, cx: &mut Context<Self>) {
479+ let Some(form) = self.providers.form.as_mut() else { return };
480+ let Some((slug, input)) = form.editing_custom.take() else { return };
481+ let name = input.read(cx).value().trim().to_string();
482+ let id = form.id.clone();
483+ self.edit_instance(&id, window, cx, |instance| {
484+ if let Some(custom) = instance.custom_models.iter_mut().find(|custom| custom.slug == slug) {
485+ custom.name = name;
486+ }
487+ });
488+ }
489+
451490 fn remove_custom_model(&mut self, slug: String, window: &mut Window, cx: &mut Context<Self>) {
452491 let Some(id) = self.providers.form.as_ref().map(|form| form.id.clone()) else { return };
453492 self.edit_instance(&id, window, cx, |instance| instance.custom_models.retain(|custom| custom.slug != slug));
@@ -496,11 +535,11 @@ impl SettingsView {
496535 let checking = instances.iter().any(|state| state.checking);
497536 let view = cx.weak_entity();
498537
538+ // Below 768px the list sits over the editor, as in T3 Code.
539+ let two_panes = content_width(window, WIDE_WIDTH) >= 768.;
499540 let list = v_flex()
500- .w(px(LIST_WIDTH))
541+ .map(|this| if two_panes { this.w(px(LIST_WIDTH)).self_stretch().rounded_l(px(18.)) } else { this.w_full().rounded_t(px(18.)) })
501542 .flex_none()
502- .self_stretch()
503- .rounded_l(px(18.))
504543 .region(p)
505544 .children(instances.iter().map(|state| {
506545 let id = state.id().to_string();
@@ -600,13 +639,13 @@ impl SettingsView {
600639 )
601640 .header_action(refresh)
602641 .row(
603- h_flex()
604- .items_start()
605- .min_h(px(512.))
642+ div()
643+ .flex()
644+ .map(|this| if two_panes { this.flex_row().items_start().min_h(px(512.)) } else { this.flex_col() })
606645 .rounded(px(18.))
607646 .raised(p)
608647 .child(list)
609- .child(div().w(px(1.)).self_stretch().bg(p.rule))
648+ .child(if two_panes { div().w(px(1.)).self_stretch().bg(p.rule) } else { div().h(px(1.)).w_full().bg(p.rule) })
610649 .child(div().flex_1().min_w_0().child(detail)),
611650 )
612651 .plain()
@@ -637,7 +676,10 @@ impl SettingsView {
637676 let config = state.config.clone();
638677 let default_instance = id == kind.key();
639678 let updating = self.providers.updating.contains(&id);
640- let row = |title: &'static str| SettingRow::new(title).page_width(WIDE_WIDTH - LIST_WIDTH);
679+ // The editor's own width decides when its rows stack.
680+ let content = content_width(window, WIDE_WIDTH);
681+ let editor_width = if content >= 768. { content - LIST_WIDTH - 33. } else { content - 32. };
682+ let row = |title: &'static str| SettingRow::new(title).page_width(editor_width);
641683 let (_, headline) = status_headline(state);
642684
643685 // ---- the instance's header ----
@@ -954,7 +996,16 @@ impl SettingsView {
954996 .on_click(cx.listener(move |this, _, window, cx| this.move_model(down_keys.clone(), ix, 1, window, cx))),
955997 )
956998 .when(custom, |this| {
999+ let edit_slug = model.id.clone();
9571000 this.child(
1001+ MetalButton::new(SharedString::from(format!("model-edit-{key}")))
1002+ .ghost()
1003+ .mini()
1004+ .icon(Lucide::Pencil)
1005+ .tooltip("Edit name")
1006+ .on_click(cx.listener(move |this, _, window, cx| this.edit_custom_model(edit_slug.clone(), window, cx))),
1007+ )
1008+ .child(
9581009 MetalButton::new(SharedString::from(format!("model-remove-{key}")))
9591010 .ghost()
9601011 .mini()
@@ -1006,13 +1057,41 @@ impl SettingsView {
10061057 if !shown(model) {
10071058 continue;
10081059 }
1009- let row = model_row(model, ix, cx);
1060+ let mut rows = vec![model_row(model, ix, cx)];
1061+ if let Some((slug, input)) = &form.editing_custom
1062+ && *slug == model.id
1063+ {
1064+ rows.push(
1065+ h_flex()
1066+ .px(px(12.))
1067+ .py(px(6.))
1068+ .gap(px(8.))
1069+ .child(div().text_size(px(12.5)).text_color(p.ink2).child("Display name"))
1070+ .child(text_field(input, 0., window, cx).flex_1())
1071+ .child(
1072+ MetalButton::new("custom-name-save")
1073+ .primary()
1074+ .compact()
1075+ .label("Save")
1076+ .on_click(cx.listener(|this, _, window, cx| this.save_custom_name(window, cx))),
1077+ )
1078+ .child(MetalButton::new("custom-name-cancel").ghost().compact().label("Cancel").on_click(cx.listener(
1079+ |this, _, _, cx| {
1080+ if let Some(form) = this.providers.form.as_mut() {
1081+ form.editing_custom = None;
1082+ }
1083+ cx.notify();
1084+ },
1085+ )))
1086+ .into_any_element(),
1087+ );
1088+ }
10101089 if hidden.contains(&model.key()) && !state.is_custom(model) {
1011- hidden_rows.push(row);
1090+ hidden_rows.extend(rows);
10121091 } else if is_favorite(model) {
1013- favorite_rows.push(row);
1092+ favorite_rows.extend(rows);
10141093 } else {
1015- all_rows.push(row);
1094+ all_rows.extend(rows);
10161095 }
10171096 }
10181097
+6−1src/ui/settings/widgets.rsView File
@@ -56,8 +56,13 @@ impl Global for Target {}
5656 /// Whether a row on a page of `max_width` stacks its control under its
5757 /// text in this window.
5858 fn stacked(window: &Window, max_width: f32) -> bool {
59+ content_width(window, max_width) < STACK_BELOW
60+ }
61+
62+ /// How wide a page of `max_width` is in this window.
63+ pub(crate) fn content_width(window: &Window, max_width: f32) -> f32 {
5964 let available = f32::from(window.viewport_size().width) - SIDEBAR_WIDTH - SHEET_INSET - PAGE_PADDING * 2.;
60- available.min(max_width) < STACK_BELOW
65+ available.min(max_width)
6166 }
6267
6368 /// Wraps `element` so a search jump to `id` can find and pulse it.