//! The composer's footer, like T3 Code's: the model, traits and access //! controls on the left (folding into "…" when they don't fit), and on the //! right the attach button, the context meter and the primary action. use gpui_kit::component::{ Icon, IconName, Selectable as _, Sizable as _, h_flex, hover_card::HoverCard, menu::DropdownMenu as _, progress::ProgressCircle, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::dock::{ask_progress, pending_plan}; use super::{ChatView, Picker, format_tokens}; use crate::models::Catalog; use crate::settings::{self, FollowUp}; use crate::ui::composer; use crate::ui::icons::Lucide; use crate::ui::metal::{self, MetalButton}; use crate::ui::pickers; /// About how wide a character of a footer label is. const CHAR_WIDTH: f32 = 7.; /// How much of the footer's left side shows: blocks (traits, then mode) /// lose their labels from the last, then move into the "…" menu. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(super) struct FooterLayout { /// Blocks shown as icons only, counted from the last. pub icon_only: usize, /// Blocks moved into the "…" menu, counted from the last. pub hidden: usize, } impl ChatView { /// Fits the footer's controls into the composer's measured width. fn footer_layout(&self, blocks: &[(f32, f32)], model_width: f32, right_width: f32) -> FooterLayout { let width = f32::from(self.composer_width); if width <= 0. { return FooterLayout::default(); } let available = width - 34. - right_width - 8.; let overflow = 32.; let fits = |layout: FooterLayout| { let mut used = model_width; let shown = blocks.len() - layout.hidden; for (ix, (full, icon)) in blocks.iter().take(shown).enumerate() { let icon_only = ix >= blocks.len() - layout.icon_only.min(blocks.len()); used += 5. + if icon_only { *icon } else { *full }; } if layout.hidden > 0 { used += overflow; } used <= available }; let mut layout = FooterLayout::default(); while !fits(layout) { if layout.icon_only < blocks.len() { layout.icon_only += 1; } else if layout.hidden < blocks.len() { layout.hidden += 1; } else { break; } } layout } /// The left side of the footer. pub(super) fn render_controls(&self, right_width: f32, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); // Without an agent to talk to, the footer only offers to set one up. if Catalog::global(cx).enabled().next().is_none() { return h_flex() .flex_1() .child( composer::control("open-provider-settings") .ml(px(-10.)) .icon(Icon::new(IconName::CircleAlert)) .label("Open provider settings") .on_click(cx.listener(|_, _, _, cx| cx.emit(super::ChatEvent::OpenProviderSettings))), ) .into_any_element(); } let session = self.session.read(cx); let provider = session.provider; let catalog = Catalog::global(cx); let model = catalog.find(&session.instance, session.model.as_deref()).cloned(); let model_label: SharedString = match (&model, &session.model) { (Some(model), _) => model.name.clone().into(), (None, Some(id)) => id.clone().into(), (None, None) => "Choose model".into(), }; let traits = pickers::has_traits(session, model.as_ref()); let traits_label = pickers::traits_label(session, model.as_ref()); let access = session.access_mode(); let planning = session.is_planning(); let plan_toggle = settings::get(|settings| settings.plan_mode) && provider.asks_permission(); let label_width = |label: &str| label.chars().count() as f32 * CHAR_WIDTH; let model_width = 52. + label_width(&model_label).min(200.); let mut blocks = Vec::new(); if traits { blocks.push((54. + label_width(&traits_label), 46.)); } let mut mode_width = 54. + label_width(access.label()); if plan_toggle { mode_width += 5. + 70.; } blocks.push((mode_width, 46. + if plan_toggle { 37. } else { 0. })); let layout = self.footer_layout(&blocks, model_width, right_width); let traits_ix = traits.then_some(0); let mode_ix = blocks.len() - 1; let shown = |ix: usize| ix < blocks.len() - layout.hidden; // What folded into "…", for the shortcuts that open these controls. self.folded.set((traits_ix.is_some_and(|ix| !shown(ix)), !shown(mode_ix))); let icon_only = |ix: usize| ix >= blocks.len() - layout.icon_only.min(blocks.len()); let model_picker = self.model_picker.clone(); let model_button = composer::control("model") .ml(px(-10.)) .icon(pickers::provider_icon(provider, px(14.), p)) .label(model_label.clone()) .caret() .tooltip(format!("{model_label} · Ctrl+Shift+M")); let close = Self::close_picker(cx); let mut row = h_flex().flex_1().min_w_0().gap(px(4.)).child(self.picker( Picker::Model, model_button, move |_, _| model_picker.clone().into_any_element(), cx, )); if let Some(ix) = traits_ix.filter(|ix| shown(*ix)) { let session_entity = self.session.clone(); let model = model.clone(); let close = close.clone(); let button = composer::control("traits") .icon(Lucide::Brain) .when(!icon_only(ix), |this| this.label(traits_label.clone())) .caret() .tooltip(traits_label.clone()); row = row.child(composer::separator(p)).child(self.picker( Picker::Traits, button, move |_, cx| { let p = metal::palette(cx); pickers::traits_menu(&session_entity, model.as_ref(), close.clone(), p, cx).into_any_element() }, cx, )); } if shown(mode_ix) { let session_entity = self.session.clone(); let model = model.clone(); let close = close.clone(); let button = composer::control("access") .icon(pickers::access_icon(access)) .when(!icon_only(mode_ix), |this| this.label(access.label())) .caret() .tooltip(access.description()); row = row.child(composer::separator(p)).child(self.picker( Picker::Access, button, move |_, cx| { let p = metal::palette(cx); pickers::access_menu(&session_entity, model.as_ref(), access, close.clone(), p, cx).into_any_element() }, cx, )); if plan_toggle { let session_entity = self.session.clone(); row = row.child(composer::separator(p)).child( composer::control("plan-toggle") .icon(if planning { Icon::new(Lucide::PencilRuler) } else { Icon::new(Lucide::Bot) }) .when(!icon_only(mode_ix), |this| this.label(if planning { "Plan" } else { "Build" })) .selected(planning) .tooltip(if planning { "Plan mode — click to return to normal build mode" } else { "Default mode — click to enter plan mode" }) .on_click(move |_, _, cx| session_entity.update(cx, |session, cx| session.toggle_plan(cx))), ); } } if layout.hidden > 0 { let session_entity = self.session.clone(); let model = model.clone(); let close = close.clone(); let traits_hidden = traits_ix.is_some_and(|ix| !shown(ix)); let mode_hidden = !shown(mode_ix); let button = composer::control("composer-more").icon(IconName::Ellipsis).tooltip("More composer controls"); row = row.child(self.picker( Picker::Overflow, button, move |_, cx| { let p = metal::palette(cx); let state = session_entity.read(cx); let planning = state.is_planning(); let access = state.access_mode(); let provider = state.provider; let mut menu = pickers::panel(p).min_w(px(160.)); if traits_hidden { menu = menu .children(pickers::traits_items(&session_entity, model.as_ref(), close.clone(), p, cx)) .child(composer::menu_separator(p)); } if mode_hidden && plan_toggle { let (chat, plan) = (session_entity.clone(), session_entity.clone()); menu = menu .child(composer::menu_label("Mode", p)) .child(composer::menu_item("mode-chat", !planning, true, p).child("Chat").on_click(move |_, _, cx| { chat.update(cx, |session, cx| { if session.is_planning() { session.toggle_plan(cx) } }) })) .child(composer::menu_item("mode-plan", planning, true, p).child("Plan").on_click(move |_, _, cx| { plan.update(cx, |session, cx| { if !session.is_planning() { session.toggle_plan(cx) } }) })) .child(composer::menu_separator(p)); } menu = menu.child(composer::menu_label("Access", p)); for mode in pickers::access_modes(provider) { let session = session_entity.clone(); menu = menu.child( composer::menu_item(SharedString::from(format!("overflow-access-{}", mode.as_cli())), mode == access, true, p) .child(mode.label()) .on_click(move |_, _, cx| session.update(cx, |session, cx| session.set_access_mode(mode, cx))), ); } menu.into_any_element() }, cx, )); } row.into_any_element() } /// How wide the footer's right side is, for fitting the left side. pub(super) fn actions_width(&self, cx: &App) -> f32 { let session = self.session.read(cx); let meter = if settings::get(|settings| settings.context_meter) { 36. } else { 0. }; let primary = if session.pending_ask().is_some() { if session.pending_ask().is_some_and(|(_, ask)| ask.current > 0) { 220. } else { 140. } } else if pending_plan(session).is_some() { 130. } else { 32. }; 36. + meter + primary } /// The right side of the footer. pub(super) fn render_actions(&self, start: bool, cx: &mut Context) -> impl IntoElement { let session = self.session.read(cx); // Answers to Claude's questions can't carry files. let attach = session.pending_ask().is_none(); h_flex() .flex_none() .gap(px(8.)) .when(attach, |this| { this.child( MetalButton::new("attach") .ghost() .compact() .size(px(28.)) .icon(Lucide::Paperclip) .tooltip("Attach files") .on_click(cx.listener(|this, _, window, cx| this.pick_attachments(window, cx))), ) }) .when(!start && settings::get(|settings| settings.context_meter), |this| { this.child(self.render_context_meter(cx)) }) .child(self.render_primary(cx)) } /// Send, stop, steer or queue, Next or Submit on a question, and /// Implement or Refine on a plan. fn render_primary(&self, cx: &mut Context) -> AnyElement { let session = self.session.read(cx); let working = session.is_working(); let has_input = !self.composer.read(cx).value().trim().is_empty() || !self.attachments.is_empty(); if let Some((ix, ask)) = session.pending_ask() { let (can_advance, complete) = ask_progress(ask); let last = ask.is_last(); let current = ask.current; let compact = f32::from(self.composer_width) > 0. && f32::from(self.composer_width) < 620.; let label = match (compact, last) { (true, true) => "Submit", (true, false) => "Next", (false, false) => "Next question", (false, true) if current > 0 => "Submit answers", (false, true) => "Submit answer", }; let enabled = if last { complete } else { can_advance }; let (previous, advance) = (self.session.clone(), self.session.clone()); return h_flex() .gap(px(8.)) .when(working, |this| this.child(self.stop_button(cx))) .when(current > 0, |this| { this.child( MetalButton::new("question-previous") .compact() .map(|this| if compact { this.icon(IconName::ChevronLeft).tooltip("Previous question") } else { this.label("Previous") }) .on_click(move |_, _, cx| previous.update(cx, |session, cx| session.ask_previous(ix, cx))), ) }) .child( MetalButton::new("question-advance") .primary() .compact() .label(label) .disabled(!enabled) .on_click(cx.listener(move |this, _, window, cx| { this.sync_custom_answer(cx); advance.update(cx, |session, cx| session.ask_advance(ix, cx)); this.focus_composer(window, cx); })), ) .into_any_element(); } if let Some((ix, _)) = pending_plan(session) { if has_input { return MetalButton::new("plan-refine") .primary() .label("Refine") .on_click(cx.listener(move |this, _, window, cx| this.refine_plan(ix, window, cx))) .into_any_element(); } let session_entity = self.session.clone(); return h_flex() .child( MetalButton::new("plan-implement") .primary() .label("Implement") .rounded_r(px(0.)) .on_click(move |_, _, cx| { session_entity.update(cx, |session, cx| { session.respond_permission(ix, crate::session::PermissionDecision::Allow, cx) }) }), ) .child( MetalButton::new("plan-implement-more") .primary() .icon(IconName::ChevronDown) .w(px(28.)) .rounded_l(px(0.)) .tooltip("Implementation actions") .dropdown_menu_with_anchor(Anchor::BottomRight, { let chat = cx.entity().downgrade(); move |menu, _, _| { let chat = chat.clone(); menu.item( gpui_kit::component::menu::PopupMenuItem::new("Implement in a new thread").on_click( move |_, window, cx| { let _ = chat.update(cx, |this, cx| this.implement_in_new_thread(ix, window, cx)); }, ), ) } }), ) .into_any_element(); } if working && !has_input && self.editing_queued.is_none() { return self.stop_button(cx).into_any_element(); } let follow_up = settings::get(|settings| settings.follow_up); let ctrl_enter = self.ctrl_sends(cx); let queue_first = follow_up == FollowUp::Queue; let (icon, tooltip): (Icon, SharedString) = if self.editing_queued.is_some() { (Icon::new(IconName::Check), "Update queued message".into()) } else if session.preparing { (Icon::new(IconName::LoaderCircle), "Preparing worktree".into()) } else if working { let (now, other) = if queue_first { ("queue", "steer") } else { ("steer", "queue") }; let key = if ctrl_enter { "Ctrl+Shift+Enter" } else { "Ctrl+Enter" }; ( if queue_first { Icon::new(Lucide::ListPlus) } else { Icon::new(Lucide::CornerUpRight) }, format!("Click to {now}, Ctrl-click or {key} to {other}").into(), ) } else { (Icon::new(IconName::ArrowUp), "Submit message".into()) }; MetalButton::new("send") .primary() .icon(icon) .disabled(!has_input) .tooltip(tooltip) .on_click(cx.listener(|this, event: &ClickEvent, window, cx| { this.submit(event.modifiers().control, window, cx) })) .into_any_element() } fn stop_button(&self, cx: &mut Context) -> MetalButton { MetalButton::new("stop") .danger() .icon(Icon::new(IconName::Square).size(px(11.))) .tooltip("Interrupt") .on_click(cx.listener(|this, _, _, cx| this.interrupt(cx))) } /// A ring showing how full the context window is; hovering it shows /// the numbers and a way to compact. fn render_context_meter(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let session = self.session.read(cx); let tokens = session.context_tokens; let window = session.context_window; let percent = match (tokens, window) { (Some(tokens), Some(window)) => Some(tokens as f32 * 100. / window.max(1) as f32), _ => None, }; let color = if percent.is_some_and(|percent| percent > 90.) { p.red } else { p.ink3.opacity(0.72) }; let cost = session.total_cost; let chat = cx.entity().downgrade(); HoverCard::new("context-meter") .anchor(Anchor::BottomRight) .open_delay(std::time::Duration::from_millis(150)) .close_delay(std::time::Duration::from_millis(150)) .trigger( div() .id("context-meter-ring") .flex() .items_center() .justify_center() .size(px(28.)) .rounded(px(8.)) .hover(|style| style.bg(p.row_hover)) .child( ProgressCircle::new("context-progress") .value(percent.unwrap_or(0.).max(if tokens.is_some() { 1. } else { 0. })) .color(color) .with_size(gpui_kit::component::Size::Size(px(20.))), ), ) .content(move |_, _, cx| { let p = metal::palette(cx); let chat = chat.clone(); let summary = match (tokens, window, percent) { (Some(tokens), Some(window), Some(percent)) => { format!("{} · {}/{}", format_percent(percent), format_tokens(tokens), format_tokens(window)) } (Some(tokens), _, _) => format_tokens(tokens), _ => "No usage yet".into(), }; v_flex() .w(px(280.)) .gap(px(8.)) .p(px(12.)) .child( h_flex() .justify_between() .gap(px(12.)) .child(div().text_size(px(12.)).font_weight(FontWeight::MEDIUM).text_color(p.ink3).child("Context Window")) .child(div().text_size(px(11.)).text_color(p.ink3).child(summary)), ) .child( div().h(px(6.)).w_full().rounded_full().bg(p.ink3.opacity(0.15)).child( div() .h_full() .rounded_full() .bg(color) .w(relative(percent.unwrap_or(0.).clamp(0., 100.) / 100.)), ), ) .child( h_flex() .justify_between() .text_size(px(11.)) .text_color(p.ink3) .child("Cost") .child(if cost < 0.01 { format!("USD {cost:.4}") } else { format!("USD {cost:.2}") }), ) .child( div() .mt(px(4.)) .text_size(px(11.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink3) .child("Context compacts automatically when needed."), ) .child( MetalButton::new("compact-context") .compact() .w_full() .icon(Lucide::Minimize2) .label("Compact context") .on_click(move |_, _, cx| { let _ = chat.update(cx, |this, cx| { this.session.update(cx, |session, cx| session.send_message("/compact".into(), Vec::new(), cx)) }); }), ) .into_any_element() }) } } /// `4.5%` under 10%, `42%` above. fn format_percent(percent: f32) -> String { if percent < 10. { let text = format!("{percent:.1}"); format!("{}%", text.trim_end_matches(".0")) } else { format!("{percent:.0}%") } }