Vectrum
Sign In
1983 lines · 84.0 KB · Rust
//! The conversation pane: transcript and composer, set on the sheet. A new
//! thread starts centered, T3 Code style; once it has messages the composer
//! moves to the bottom. What docks on top of the composer lives in `dock`,
//! its footer in `footer`, and the strip hanging below it in `strip`.

mod dock;
mod footer;
mod strip;
mod timeline;

use std::{
    path::PathBuf,
    time::{Duration, SystemTime},
};

use gpui_kit::component::{
    Icon, IconName, h_flex,
    input::{self, EditorState, InputEvent},
    menu::{DropdownMenu as _, PopupMenuItem},
    message_scroller::{MessageScroller, MessageScrollerState},
    popover::Popover,
    text::{TextView, TextViewStyle},
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use crate::Interrupt;
use crate::clock;
use crate::git;
use crate::models::{Catalog, ModelEntry, SlashCommand};
use crate::session::{
    ChatItem, PermissionDecision, Session, SessionEvent, SetupCancel, tool_field,
};
use crate::worktree::StageStatus;
use crate::settings::{self, FollowUp, WorkspaceMode};
use crate::ui::changes::{self, DiffAction, DiffView};
use crate::ui::composer::{self, CONTENT_WIDTH, Density, Tone};
use crate::ui::icons::Lucide;
use crate::ui::markdown_input::{self, MarkdownInput};
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
use crate::ui::pickers::{ModelPicker, ModelPickerEvent};
use crate::ui::workspace::ProjectDirectory;

pub use strip::BranchPicker;

const DEFAULT_PLACEHOLDER: &str = "Ask anything, @tag files/folders, $use skills, or / for commands";
const MAX_TOOL_OUTPUT: usize = 6_000;

/// What the conversation asks of the workspace around it.
pub enum ChatEvent {
    /// Start a thread on this model; it belongs to another provider than this
    /// thread's.
    NewThreadWithModel(ModelEntry),
    /// Move this (still empty) thread to another project: its folder, and
    /// the remote connection it's on (`None` here).
    SwitchProject(PathBuf, Option<String>),
    AddProject,
    StartWithoutProject,
    /// Check out a branch in the thread's folder, creating it first when
    /// `create`.
    SwitchBranch { branch: String, create: bool },
    /// Run this (still empty) thread in the project folder or a new worktree.
    SetWorkspace(WorkspaceMode),
    /// Start a new thread in this project that implements this plan.
    ImplementInNewThread(String),
    /// No agent is on: open Settings → Providers.
    OpenProviderSettings,
}

/// Proactive panels open the diff after a turn that changed at least this
/// many files or lines.
const PROACTIVE_FILES: usize = 3;
const PROACTIVE_LINES: u64 = 50;
/// The most rows the `/`, `$` and `@` menus list.
const MENU_ROWS: usize = 40;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Picker {
    Model,
    Traits,
    Access,
    Overflow,
    Environment,
    Workspace,
    Branch,
}

/// A notice docked above the composer, T3 Code's banner stack.
pub(crate) enum Notice {
    /// Commands, monitors or subagents left running in the background.
    BackgroundWork { title: SharedString, detail: Option<SharedString>, waiting: bool, task_ids: Vec<String> },
    /// A usage limit stopped the thread.
    Limit { resets: SystemTime, resume_at: Option<SystemTime> },
    /// The project's checkout moved to another branch since the thread
    /// last ran there.
    BranchChanged { was: String, now: String },
}

impl Notice {
    fn tone(&self) -> Tone {
        match self {
            Notice::BackgroundWork { .. } | Notice::BranchChanged { .. } => Tone::Neutral,
            Notice::Limit { .. } => Tone::Warning,
        }
    }
}

/// A prompt put aside with Ctrl+S.
struct Stashed {
    text: String,
    attachments: Vec<PathBuf>,
    at: SystemTime,
}

impl Stashed {
    /// The stash menu's line for it: its start, or what it carries.
    fn snippet(&self) -> String {
        let text = self.text.split_whitespace().collect::<Vec<_>>().join(" ");
        if !text.is_empty() {
            let mut snippet: String = text.chars().take(90).collect();
            if text.chars().count() > 90 {
                snippet.push('…');
            }
            return snippet;
        }
        match self.attachments.len() {
            0 => "(empty)".into(),
            1 => "(1 file)".into(),
            n => format!("({n} files)"),
        }
    }
}

/// What the `/`, `$` or `@` menu offers for the word at the cursor.
struct CommandMenu {
    /// `/`, `$` or `@`.
    trigger: char,
    /// Where the word (with its trigger) starts in the composer text.
    start: usize,
    items: Vec<MenuItem>,
}

#[derive(Clone)]
enum MenuItem {
    Command(SlashCommand),
    /// A file or folder of the thread's folder, relative to it.
    Path { path: String, folder: bool },
}

pub struct ChatView {
    session: Entity<Session>,
    composer: Entity<EditorState>,
    scroller: Entity<MessageScrollerState>,
    model_picker: Entity<ModelPicker>,
    branch_picker: Entity<BranchPicker>,
    open_picker: Option<Picker>,
    attachments: Vec<PathBuf>,
    diff: Option<DiffView>,
    /// The composer's placeholder, as the thread's state picks it.
    placeholder: &'static str,
    /// The question form's question the composer holds the answer to.
    ask_shown: Option<(usize, usize)>,
    /// A question folded away, by form and question.
    question_collapsed: Option<(usize, usize)>,
    /// The option just picked on a single-select question, shown checked
    /// until the form moves on.
    optimistic_pick: Option<(usize, usize, usize)>,
    ask_timer: Task<()>,
    /// The highlighted row of the `/`, `$` or `@` menu.
    menu_selected: usize,
    /// The composer text the menu was dismissed at, with Esc.
    menu_dismissed: Option<String>,
    /// The thread folder's files, for the `@` menu, once read.
    files: Option<(PathBuf, Vec<String>)>,
    files_task: Task<()>,
    /// Where ↑/↓ are in the thread's sent prompts: the entry shown, and the
    /// text put in the composer for it.
    history: Option<(usize, String)>,
    /// Runs of tool calls unfolded in the timeline, by their first call.
    expanded_groups: std::collections::HashSet<usize>,
    /// Long user messages unfolded in the timeline.
    expanded_messages: std::collections::HashSet<usize>,
    /// Prompts put aside with Ctrl+S, oldest first.
    stash: Vec<Stashed>,
    stash_open: bool,
    stash_selected: usize,
    /// When a prompt was last stashed, for the tab's brief pulse.
    stash_pulse: Option<std::time::Instant>,
    queue_expanded: bool,
    /// The queued message being edited in the composer.
    editing_queued: Option<usize>,
    notices_expanded: bool,
    tasks_expanded: bool,
    /// The composer card's width, measured as it's laid out, so the footer
    /// knows how much fits.
    composer_width: Pixels,
    /// Whether the worktree setup card shows its details.
    setup_details: bool,
    /// Whether the traits and access controls folded into "…" last frame.
    folded: std::cell::Cell<(bool, bool)>,
    /// The composer rests on one line while the conversation scrolls.
    collapsed: bool,
    _subscriptions: Vec<Subscription>,
    _ticker: Task<()>,
}

impl EventEmitter<ChatEvent> for ChatView {}

impl ChatView {
    pub fn new(session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let composer = cx.new(|cx| {
            markdown_input::composer_state(EditorState::new(window, cx), settings::get(|settings| settings.rich_composer))
                .submit_on_enter(!settings::get(|settings| settings.send_with_ctrl_enter))
                .placeholder(DEFAULT_PLACEHOLDER)
        });
        let item_count = session.read(cx).items.len();
        let scroller = cx.new(|cx| MessageScrollerState::new(item_count, cx));
        let model_picker = cx.new(|cx| ModelPicker::new(session.clone(), window, cx));
        let branch_picker = cx.new(|cx| BranchPicker::new(window, cx));

        let subscriptions = vec![
            cx.observe_in(&session, window, |this, session, window, cx| {
                this.sync_with_session(window, cx);
                let count = session.read(cx).items.len();
                this.scroller.update(cx, |scroller, cx| {
                    let current = scroller.item_count();
                    if count > current {
                        scroller.append(count - current, cx);
                    } else if count < current {
                        scroller.reset(count, cx);
                    }
                });
                cx.notify();
            }),
            cx.subscribe_in(&composer, window, |this, _, event, window, cx| match event {
                InputEvent::PressEnter { shift: false, secondary } => {
                    let ctrl_enter = settings::get(|settings| settings.send_with_ctrl_enter);
                    if *secondary || !ctrl_enter {
                        // With Enter sending, Ctrl+Enter does the opposite
                        // of the follow-up setting.
                        this.submit(*secondary && !ctrl_enter, window, cx);
                    }
                }
                InputEvent::Change => {
                    this.menu_selected = 0;
                    this.stash_open = false;
                    this.sync_custom_answer(cx);
                    this.expand(cx);
                    this.load_files_if_mentioning(cx);
                    cx.notify();
                }
                InputEvent::Focus => this.expand(cx),
                _ => {}
            }),
            cx.subscribe_in(&session, window, |this, session, event: &SessionEvent, window, cx| {
                let SessionEvent::TurnChanges(end) = event;
                let big = session.read(cx).turn_changes(*end).is_some_and(|changes| {
                    changes.files.len() >= PROACTIVE_FILES || changes.added() + changes.removed() >= PROACTIVE_LINES
                });
                if big && this.diff.is_none() && settings::get(|settings| settings.proactive_panels) {
                    this.open_diff(*end, None, window, cx);
                }
            }),
            cx.subscribe_in(&model_picker, window, |this, _, event, window, cx| {
                let ModelPickerEvent::Picked(model) = event;
                this.open_picker = None;
                if model.provider == this.session.read(cx).provider {
                    let id = model.id.clone();
                    this.session.update(cx, |session, cx| session.set_model(Some(id), cx));
                    this.focus_composer(window, cx);
                } else {
                    cx.emit(ChatEvent::NewThreadWithModel(model.clone()));
                }
                cx.notify();
            }),
            cx.subscribe_in(&branch_picker, window, |this, _, event, window, cx| this.on_branch_event(event, window, cx)),
        ];

        // Re-render once a second while the agent works so the timer advances.
        let ticker = cx.spawn(async move |this, cx| {
            loop {
                cx.background_executor().timer(Duration::from_secs(1)).await;
                let alive = this.update(cx, |this, cx| {
                    let session = this.session.read(cx);
                    if session.is_working() || !session.background_tasks.is_empty() {
                        cx.notify();
                    }
                });
                if alive.is_err() {
                    break;
                }
            }
        });

        let mut this = Self {
            session,
            composer,
            scroller,
            model_picker,
            branch_picker,
            open_picker: None,
            attachments: Vec::new(),
            diff: None,
            placeholder: DEFAULT_PLACEHOLDER,
            ask_shown: None,
            question_collapsed: None,
            optimistic_pick: None,
            ask_timer: Task::ready(()),
            menu_selected: 0,
            menu_dismissed: None,
            files: None,
            files_task: Task::ready(()),
            history: None,
            expanded_groups: Default::default(),
            expanded_messages: Default::default(),
            stash: Vec::new(),
            stash_open: false,
            stash_selected: 0,
            stash_pulse: None,
            queue_expanded: true,
            editing_queued: None,
            notices_expanded: false,
            tasks_expanded: false,
            composer_width: px(0.),
            setup_details: false,
            folded: Default::default(),
            collapsed: false,
            _subscriptions: subscriptions,
            _ticker: ticker,
        };
        this.sync_with_session(window, cx);
        this
    }

    pub fn session(&self) -> &Entity<Session> {
        &self.session
    }

    /// Picks up changed settings: the send key, and folding, which changes
    /// the height of transcript rows.
    pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let ctrl_enter = settings::get(|settings| settings.send_with_ctrl_enter);
        self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(!ctrl_enter, cx));
        markdown_input::enable(&self.composer, settings::get(|settings| settings.rich_composer), cx);
        self.scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
        self.sync_with_session(window, cx);
        cx.notify();
    }

    pub fn focus_composer(&self, window: &mut Window, cx: &mut App) {
        self.composer.update(cx, |composer, cx| composer.focus(window, cx));
    }

    /// Carries what the user typed over from another thread, when this one
    /// replaces it (a provider or project switch before the first message).
    pub fn take_draft_from(&mut self, other: &Entity<ChatView>, window: &mut Window, cx: &mut Context<Self>) {
        let (text, attachments) = other.update(cx, |other, cx| {
            (other.composer.read(cx).value().to_string(), std::mem::take(&mut other.attachments))
        });
        self.attachments = attachments;
        self.composer.update(cx, |composer, cx| composer.set_value(text, window, cx));
    }

    /// Keeps the composer in step with what the thread waits on: its
    /// placeholder, whether it takes input, and (while a question is open)
    /// the answer typed for the question on screen.
    fn sync_with_session(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let session = self.session.read(cx);
        let approval = dock::pending_approval(session).is_some() && dock::pending_plan(session).is_none();
        let ask = session.pending_ask().map(|(ix, ask)| (ix, ask.current, ask.custom[ask.current].clone()));
        let plan = dock::pending_plan(session).is_some();
        let choice_only = dock::pending_extension_question(session).is_some() && ask.is_none();
        let no_provider = Catalog::global(cx).enabled().next().is_none();
        let placeholder = if approval {
            "Resolve this approval request to continue"
        } else if choice_only {
            "Choose an option above"
        } else if ask.is_some() {
            "Type your own answer, or leave this blank to use the selected option"
        } else if plan {
            "Add feedback to refine the plan, or leave this blank to implement it"
        } else if no_provider {
            "Enable a provider in Settings to send a message"
        } else {
            DEFAULT_PLACEHOLDER
        };
        if placeholder != self.placeholder {
            self.placeholder = placeholder;
            self.composer.update(cx, |composer, cx| {
                composer.set_placeholder(placeholder, window, cx);
                composer.set_disabled(approval || choice_only, cx);
            });
        }
        // A question shown anew brings its own typed answer into the composer.
        let shown = ask.as_ref().map(|(ix, current, _)| (*ix, *current));
        if shown != self.ask_shown {
            let had_question = self.ask_shown.is_some();
            self.ask_shown = shown;
            match ask {
                Some((_, _, custom)) => self.composer.update(cx, |composer, cx| composer.set_value(custom, window, cx)),
                // The form is answered: the answer typed for it goes away.
                None if had_question => self.composer.update(cx, |composer, cx| composer.set_value("", window, cx)),
                None => {}
            }
        }
    }

    /// Hands what the composer holds to the open question as the user's
    /// own answer.
    fn sync_custom_answer(&mut self, cx: &mut Context<Self>) {
        let Some((ix, _)) = self.session.read(cx).pending_ask() else { return };
        if self.ask_shown.is_none() {
            return;
        }
        let text = self.composer.read(cx).value().to_string();
        self.session.update(cx, |session, cx| session.ask_set_custom(ix, text, cx));
    }

    /// Sends what's in the composer. While the agent works, the follow-up
    /// setting decides between steering it now and queueing for after the
    /// turn; `opposite` flips that for this one message.
    fn submit(&mut self, opposite: bool, window: &mut Window, cx: &mut Context<Self>) {
        if self.command_menu(cx).is_some() {
            return;
        }
        let text = self.composer.read(cx).value().trim().to_string();
        let session = self.session.read(cx);
        // An open question takes the text as the user's own answer and moves on.
        if let Some((ix, _)) = session.pending_ask() {
            self.sync_custom_answer(cx);
            self.session.update(cx, |session, cx| session.ask_advance(ix, cx));
            return;
        }
        if dock::pending_approval(session).is_some_and(|(_, tool, _)| tool.name != "ExitPlanMode") {
            return;
        }
        if let Some((ix, _)) = dock::pending_plan(session) {
            if text.is_empty() {
                self.session.update(cx, |session, cx| session.respond_permission(ix, PermissionDecision::Allow, cx));
            } else {
                self.refine_plan(ix, window, cx);
            }
            return;
        }
        if text.is_empty() && self.attachments.is_empty() {
            return;
        }
        let attachments = std::mem::take(&mut self.attachments);
        self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
        if let Some(ix) = self.editing_queued.take() {
            self.session.update(cx, |session, cx| session.update_queued(ix, text, attachments, cx));
            return;
        }
        let queue_mode = settings::get(|settings| settings.follow_up) == FollowUp::Queue;
        let branch = self.checkout_branch(cx);
        self.session.update(cx, |session, cx| {
            if branch.is_some() {
                session.branch = branch;
            }
            if (session.is_working() || session.preparing) && queue_mode != opposite {
                session.queue_message(text, attachments, cx);
            } else {
                session.send_message(text, attachments, cx);
            }
        });
        self.menu_dismissed = None;
        self.scroller.update(cx, |scroller, cx| scroller.scroll_to_end(cx));
    }

    /// Declines Claude's plan with the composer's text as feedback, so it
    /// plans again.
    fn refine_plan(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
        let text = self.composer.read(cx).value().trim().to_string();
        if text.is_empty() {
            return;
        }
        self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
        self.session.update(cx, |session, cx| {
            session.respond_permission(ix, PermissionDecision::Deny, cx);
            session.send_message(text, Vec::new(), cx);
        });
    }

    /// Hands Claude's plan to a new thread to implement, and declines it here.
    fn implement_in_new_thread(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) {
        let plan = match self.session.read(cx).items.get(ix) {
            Some(ChatItem::Tool(tool)) => tool_field(&tool.input, &["plan"]).unwrap_or_default(),
            _ => return,
        };
        self.session.update(cx, |session, cx| session.respond_permission(ix, PermissionDecision::Deny, cx));
        cx.emit(ChatEvent::ImplementInNewThread(plan));
    }

    /// Loads a queued message into the composer to edit it.
    fn edit_queued(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
        let Some(message) = self.session.read(cx).queue.get(ix) else { return };
        let (text, attachments) = (message.text.clone(), message.attachments.clone());
        self.editing_queued = Some(ix);
        self.attachments = attachments;
        self.composer.update(cx, |composer, cx| {
            composer.set_value(text, window, cx);
            composer.focus(window, cx);
        });
        cx.notify();
    }

    fn cancel_queued_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        if self.editing_queued.take().is_some() {
            self.attachments.clear();
            self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
            cx.notify();
        }
    }

    /// T3 Code's composer shortcuts: Ctrl+Shift+M (model), E (effort),
    /// A (access), G (branch), X (workspace), L (previous worktree) and
    /// H (machine).
    fn composer_shortcut(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) -> bool {
        let keystroke = &event.keystroke;
        let modifiers = &keystroke.modifiers;
        if !(modifiers.control && modifiers.shift) || modifiers.alt || event.is_held {
            return false;
        }
        let (traits_folded, access_folded) = self.folded.get();
        let strip = self.strip_visible(cx);
        let draft = self.session.read(cx).items.is_empty();
        let picker = match keystroke.key.as_str() {
            "m" => Picker::Model,
            "e" if traits_folded => Picker::Overflow,
            "e" => Picker::Traits,
            "a" if access_folded => Picker::Overflow,
            "a" => Picker::Access,
            "g" if strip => Picker::Branch,
            "x" if strip && draft => Picker::Workspace,
            "h" if strip && draft => Picker::Environment,
            "l" if draft => {
                self.use_previous_worktree(window, cx);
                return true;
            }
            _ => return false,
        };
        self.expand(cx);
        let open = self.open_picker != Some(picker);
        self.set_picker(picker, open, window, cx);
        if !open {
            self.focus_composer(window, cx);
        }
        true
    }

    /// Points a new thread at the project's previous worktree (Ctrl+Shift+L).
    fn use_previous_worktree(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let session = self.session.read(cx);
        let env = session.remote.as_ref().map(|remote| remote.env.clone());
        let previous = cx
            .try_global::<ProjectDirectory>()
            .and_then(|directory| directory.find(&session.project, env.as_deref()))
            .and_then(|project| project.previous_worktree.clone());
        if let Some(worktree) = previous {
            cx.emit(ChatEvent::SetWorkspace(WorkspaceMode::Local));
            self.session.update(cx, |session, cx| session.use_worktree(Some(worktree), cx));
            self.focus_composer(window, cx);
        }
    }

    /// The thread's sent prompts, oldest first, without repeats in a row.
    fn prompt_history(&self, cx: &App) -> Vec<String> {
        let mut prompts: Vec<String> = Vec::new();
        for item in &self.session.read(cx).items {
            if let ChatItem::User { text, .. } = item
                && !text.trim().is_empty()
                && prompts.last().map(String::as_str) != Some(text.as_ref())
            {
                prompts.push(text.to_string());
            }
        }
        prompts
    }

    /// Terminal-style recall, as in T3 Code: ↑ on an empty composer walks
    /// back through the thread's prompts, ↓ walks forward and past the
    /// newest clears the composer again. Editing a recalled prompt ends it.
    fn recall_prompt(&mut self, older: bool, window: &mut Window, cx: &mut Context<Self>) -> bool {
        let text = self.composer.read(cx).value().to_string();
        let browsing = self.history.as_ref().filter(|(_, recalled)| *recalled == text).map(|(at, _)| *at);
        if browsing.is_none() && (!text.is_empty() || !older) {
            self.history = None;
            return false;
        }
        let prompts = self.prompt_history(cx);
        if prompts.is_empty() {
            return false;
        }
        let next = match (browsing, older) {
            (None, _) => Some(prompts.len() - 1),
            (Some(at), true) => Some(at.saturating_sub(1)),
            (Some(at), false) if at + 1 < prompts.len() => Some(at + 1),
            (Some(_), false) => None,
        };
        let value = match next {
            Some(at) => {
                self.history = Some((at, prompts[at].clone()));
                prompts[at].clone()
            }
            None => {
                self.history = None;
                String::new()
            }
        };
        self.composer.update(cx, |composer, cx| composer.set_value(value, window, cx));
        true
    }

    /// Puts the composer's prompt aside (Ctrl+S), or with nothing to stash,
    /// opens the stash.
    fn stash_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let text = self.composer.read(cx).value().to_string();
        if self.session.read(cx).pending_ask().is_some() || text.trim().is_empty() && self.attachments.is_empty() {
            self.toggle_stash(cx);
            return;
        }
        self.stash.push(Stashed { text, attachments: std::mem::take(&mut self.attachments), at: SystemTime::now() });
        self.stash_pulse = Some(std::time::Instant::now());
        self.composer.update(cx, |composer, cx| composer.set_value("", window, cx));
        cx.notify();
    }

    fn toggle_stash(&mut self, cx: &mut Context<Self>) {
        self.stash_open = !self.stash_open;
        self.stash_selected = self.stash.len().saturating_sub(1);
        cx.notify();
    }

    /// Puts a stashed prompt back in the composer, stashing what's there.
    fn restore_stash(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
        if ix >= self.stash.len() {
            return;
        }
        let stashed = self.stash.remove(ix);
        let current = self.composer.read(cx).value().to_string();
        if !current.trim().is_empty() || !self.attachments.is_empty() {
            self.stash.push(Stashed { text: current, attachments: std::mem::take(&mut self.attachments), at: SystemTime::now() });
        }
        self.attachments = stashed.attachments;
        self.composer.update(cx, |composer, cx| {
            composer.set_value(stashed.text, window, cx);
            composer.focus(window, cx);
        });
        self.stash_open = false;
        cx.notify();
    }

    fn delete_stash(&mut self, ix: usize, cx: &mut Context<Self>) {
        if ix < self.stash.len() {
            self.stash.remove(ix);
            self.stash_selected = self.stash_selected.min(self.stash.len().saturating_sub(1));
            cx.notify();
        }
    }

    /// Keys the open stash takes: arrows, Enter to restore, Ctrl+Backspace
    /// to delete, Esc to close.
    fn stash_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) -> bool {
        if !self.stash_open {
            return false;
        }
        let count = self.stash.len();
        let modifiers = &event.keystroke.modifiers;
        match event.keystroke.key.as_str() {
            // Newest is listed first, so up moves to newer ones.
            "up" if count > 0 => self.stash_selected = (self.stash_selected + 1) % count,
            "down" if count > 0 => self.stash_selected = (self.stash_selected + count - 1) % count,
            "enter" if count > 0 => {
                let ix = self.stash_selected.min(count - 1);
                self.restore_stash(ix, window, cx);
            }
            "backspace" if modifiers.control && count > 0 => {
                let ix = self.stash_selected.min(count - 1);
                self.delete_stash(ix, cx);
            }
            "escape" => self.stash_open = false,
            _ => return false,
        }
        cx.notify();
        true
    }

    /// 1–9 pick an option of the open question while the composer is empty.
    fn pick_with_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
        if self.composer_shortcut(event, window, cx) {
            cx.stop_propagation();
            return;
        }
        let keystroke = &event.keystroke;
        if self.diff.is_some() || keystroke.modifiers.modified() || !self.composer.read(cx).value().is_empty() {
            return;
        }
        let Some(digit) = keystroke.key.parse::<usize>().ok().filter(|digit| (1..=9).contains(digit)) else {
            return;
        };
        if let Some((ix, question)) = dock::pending_extension_question(self.session.read(cx))
            && self.session.read(cx).pending_ask().is_none()
        {
            if digit <= question.options.len() {
                self.session.update(cx, |session, cx| session.answer_question(ix, digit - 1, cx));
                cx.stop_propagation();
            }
            return;
        }
        let Some((ix, ask)) = self.session.read(cx).pending_ask() else { return };
        if self.question_collapsed == Some((ix, ask.current)) || digit > ask.questions[ask.current].options.len() {
            return;
        }
        self.pick_option(ix, digit - 1, cx);
        cx.stop_propagation();
    }

    fn expand(&mut self, cx: &mut Context<Self>) {
        if self.collapsed {
            self.collapsed = false;
            cx.notify();
        }
    }

    fn interrupt(&mut self, cx: &mut Context<Self>) {
        self.session.update(cx, |session, cx| session.interrupt(cx));
    }

    /// The notices docked above the composer, front first: work running in
    /// the background, then a usage limit.
    fn notices(&self, cx: &App) -> Vec<Notice> {
        let session = self.session.read(cx);
        let mut notices = Vec::new();
        if !session.background_tasks.is_empty() {
            // What each task is, from the tool call that started it.
            let kind = |task_id: &str| {
                let tool = session.items.iter().find_map(|item| match item {
                    ChatItem::Tool(tool) if tool.task_id.as_deref() == Some(task_id) => Some(tool.name.as_str()),
                    _ => None,
                });
                match tool {
                    Some("Task" | "Agent") => 0,
                    Some("Bash") => 1,
                    Some("Monitor") => 2,
                    _ => 3,
                }
            };
            const NOUNS: [(&str, &str); 4] =
                [("subagent", "subagents"), ("command", "commands"), ("monitor", "monitors"), ("background task", "background tasks")];
            let mut tasks: Vec<(usize, &crate::session::BackgroundTask)> =
                session.background_tasks.iter().map(|task| (kind(&task.task_id), task)).collect();
            tasks.sort_by_key(|(kind, _)| *kind);
            // Subagents and monitors wake the agent; a dev server doesn't.
            let waiting = tasks.iter().any(|(kind, _)| matches!(kind, 0 | 2));
            let title = if let [(kind, task)] = tasks.as_slice() {
                let noun = NOUNS[*kind].0;
                let named = !task.description.is_empty();
                match (waiting, named) {
                    (true, true) => format!("Waiting on {noun} {}", task.description),
                    (true, false) => format!("Waiting on a {noun}"),
                    (false, true) => format!("Running: {}", task.description),
                    (false, false) => format!("Running a {noun}"),
                }
            } else {
                let mut groups: Vec<String> = Vec::new();
                for kind in 0..4 {
                    let count = tasks.iter().filter(|(task_kind, _)| *task_kind == kind).count();
                    if count > 0 {
                        let (one, many) = NOUNS[kind];
                        groups.push(format!("{count} {}", if count == 1 { one } else { many }));
                    }
                }
                let joined = match groups.len() {
                    0 | 1 => groups.join(""),
                    n => format!("{} and {}", groups[..n - 1].join(", "), groups[n - 1]),
                };
                format!("{} {joined}", if waiting { "Waiting on" } else { "Running" })
            };
            let detail = (tasks.len() > 1).then(|| {
                tasks.iter().map(|(_, task)| task.description.to_string()).collect::<Vec<_>>().join(", ").into()
            });
            notices.push(Notice::BackgroundWork {
                title: title.into(),
                detail,
                waiting,
                task_ids: tasks.iter().map(|(_, task)| task.task_id.clone()).collect(),
            });
        }
        if let Some(resets) = session.limited_until() {
            notices.push(Notice::Limit { resets, resume_at: session.resume_at });
        }
        // Shown once there's something to send, as in T3 Code.
        let typing = !self.composer.read(cx).value().trim().is_empty();
        if typing
            && let Some(was) = session.branch.clone()
            && let Some(now) = self.checkout_branch(cx)
            && was != now
        {
            notices.push(Notice::BranchChanged { was, now });
        }
        notices
    }

    /// The branch the project's checkout is on, for a thread that runs in
    /// it (not in a worktree of its own, nor on another device).
    fn checkout_branch(&self, cx: &App) -> Option<String> {
        let session = self.session.read(cx);
        if session.worktree.is_some() || session.pending_worktree.is_some() || session.is_remote() {
            return None;
        }
        cx.try_global::<ProjectDirectory>()?.find(&session.project, None)?.branch.clone()
    }

    // ---- the `/`, `$` and `@` menus ---------------------------------------

    /// The menu for the word at the cursor: `/` at the start of a line lists
    /// commands (and skills, when set to), `$` lists skills, `@` lists the
    /// thread folder's files and folders.
    fn command_menu(&self, cx: &App) -> Option<CommandMenu> {
        let composer = self.composer.read(cx);
        let text = composer.value();
        if self.menu_dismissed.as_deref() == Some(text.as_ref()) {
            return None;
        }
        let cursor = composer.cursor().min(text.len());
        let before = &text[..cursor];
        let start = before.rfind(char::is_whitespace).map_or(0, |at| at + before[at..].chars().next().map_or(1, char::len_utf8));
        let word = &before[start..];
        let trigger = word.chars().next().filter(|c| matches!(c, '/' | '$' | '@'))?;
        // `/` only opens a command at the start of a line.
        if trigger == '/' && start > 0 && !before[..start].ends_with('\n') {
            return None;
        }
        let query = word[1..].to_lowercase();
        let items: Vec<MenuItem> = match trigger {
            '@' => {
                let files = self.files.as_ref().map(|(_, files)| files.as_slice()).unwrap_or_default();
                let mut matches: Vec<MenuItem> = files
                    .iter()
                    .filter(|path| path.to_lowercase().contains(&query))
                    .take(MENU_ROWS)
                    .map(|path| MenuItem::Path { folder: path.ends_with('/'), path: path.clone() })
                    .collect();
                matches.sort_by_key(|item| match item {
                    MenuItem::Path { path, .. } => {
                        let name = path.trim_end_matches('/').rsplit('/').next().unwrap_or(path).to_lowercase();
                        (!name.starts_with(&query), path.len())
                    }
                    MenuItem::Command(_) => (true, 0),
                });
                matches
            }
            _ => {
                let skills_too = settings::get(|settings| settings.slash_skills);
                let instance = self.session.read(cx).instance.clone();
                let commands = Catalog::global(cx).commands(&instance);
                let wanted = |command: &&SlashCommand| match trigger {
                    '$' => command.skill,
                    _ => !command.skill || skills_too,
                };
                let (mut prefix, mut inside): (Vec<_>, Vec<_>) = commands
                    .iter()
                    .filter(wanted)
                    .filter(|command| command.name.to_lowercase().contains(&query))
                    .cloned()
                    .partition(|command| command.name.to_lowercase().starts_with(&query));
                prefix.append(&mut inside);
                prefix.truncate(MENU_ROWS);
                prefix.into_iter().map(MenuItem::Command).collect()
            }
        };
        Some(CommandMenu { trigger, start, items })
    }

    /// Reads the thread folder's files in the background once `@` is typed.
    fn load_files_if_mentioning(&mut self, cx: &mut Context<Self>) {
        let cwd = self.session.read(cx).cwd.clone();
        let text = self.composer.read(cx).value();
        if !text.contains('@') || self.files.as_ref().is_some_and(|(dir, _)| *dir == cwd) || self.session.read(cx).is_remote() {
            return;
        }
        self.files = Some((cwd.clone(), Vec::new()));
        let task = cx.background_executor().spawn({
            let cwd = cwd.clone();
            async move { list_files(&cwd) }
        });
        self.files_task = cx.spawn(async move |this, cx| {
            let files = task.await;
            let _ = this.update(cx, |this, cx| {
                this.files = Some((cwd, files));
                cx.notify();
            });
        });
    }

    /// Puts the picked item in the composer in place of the word typed.
    fn accept_menu_item(&mut self, menu: &CommandMenu, item: &MenuItem, window: &mut Window, cx: &mut Context<Self>) {
        let replacement = match item {
            MenuItem::Command(command) => format!("{}{} ", if menu.trigger == '$' { '$' } else { '/' }, command.name),
            MenuItem::Path { path, .. } => format!("@{path} "),
        };
        let start = menu.start;
        self.composer.update(cx, |composer, cx| {
            let cursor = composer.cursor();
            composer.set_selected_range(start..cursor, cx);
            composer.replace(replacement, window, cx);
            composer.focus(window, cx);
        });
        self.menu_selected = 0;
        cx.notify();
    }

    /// Keys the menus take before the composer sees them, Ctrl+Shift+Enter
    /// (the send key's opposite, or steering the first queued message), and
    /// Alt+↑ to edit the last queued message.
    fn composer_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
        let keystroke = &event.keystroke;
        let modifiers = &keystroke.modifiers;
        if self.stash_key(event, window, cx) {
            cx.stop_propagation();
            return;
        }
        if keystroke.key == "s" && modifiers.control && !modifiers.shift && !modifiers.alt {
            cx.stop_propagation();
            self.stash_prompt(window, cx);
            return;
        }
        if keystroke.key == "enter" && modifiers.control && modifiers.shift {
            cx.stop_propagation();
            let queued = !self.session.read(cx).queue.is_empty();
            if queued && self.composer.read(cx).value().trim().is_empty() {
                self.session.update(cx, |session, cx| session.steer_queued(0, cx));
            } else {
                self.submit(true, window, cx);
            }
            return;
        }
        if keystroke.key == "up" && modifiers.alt && !modifiers.control {
            let count = self.session.read(cx).queue.len();
            if count > 0 {
                cx.stop_propagation();
                self.edit_queued(count - 1, window, cx);
            }
            return;
        }
        if keystroke.key == "tab" && modifiers.shift && settings::get(|settings| settings.plan_mode) {
            cx.stop_propagation();
            self.session.update(cx, |session, cx| session.toggle_plan(cx));
            return;
        }
        let Some(menu) = self.command_menu(cx) else {
            let plain = !(modifiers.control || modifiers.alt || modifiers.shift || modifiers.platform);
            let asking = self.session.read(cx).pending_ask().is_some();
            if plain && !asking && matches!(keystroke.key.as_str(), "up" | "down") {
                if self.recall_prompt(keystroke.key == "up", window, cx) {
                    cx.stop_propagation();
                }
            }
            return;
        };
        if modifiers.control || modifiers.alt || modifiers.shift {
            return;
        }
        let count = menu.items.len();
        match keystroke.key.as_str() {
            "up" if count > 0 => self.menu_selected = (self.menu_selected + count - 1) % count,
            "down" if count > 0 => self.menu_selected = (self.menu_selected + 1) % count,
            "enter" | "tab" if count > 0 => {
                let item = menu.items[self.menu_selected.min(count - 1)].clone();
                cx.stop_propagation();
                self.accept_menu_item(&menu, &item, window, cx);
                return;
            }
            "escape" => self.menu_dismissed = Some(self.composer.read(cx).value().to_string()),
            _ => return,
        }
        cx.stop_propagation();
        cx.notify();
    }

    /// The menu, docked on top of the composer like T3 Code's.
    fn render_command_menu(&self, menu: CommandMenu, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let selected = self.menu_selected.min(menu.items.len().saturating_sub(1));
        let loading = menu.trigger == '@' && self.files.as_ref().is_some_and(|(_, files)| files.is_empty());
        let empty = match menu.trigger {
            '$' => "No skills found. Try / to browse provider commands.",
            '@' if loading => "Searching workspace files...",
            '@' => "No matching files or folders.",
            _ => "No matching command.",
        };
        let trigger = menu.trigger;
        let rows: Vec<AnyElement> = menu
            .items
            .iter()
            .enumerate()
            .map(|(ix, item)| {
                let pick = item.clone();
                let start = menu.start;
                let row = h_flex()
                    .id(("command-menu-item", ix))
                    .min_h(px(28.))
                    .gap(px(8.))
                    .px(px(8.))
                    .py(px(6.))
                    .rounded(px(6.))
                    .cursor_pointer()
                    .text_size(px(13.5))
                    .when(ix == selected, |this| this.bg(p.menu_row_hover))
                    .hover(|style| style.bg(p.menu_row_hover))
                    .on_click(cx.listener(move |this, _, window, cx| {
                        let menu = CommandMenu { trigger, start, items: Vec::new() };
                        this.accept_menu_item(&menu, &pick, window, cx)
                    }));
                match item {
                    MenuItem::Command(command) => row
                        .child(
                            div()
                                .flex_none()
                                .max_w(relative(0.45))
                                .truncate()
                                .text_size(px(12.))
                                .font_weight(FontWeight::MEDIUM)
                                .text_color(p.ink)
                                .when(command.skill && trigger == '/', |this| {
                                    this.child(div().text_color(p.ink3).child("/skill:")).child(command.name.clone())
                                })
                                .when(!(command.skill && trigger == '/'), |this| {
                                    this.child(format!("{}{}", if trigger == '$' { '$' } else { '/' }, command.name))
                                }),
                        )
                        .child(
                            div()
                                .flex_1()
                                .min_w_0()
                                .truncate()
                                .text_size(px(12.))
                                .text_color(p.ink3)
                                .child(if command.description.is_empty() {
                                    command.argument_hint.clone()
                                } else {
                                    command.description.clone()
                                }),
                        )
                        .when(command.skill, |this| {
                            this.child(metal::tag(if trigger == '$' { "Personal Skill" } else { "Personal" }, p))
                        })
                        .into_any_element(),
                    MenuItem::Path { path, folder } => {
                        let trimmed = path.trim_end_matches('/');
                        let (dir, name) = match trimmed.rsplit_once('/') {
                            Some((dir, name)) => (dir.to_string(), name.to_string()),
                            None => (String::new(), trimmed.to_string()),
                        };
                        row.child(
                            Icon::new(if *folder { IconName::Folder } else { IconName::File }).size(px(16.)).text_color(p.ink3),
                        )
                        .child(
                            div()
                                .flex_none()
                                .max_w(relative(0.45))
                                .truncate()
                                .text_size(px(12.))
                                .font_weight(FontWeight::MEDIUM)
                                .text_color(p.ink)
                                .child(name),
                        )
                        .child(div().flex_1().min_w_0().truncate().text_size(px(12.)).text_color(p.ink3).child(dir))
                        .into_any_element()
                    }
                }
            })
            .collect();
        let empty_rows = rows.is_empty();
        composer::banner(Tone::Neutral, Density::Default, false, p)
            .child(
                v_flex()
                    .id("command-menu")
                    .max_h(px(288.))
                    .overflow_y_scroll()
                    .p(px(4.))
                    .children(rows)
                    .when(empty_rows, |this| {
                        this.child(div().px(px(14.)).pt(px(20.)).pb(px(12.)).text_size(px(12.)).text_color(p.ink3).child(empty))
                    }),
            )
            .into_any_element()
    }

    fn escape(&mut self, cx: &mut Context<Self>) {
        if self.stash_open {
            self.stash_open = false;
            cx.notify();
            return;
        }
        if self.command_menu(cx).is_some() {
            self.menu_dismissed = Some(self.composer.read(cx).value().to_string());
            cx.notify();
            return;
        }
        if self.open_picker.take().is_some() {
            cx.notify();
            return;
        }
        if self.diff.take().is_some() {
            cx.notify();
            return;
        }
        self.session.update(cx, |session, cx| session.interrupt(cx));
    }

    fn pick_attachments(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let paths = cx.prompt_for_paths(PathPromptOptions {
            files: true,
            directories: false,
            multiple: true,
            prompt: Some("Attach".into()),
        });
        cx.spawn_in(window, async move |this, cx| {
            let Ok(Ok(Some(paths))) = paths.await else { return };
            let _ = this.update_in(cx, |this, window, cx| {
                for path in paths {
                    if !this.attachments.contains(&path) {
                        this.attachments.push(path);
                    }
                }
                this.focus_composer(window, cx);
                cx.notify();
            });
        })
        .detach();
    }

    /// Shows what a finished turn changed, all files or one.
    fn open_diff(&mut self, end: usize, path: Option<String>, window: &mut Window, cx: &mut Context<Self>) {
        let Some(changes) = self.session.read(cx).turn_changes(end) else { return };
        let title = match &path {
            Some(path) => path.clone(),
            None => format!("{} changed files", changes.files.len()),
        };
        let (ignore_whitespace, side_by_side, collapsed) = settings::get(|settings| {
            (settings.hide_whitespace, settings.diff_side_by_side, settings.diff_collapsed)
        });
        self.diff = Some(DiffView {
            title: title.into(),
            end,
            path,
            ignore_whitespace,
            side_by_side,
            start_collapsed: collapsed,
            ..Default::default()
        });
        self.load_diff(window, cx);
        cx.notify();
    }

    /// Reads the open diff in the background, as its whitespace toggle asks.
    fn load_diff(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        let Some(diff) = &self.diff else { return };
        let session = self.session.read(cx);
        let Some(changes) = session.turn_changes(diff.end) else { return };
        if session.is_remote() {
            // The snapshots live in the host's repository.
            let note = Err("This thread runs on another device; its diffs open there.".to_string());
            if let Some(diff) = self.diff.as_mut() {
                diff.set_files(note);
            }
            return;
        }
        let (base, head) = (changes.base.clone(), changes.head.clone());
        let cwd = session.cwd.clone();
        let (end, path, ignore_whitespace) = (diff.end, diff.path.clone(), diff.ignore_whitespace);
        let task = cx.background_executor().spawn({
            let path = path.clone();
            async move { git::diff_between(&cwd, &base, &head, path.as_deref(), ignore_whitespace) }
        });
        cx.spawn_in(window, async move |this, cx| {
            let files = task.await;
            let _ = this.update(cx, |this, cx| {
                // Unless the diff was closed, replaced or toggled meanwhile.
                if let Some(diff) = this.diff.as_mut()
                    && (diff.end, &diff.path, diff.ignore_whitespace) == (end, &path, ignore_whitespace)
                {
                    diff.set_files(files);
                    cx.notify();
                }
            });
        })
        .detach();
    }

    fn diff_action(&mut self, action: DiffAction, window: &mut Window, cx: &mut Context<Self>) {
        let Some(diff) = self.diff.as_mut() else { return };
        match action {
            DiffAction::Close => self.diff = None,
            DiffAction::ToggleWhitespace => {
                diff.ignore_whitespace = !diff.ignore_whitespace;
                self.load_diff(window, cx);
            }
            DiffAction::SetSideBySide(side_by_side) => {
                diff.side_by_side = side_by_side;
                settings::update(|settings| settings.diff_side_by_side = side_by_side);
            }
            DiffAction::ToggleFile(path) => diff.toggle_file(path),
            DiffAction::SetAllCollapsed(collapsed) => diff.set_all_collapsed(collapsed),
        }
        cx.notify();
    }

    /// A callback that closes whichever picker is open.
    fn close_picker(cx: &Context<Self>) -> impl Fn(&mut Window, &mut App) + Clone + 'static {
        let chat = cx.entity().downgrade();
        move |window: &mut Window, cx: &mut App| {
            let _ = chat.update(cx, |this, cx| {
                this.open_picker = None;
                this.focus_composer(window, cx);
                cx.notify();
            });
        }
    }

    fn set_picker(&mut self, picker: Picker, open: bool, window: &mut Window, cx: &mut Context<Self>) {
        if open {
            self.open_picker = Some(picker);
            match picker {
                Picker::Model => self.model_picker.update(cx, |model_picker, cx| model_picker.opened(window, cx)),
                Picker::Branch => self.open_branch_picker(window, cx),
                _ => {}
            }
        } else if self.open_picker == Some(picker) {
            self.open_picker = None;
        }
        cx.notify();
    }

    /// "What should we build in T3-2?" with the project as a switcher.
    fn render_start(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let cwd = session.project.clone();
        let env = session.remote.as_ref().map(|remote| remote.env.clone());
        let directory = cx.try_global::<ProjectDirectory>();
        let current = directory.and_then(|directory| directory.find(&cwd, env.as_deref()));
        let scratch = current.is_some_and(|project| project.scratch);
        let project = current.map(|project| project.name.clone()).unwrap_or_else(|| display_path(&cwd).into());
        let projects: Vec<(SharedString, PathBuf, Option<String>)> = directory
            .map(|directory| {
                directory
                    .projects
                    .iter()
                    .filter(|project| !project.scratch)
                    .map(|project| {
                        let name = match &project.host {
                            Some(host) => format!("{} · {host}", project.name).into(),
                            None => project.name.clone(),
                        };
                        (name, project.path.clone(), project.environment.clone())
                    })
                    .collect()
            })
            .unwrap_or_default();
        let chat = cx.entity().downgrade();
        // T3 Code's project menu: No project, the projects, Add project.
        let project_menu = move |mut menu: gpui_kit::component::menu::PopupMenu, _: &mut Window, _: &mut Context<gpui_kit::component::menu::PopupMenu>| {
            menu = menu.min_w(px(240.)).max_h(px(320.));
            let no_project = chat.clone();
            menu = menu.item(
                PopupMenuItem::new("No project")
                    .icon(Icon::new(Lucide::MessageSquareDashed))
                    .checked(scratch)
                    .on_click(move |_, _, cx| {
                        let _ = no_project.update(cx, |_, cx| cx.emit(ChatEvent::StartWithoutProject));
                    }),
            );
            for (name, path, project_env) in projects.clone() {
                let chat = chat.clone();
                let checked = path == cwd && project_env == env;
                menu = menu.item(PopupMenuItem::new(name).checked(checked).on_click(move |_, _, cx| {
                    let (path, project_env) = (path.clone(), project_env.clone());
                    let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::SwitchProject(path, project_env)));
                }));
            }
            let chat = chat.clone();
            menu.separator().item(PopupMenuItem::new("Add project").icon(Icon::new(Lucide::FolderPlus)).on_click(
                move |_, _, cx| {
                    let _ = chat.update(cx, |_, cx| cx.emit(ChatEvent::AddProject));
                },
            ))
        };
        let picker = |id: &'static str, label: SharedString| {
            MetalButton::new(id)
                .ghost()
                .label(label)
                .px(px(2.))
                .rounded(px(0.))
                .border_b(px(1.))
                .border_dashed()
                .border_color(p.ink3.opacity(0.6))
        };

        v_flex()
            .flex_1()
            .items_center()
            .justify_center()
            .px(px(24.))
            .pb(px(48.))
            .child(
                h_flex()
                    .flex_wrap()
                    .justify_center()
                    .items_center()
                    .text_size(px(30.))
                    .line_height(px(36.))
                    .text_color(p.ink)
                    .map(|this| {
                        if scratch {
                            this.child("What should we work on?")
                        } else {
                            this.child("What should we build in\u{a0}")
                                .child(
                                    picker("start-project", project)
                                        .h(px(36.))
                                        .max_w(px(256.))
                                        .text_size(px(30.))
                                        .font_weight(FontWeight::NORMAL)
                                        .text_color(p.ink)
                                        .dropdown_menu(project_menu.clone()),
                                )
                                .child("?")
                        }
                    }),
            )
            .child(div().mt(px(8.)).h(px(24.)).flex().items_center().text_size(px(14.)).map(|this| {
                if scratch {
                    this.child(
                        picker("pick-project", "No project".into())
                            .h(px(24.))
                            .text_size(px(14.))
                            .text_color(p.ink3)
                            .dropdown_menu(project_menu),
                    )
                } else {
                    this.child(
                        div()
                            .id("start-without-project")
                            .text_color(p.ink3)
                            .cursor_pointer()
                            .hover(|style| style.text_color(p.ink).underline())
                            .tooltip(|window, cx| {
                                gpui_kit::component::tooltip::Tooltip::new("Ctrl+Alt+N").build(window, cx)
                            })
                            .on_click(cx.listener(|_, _, _, cx| cx.emit(ChatEvent::StartWithoutProject)))
                            .child("or start without a project"),
                    )
                }
            }))
            .child(div().h(px(24.)))
            .child(self.render_composer(true, window, cx))
    }

    fn render_transcript(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let session = self.session.clone();
        let scroller = self.scroller.clone();
        let chat = cx.entity().downgrade();
        div()
            .flex_1()
            .min_h_0()
            .w_full()
            .flex()
            .flex_col()
            // Scrolling rests the composer, when set to.
            .on_scroll_wheel(cx.listener(|this, _, _, cx| {
                let empty = this.composer.read(cx).value().is_empty() && this.attachments.is_empty();
                if !this.collapsed && empty && settings::get(|settings| settings.collapse_composer) {
                    this.collapsed = true;
                    cx.notify();
                }
            }))
            .child(
                MessageScroller::new("transcript", self.scroller.clone(), move |ix, window, cx| {
                    timeline::render_item(&session, &scroller, &chat, ix, window, cx)
                })
                .with_jump_button_label("Scroll to end")
                .flex_1()
                .w_full()
                .with_row_style(StyleRefinement::default().pb_0())
                .with_bottom_fade(metal::palette(cx).s),
            )
    }

    /// The line closing the conversation, as T3 Code ends its timeline: the
    /// worktree being set up, or once the agent is done, whether the thread
    /// is snoozed or settled. (While the agent works, "Working for 12s"
    /// heads its turn in the timeline.)
    fn render_status_line(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        if session.worktree_setup.is_some() {
            return Some(self.render_worktree_setup(cx));
        }
        let snoozed = session.snoozed_until.filter(|_| session.is_snoozed());
        let settled = session.settled;
        if snoozed.is_none() && !settled {
            return None;
        }
        let action_session = self.session.clone();
        let (icon, label, action): (Icon, String, &'static str) = match snoozed {
            Some(until) => (Icon::new(Lucide::AlarmClock), format!("Snoozed, {}", clock::when(until)), "Wake now"),
            None => (Icon::new(IconName::CircleCheck), "Settled".to_string(), "Un-settle"),
        };
        Some(
            h_flex()
                .w_full()
                .max_w(px(CONTENT_WIDTH))
                .px(px(16.))
                .pb(px(8.))
                .gap(px(6.))
                .text_size(px(12.))
                .text_color(p.ink3)
                .child(icon.size(px(14.)))
                .child(div().truncate().child(label))
                .child("·")
                .child(
                    div()
                        .id("status-line-action")
                        .font_weight(FontWeight::MEDIUM)
                        .cursor_pointer()
                        .hover(|style| style.text_color(p.ink).underline())
                        .on_click(move |_, _, cx| {
                            action_session.update(cx, |session, cx| {
                                if snoozed.is_some() {
                                    session.snooze(None, cx)
                                } else {
                                    session.unsettle(cx)
                                }
                            })
                        })
                        .child(action),
                )
                .into_any_element(),
        )
    }

    /// T3 Code's worktree setup card: the stages of making the thread's
    /// worktree as they run, with Details, Work locally and Cancel; or what
    /// went wrong once it failed or was cancelled.
    fn render_worktree_setup(&self, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let Some(setup) = session.worktree_setup.as_ref() else { return div().into_any_element() };
        let running = session.preparing;
        let header = if running {
            "Setting up worktree…"
        } else if setup.cancelled || setup.cancel.is_some() {
            "Worktree setup cancelled"
        } else if setup.error.is_some() {
            "Worktree setup failed"
        } else {
            "Worktree ready"
        };
        let header_color = if setup.error.is_some() && !running { p.red } else { p.ink3 };
        let stage_rows = setup.stages.iter().map(|(stage, status, started, ended)| {
            let (icon, color): (AnyElement, Hsla) = match status {
                StageStatus::Done => (Icon::new(IconName::Check).size(px(14.)).into_any_element(), p.ink3),
                StageStatus::Running => (metal::led(("setup-led", *stage as usize), Led::Live, true, p), p.ink2),
                StageStatus::Failed => (Icon::new(IconName::Close).size(px(14.)).into_any_element(), p.red),
                StageStatus::Skipped => (Icon::new(IconName::Minus).size(px(14.)).into_any_element(), p.ink3),
                StageStatus::Pending => (Icon::new(Lucide::Circle).size(px(14.)).into_any_element(), p.ink3.opacity(0.4)),
            };
            let elapsed = started.map(|started| ended.unwrap_or_else(std::time::Instant::now).duration_since(started).as_secs());
            let trailing = match status {
                StageStatus::Skipped => Some("skipped".to_string()),
                StageStatus::Pending => None,
                _ => elapsed.map(format_elapsed),
            };
            h_flex()
                .h(px(24.))
                .gap(px(10.))
                .text_size(px(13.))
                .text_color(color)
                .child(div().w(px(16.)).flex().justify_center().child(icon))
                .child(div().flex_1().min_w_0().truncate().child(stage.label()))
                .when_some(trailing, |this, trailing| this.child(div().text_size(px(12.)).text_color(p.ink3).child(trailing)))
        });
        let details = self.setup_details.then(|| {
            let row = |label: &'static str, value: String| {
                h_flex()
                    .gap(px(12.))
                    .child(div().w(px(48.)).text_color(p.ink2).child(label))
                    .child(div().flex_1().min_w_0().truncate().font_family(metal::MONO).child(value))
            };
            v_flex()
                .ml(px(26.))
                .mt(px(4.))
                .gap(px(2.))
                .text_size(px(12.))
                .text_color(p.ink3)
                .when_some(setup.branch.clone(), |this, branch| this.child(row("Branch", branch)))
                .child(row("Base", setup.base.clone()))
                .when_some(setup.path.clone(), |this, path| this.child(row("Path", display_path(&path))))
        });
        let (cancel, local) = (self.session.clone(), self.session.clone());
        v_flex()
            .w_full()
            .max_w(px(CONTENT_WIDTH))
            .px(px(16.))
            .pb(px(8.))
            .child(
                h_flex()
                    .h(px(24.))
                    .px(px(4.))
                    .gap(px(8.))
                    .mb(px(4.))
                    .text_size(px(14.))
                    .text_color(header_color)
                    .when(running, |this| this.child(metal::led("setup-header-led", Led::Live, true, p)))
                    .child(header)
                    .child(div().flex_1())
                    .child(div().text_size(px(12.)).text_color(p.ink3).child(format_elapsed(setup.started.elapsed().as_secs()))),
            )
            .child(v_flex().px(px(4.)).children(stage_rows))
            .when_some(setup.error.clone().filter(|_| !running), |this, error| {
                this.child(div().ml(px(30.)).mt(px(4.)).text_size(px(12.)).text_color(p.ink3).child(error))
            })
            .children(details)
            .child(
                h_flex()
                    .ml(px(26.))
                    .mt(px(6.))
                    .gap(px(4.))
                    .child(
                        MetalButton::new("setup-details")
                            .ghost()
                            .mini()
                            .label("Details")
                            .on_click(cx.listener(|this, _, _, cx| {
                                this.setup_details = !this.setup_details;
                                cx.notify();
                            })),
                    )
                    .when(running, |this| {
                        this.child(
                            MetalButton::new("setup-local")
                                .ghost()
                                .mini()
                                .icon(Lucide::Laptop)
                                .label("Work locally")
                                .on_click(move |_, _, cx| {
                                    local.update(cx, |session, cx| session.cancel_worktree_setup(SetupCancel::Local, cx))
                                }),
                        )
                        .child(
                            MetalButton::new("setup-cancel")
                                .ghost()
                                .mini()
                                .label("Cancel")
                                .on_click(move |_, _, cx| {
                                    cancel.update(cx, |session, cx| session.cancel_worktree_setup(SetupCancel::Cancel, cx))
                                }),
                        )
                    }),
            )
            .into_any_element()
    }

    /// A composer picker: a trigger that opens `content` above it.
    fn picker(
        &self,
        picker: Picker,
        trigger: MetalButton,
        content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
        cx: &mut Context<Self>,
    ) -> impl IntoElement {
        Popover::new(SharedString::from(format!("{picker:?}-popover")))
            .anchor(Anchor::BottomLeft)
            .p_0()
            .bg(gpui_kit::transparent_black())
            .shadow_none()
            .open(self.open_picker == Some(picker))
            .on_open_change(cx.listener(move |this, open: &bool, window, cx| this.set_picker(picker, *open, window, cx)))
            .trigger(trigger)
            .content(move |_, window, cx| content(window, cx))
    }

    /// A picker whose content reads the conversation as it opens.
    fn picker_anchored(
        &self,
        picker: Picker,
        trigger: MetalButton,
        anchor: Anchor,
        cx: &mut Context<Self>,
        content: impl Fn(&mut Self, &mut Context<Self>) -> AnyElement + 'static,
    ) -> impl IntoElement {
        let chat = cx.entity().downgrade();
        let content = std::rc::Rc::new(content);
        Popover::new(SharedString::from(format!("{picker:?}-popover")))
            .anchor(anchor)
            .p_0()
            .bg(gpui_kit::transparent_black())
            .shadow_none()
            .open(self.open_picker == Some(picker))
            .on_open_change(cx.listener(move |this, open: &bool, window, cx| this.set_picker(picker, *open, window, cx)))
            .trigger(trigger)
            .content(move |_, _, cx| {
                let content = content.clone();
                chat.update(cx, |this, cx| content(this, cx)).unwrap_or_else(|_| div().into_any_element())
            })
    }

    fn render_composer(&self, start: bool, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let approval = dock::pending_approval(session).is_some_and(|(_, tool, _)| tool.name != "ExitPlanMode");
        let asking = session.pending_ask().is_some();
        let focused = self.composer.read(cx).focus_handle(cx).is_focused(window);
        let dock = self.render_dock(cx);
        let collapsed = self.collapsed && !start && dock.is_empty();
        let strip = self.strip_visible(cx).then(|| self.render_strip(cx));

        // MetalUI marks focus with a green-deep ring.
        let mut card_shadow = p.raise.clone();
        if focused && !approval {
            card_shadow.push(BoxShadow::new(px(0.), px(0.), p.green_deep.opacity(0.4)).spread_radius(px(1.)));
        }
        let measure = {
            let chat = cx.entity().downgrade();
            let known = self.composer_width;
            canvas(
                move |bounds, _, cx| {
                    if (bounds.size.width - known).abs() > px(0.5) {
                        let _ = chat.update(cx, |this, cx| {
                            this.composer_width = bounds.size.width;
                            cx.notify();
                        });
                    }
                },
                |_, _, _, _| {},
            )
            .absolute()
            .size_full()
        };

        let textarea = div()
            .text_size(px(14.))
            .text_color(p.ink)
            .child(MarkdownInput::new(&self.composer).rows(if approval { 2 } else { 3 }, 9));
        let card = if collapsed {
            // Resting: one line, a click (or typing) away from the full composer.
            h_flex()
                .id("composer-collapsed")
                .relative()
                .w_full()
                .h(px(48.))
                .pl(px(16.))
                .pr(px(8.))
                .gap(px(8.))
                .rounded(px(24.))
                .bg(metal::vertical((p.s_hi, p.s)))
                .shadow(card_shadow)
                .cursor_text()
                .on_click(cx.listener(|this, _, window, cx| {
                    this.expand(cx);
                    this.focus_composer(window, cx);
                }))
                .child(measure)
                .child(textarea.flex_1().min_w_0().h(px(22.)).overflow_hidden())
                .child(self.render_actions(start, cx))
                .into_any_element()
        } else {
            let right_width = self.actions_width(cx);
            v_flex()
                .id("composer-card")
                .relative()
                .w_full()
                .p(px(1.))
                .rounded(px(24.))
                .bg(metal::vertical((p.s_hi, p.s)))
                .shadow(card_shadow)
                .when(approval, |this| this.opacity(0.92))
                // Files dropped on the composer are attached.
                .drag_over::<ExternalPaths>(move |style, _, _, _| {
                    style.bg(p.menu_row_hover).border_1().border_color(p.green_deep.opacity(0.7))
                })
                .on_drop(cx.listener(|this, paths: &ExternalPaths, window, cx| {
                    for path in paths.paths() {
                        if !this.attachments.contains(path) {
                            this.attachments.push(path.clone());
                        }
                    }
                    this.focus_composer(window, cx);
                    cx.notify();
                }))
                .child(measure)
                .child(
                    v_flex()
                        .px(px(16.))
                        .pt(px(16.))
                        .pb(px(if approval { 16. } else { 8. }))
                        .when(!self.attachments.is_empty() && !asking, |this| this.child(self.render_attachments(cx)))
                        .child(textarea),
                )
                .when(!approval, |this| {
                    this.child(
                        h_flex()
                            .w_full()
                            .justify_between()
                            .px(px(16.))
                            .pb(px(16.))
                            .when(asking, |this| this.pt(px(8.)))
                            .child(self.render_controls(right_width, cx))
                            .child(self.render_actions(start, cx)),
                    )
                })
                .into_any_element()
        };

        v_flex()
            .flex_none()
            .w_full()
            .items_center()
            .px(px(24.))
            .pb(px(if start { 0. } else { 20. }))
            .when(!start, |this| this.children(self.render_status_line(cx)))
            .child(
                v_flex()
                    .w_full()
                    .max_w(px(CONTENT_WIDTH))
                    .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
                        this.composer_key(event, window, cx)
                    }))
                    .children(dock)
                    // The strip is laid out below the card but painted first,
                    // so the card covers its top edge.
                    .child(v_flex().w_full().flex_col_reverse().children(strip).child(card)),
            )
    }

    /// What's attached to the message, as T3 Code shows it above the
    /// prompt: images as thumbnails, other files as a list with their size.
    fn render_attachments(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let is_image = |path: &PathBuf| {
            matches!(
                path.extension().and_then(|ext| ext.to_str()).map(str::to_ascii_lowercase).as_deref(),
                Some("png" | "jpg" | "jpeg" | "gif" | "webp")
            )
        };
        let chat = cx.entity().downgrade();
        let remove = |ix: usize, _: &mut Context<Self>| {
            let chat = chat.clone();
            move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
                let _ = chat.update(cx, |this, cx| {
                    if ix < this.attachments.len() {
                        this.attachments.remove(ix);
                    }
                    cx.notify();
                });
            }
        };
        let name_of = |path: &PathBuf| {
            path.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_else(|| path.display().to_string())
        };
        let images: Vec<AnyElement> = self
            .attachments
            .iter()
            .enumerate()
            .filter(|(_, path)| is_image(path))
            .map(|(ix, path)| {
                let name = name_of(path);
                div()
                    .id(("attachment-image", ix))
                    .relative()
                    .size(px(64.))
                    .rounded(px(8.))
                    .overflow_hidden()
                    .well(p)
                    .child(img(path.clone()).size_full().object_fit(ObjectFit::Cover))
                    .child(
                        div().absolute().top(px(4.)).right(px(4.)).child(
                            MetalButton::new(("remove-image", ix))
                                .mini()
                                .size(px(20.))
                                .icon(Icon::new(IconName::Close).size(px(11.)))
                                .tooltip(format!("Remove {name}"))
                                .on_click(remove(ix, cx)),
                        ),
                    )
                    .into_any_element()
            })
            .collect();
        let files: Vec<AnyElement> = self
            .attachments
            .iter()
            .enumerate()
            .filter(|(_, path)| !is_image(path))
            .map(|(ix, path)| {
                let name = name_of(path);
                let size = std::fs::metadata(path).map(|meta| format_size(meta.len())).unwrap_or_default();
                h_flex()
                    .id(("attachment-file", ix))
                    .gap(px(8.))
                    .py(px(4.))
                    .text_size(px(14.))
                    .child(Icon::new(Lucide::File).size(px(16.)).text_color(p.ink3))
                    .child(div().flex_1().min_w_0().truncate().text_color(p.ink).child(name.clone()))
                    .child(div().text_size(px(12.)).text_color(p.ink3).child(size))
                    .child(
                        MetalButton::new(("remove-file", ix))
                            .ghost()
                            .mini()
                            .size(px(24.))
                            .icon(IconName::Close)
                            .tooltip(format!("Remove {name}"))
                            .on_click(remove(ix, cx)),
                    )
                    .into_any_element()
            })
            .collect();
        v_flex()
            .when(!images.is_empty(), |this| this.child(h_flex().flex_wrap().gap(px(8.)).mb(px(12.)).children(images)))
            .when(!files.is_empty(), |this| this.child(v_flex().gap(px(4.)).mb(px(12.)).children(files)))
    }
}

