//! 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<Self>) -> impl IntoElement {
let p = metal::palette(cx);
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
}
/// 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<Self>) -> 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<Self>) -> 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, ctrl_enter) = settings::get(|settings| (settings.follow_up, settings.send_with_ctrl_enter));
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<Self>) -> 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<Self>) -> 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}%")
}
}