Vectrum
Sign In
505 lines · 22.5 KB · Rust
//! 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;
        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}%")
    }
}