impl Render for ChatView {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let is_empty = self.session.read(cx).items.is_empty();
        let on_diff = {
            let chat = cx.entity().downgrade();
            move |action: DiffAction, window: &mut Window, cx: &mut App| {
                let _ = chat.update(cx, |this, cx| this.diff_action(action, window, cx));
            }
        };
        v_flex()
            .key_context("ChatView")
            .size_full()
            .on_action(cx.listener(|this, _: &Interrupt, _, cx| this.escape(cx)))
            .on_action(cx.listener(|this, _: &input::Escape, _, cx| this.escape(cx)))
            .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| this.pick_with_key(event, window, cx)))
            .map(|this| match &self.diff {
                Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, on_diff, cx))),
                None if is_empty => this.child(self.render_start(window, cx)),
                None => this.child(self.render_transcript(cx)),
            })
            .when(!is_empty, |this| this.child(self.render_composer(false, window, cx)))
    }
}

/// Shortens a path under the home directory to `~/…`.
pub fn display_path(path: &std::path::Path) -> String {
    let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
    match home.as_deref().and_then(|home| path.strip_prefix(home).ok()) {
        Some(rest) if rest.as_os_str().is_empty() => "~".to_string(),
        Some(rest) => format!("~/{}", rest.display()),
        None => path.display().to_string(),
    }
}

fn format_tokens(tokens: u64) -> String {
    let (value, unit) = match tokens {
        0..1_000 => return tokens.to_string(),
        1_000..1_000_000 => (tokens as f64 / 1_000., "k"),
        _ => (tokens as f64 / 1_000_000., "M"),
    };
    let value = format!("{value:.1}");
    format!("{}{unit}", value.trim_end_matches(".0"))
}

/// Markdown in MetalUI's voice: code sits in pressed wells. Colors come from
/// the theme, which `metal` keeps in step with the theme.
pub(super) fn markdown_style(p: &Palette) -> TextViewStyle {
    let mut code_block = StyleRefinement::default()
        .rounded(px(14.))
        .bg(metal::vertical((p.well_top, p.well_bot)))
        .shadow(p.well.clone());
    code_block.padding.left = Some(px(14.).into());
    code_block.padding.right = Some(px(14.).into());
    TextViewStyle {
        is_dark: p.dark,
        ..TextViewStyle::default()
    }
    .code_block(code_block)
    .table(StyleRefinement::default().rounded(px(12.)))
    .inline_code(HighlightStyle {
        background_color: Some(p.well_top),
        ..Default::default()
    })
}

/// Markdown with a copy button on every code block.
pub(super) fn markdown(state: &Entity<gpui_kit::component::text::TextViewState>, p: &Palette) -> TextView {
    TextView::new(state)
        .style(markdown_style(p))
        .selectable(true)
        .code_block_actions(|block, _, _| {
            let code = block.code();
            let id = {
                use std::hash::{Hash as _, Hasher as _};
                let mut hasher = std::collections::hash_map::DefaultHasher::new();
                code.hash(&mut hasher);
                hasher.finish()
            };
            MetalButton::new(ElementId::NamedInteger("copy-code".into(), id))
                .ghost()
                .mini()
                .icon(IconName::Copy)
                .tooltip("Copy")
                .on_click(move |_, _, cx| cx.write_to_clipboard(ClipboardItem::new_string(code.to_string())))
        })
}

/// The row a finished turn folds into: "Worked for 11s ›".
pub(super) fn truncate(text: &str, max_chars: usize) -> String {
    if text.chars().count() <= max_chars {
        return text.to_string();
    }
    let mut truncated: String = text.chars().take(max_chars).collect();
    truncated.push_str("\n… (truncated)");
    truncated
}


/// The files and folders of `cwd` for the `@` menu: what git tracks (or
/// would, untracked files included), else a shallow walk. Folders end in `/`.
fn list_files(cwd: &std::path::Path) -> Vec<String> {
    const MAX_FILES: usize = 20_000;
    let mut files: Vec<String> = match git::git(cwd, &["ls-files", "--cached", "--others", "--exclude-standard", "-z"]) {
        Ok(out) => out.split('\0').filter(|file| !file.is_empty()).take(MAX_FILES).map(str::to_string).collect(),
        Err(_) => {
            let mut found = Vec::new();
            let mut stack = vec![(cwd.to_path_buf(), 0)];
            while let Some((dir, depth)) = stack.pop() {
                let Ok(entries) = std::fs::read_dir(&dir) else { continue };
                for entry in entries.flatten() {
                    let path = entry.path();
                    let name = entry.file_name().to_string_lossy().to_string();
                    if name.starts_with('.') || found.len() >= MAX_FILES {
                        continue;
                    }
                    if path.is_dir() {
                        if depth < 4 && name != "node_modules" && name != "target" {
                            stack.push((path, depth + 1));
                        }
                    } else if let Ok(relative) = path.strip_prefix(cwd) {
                        found.push(relative.to_string_lossy().to_string());
                    }
                }
            }
            found
        }
    };
    let mut folders: Vec<String> = files
        .iter()
        .flat_map(|file| {
            let parts: Vec<&str> = file.split('/').collect();
            (1..parts.len()).map(move |n| format!("{}/", parts[..n].join("/")))
        })
        .collect();
    folders.sort();
    folders.dedup();
    files.sort();
    folders.extend(files);
    folders
}

/// `12s`, `1m 5s`, `1h 2m`, as T3 Code's working timer reads.
pub(super) fn format_elapsed(seconds: u64) -> String {
    match seconds {
        0..60 => format!("{seconds}s"),
        60..3600 => format!("{}m {}s", seconds / 60, seconds % 60),
        _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60),
    }
}

/// `12 KB`, `3.4 MB`.
fn format_size(bytes: u64) -> String {
    match bytes {
        0..1_024 => format!("{bytes} B"),
        1_024..1_048_576 => format!("{:.0} KB", bytes as f64 / 1_024.),
        _ => format!("{:.1} MB", bytes as f64 / 1_048_576.),
    }
}