//! 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 find;
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::{
ComposerBranch, ComposerEffort, ComposerHost, ComposerMode, ComposerPreviousWorktree, ComposerStash, ComposerWorkspace,
FindInThread, Interrupt, ToggleModelPicker,
};
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, 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)>,
/// The find bar, while open (Ctrl+F).
find: Option<find::Find>,
/// 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,
/// Whether the conversation was scrolled away from its end last frame.
was_scrolled_up: 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_shortcut) != settings::SendShortcut::ModEnter)
.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 = this.ctrl_sends(cx);
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.update_send_key(cx);
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,
find: 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,
was_scrolled_up: 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>) {
self.update_send_key(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();
}
/// Whether Ctrl+Enter sends right now, as Settings → Send shortcut says:
/// always, never, or once the prompt runs over several lines.
pub(super) fn ctrl_sends(&self, cx: &App) -> bool {
match settings::get(|settings| settings.send_shortcut) {
settings::SendShortcut::Enter => false,
settings::SendShortcut::ModEnter => true,
settings::SendShortcut::ModEnterMultiline => self.composer.read(cx).value().contains('\n'),
}
}
/// Lets Enter send, or start a new line, as the send shortcut wants.
fn update_send_key(&mut self, cx: &mut Context<Self>) {
let enter_sends = !self.ctrl_sends(cx);
self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(enter_sends, cx));
}
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));
}
/// What's typed and attached but not sent, for the sidebar's draft rows.
pub fn draft(&self, cx: &App) -> (String, usize) {
(self.composer.read(cx).value().to_string(), self.attachments.len())
}
/// Empties the composer, as discarding an unsent draft does.
pub fn clear_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.attachments.clear();
self.composer.update(cx, |composer, cx| composer.set_value("", window, 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();
}
}
/// Opens (or closes) one of the composer's controls, as T3 Code's
/// composer shortcuts do; a control folded into "…" opens that menu.
fn open_control(&mut self, picker: Picker, window: &mut Window, cx: &mut Context<Self>) {
let (traits_folded, access_folded) = self.folded.get();
let picker = match picker {
Picker::Traits if traits_folded => Picker::Overflow,
Picker::Access if access_folded => Picker::Overflow,
Picker::Branch | Picker::Workspace | Picker::Environment if !self.strip_visible(cx) => return,
Picker::Workspace | Picker::Environment if !self.session.read(cx).items.is_empty() => return,
picker => picker,
};
self.expand(cx);
let open = self.open_picker != Some(picker);
self.set_picker(picker, open, window, cx);
if !open {
self.focus_composer(window, cx);
}
}
/// 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, _: &mut Window, cx: &mut Context<Self>) {
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>) {
// `/model` opens the model picker, as in T3 Code.
if let MenuItem::Command(command) = item
&& command.name == "model"
&& menu.trigger == '/'
{
let start = menu.start;
self.composer.update(cx, |composer, cx| {
let cursor = composer.cursor();
composer.set_selected_range(start..cursor, cx);
composer.replace("", window, cx);
});
self.set_picker(Picker::Model, true, window, cx);
return;
}
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 == "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, window: &mut Window, cx: &mut Context<Self>) {
// Closing the find bar or a picker hands the keyboard back to the composer.
if self.find.take().is_some() || self.open_picker.take().is_some() {
self.focus_composer(window, cx);
cx.notify();
return;
}
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.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;
// A picker closed with Esc or a click outside leaves no focus.
self.focus_composer(window, cx);
}
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()
.relative()
.flex_1()
.min_h_0()
.w_full()
.flex()
.flex_col()
// Scrolling rests the composer, when set to.
.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")
.with_jump_button_renderer(|button| button.icon(IconName::ChevronDown).label("Scroll to end"))
.flex_1()
.w_full()
.with_row_style(StyleRefinement::default().pb_0())
.with_bottom_fade(metal::palette(cx).s),
)
.children(self.render_find_bar(cx))
}
/// 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(composer::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(composer::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 rows = if collapsed { (1, 1) } else if approval { (2, 9) } else { (3, 9) };
let textarea = div()
.text_size(px(14.))
.text_color(p.ink)
.child(MarkdownInput::new(&self.composer).rows(rows.0, rows.1));
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().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(composer::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();
// Scrolling up through the conversation rests the composer, when set
// to, as in T3 Code; a click or typing wakes it.
let scrolled_up = self.scroller.read(cx).is_scrolled_up();
if scrolled_up && !self.was_scrolled_up {
let empty = self.composer.read(cx).value().is_empty() && self.attachments.is_empty();
if empty && settings::get(|settings| settings.collapse_composer) {
self.collapsed = true;
}
}
self.was_scrolled_up = scrolled_up;
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, window, cx| this.escape(window, cx)))
.on_action(cx.listener(|this, _: &ToggleModelPicker, window, cx| this.open_control(Picker::Model, window, cx)))
.on_action(cx.listener(|this, _: &ComposerEffort, window, cx| this.open_control(Picker::Traits, window, cx)))
.on_action(cx.listener(|this, _: &ComposerMode, window, cx| this.open_control(Picker::Access, window, cx)))
.on_action(cx.listener(|this, _: &ComposerBranch, window, cx| this.open_control(Picker::Branch, window, cx)))
.on_action(cx.listener(|this, _: &ComposerWorkspace, window, cx| this.open_control(Picker::Workspace, window, cx)))
.on_action(cx.listener(|this, _: &ComposerHost, window, cx| this.open_control(Picker::Environment, window, cx)))
.on_action(cx.listener(|this, _: &ComposerPreviousWorktree, window, cx| {
if this.session.read(cx).items.is_empty() {
this.use_previous_worktree(window, cx);
}
}))
.on_action(cx.listener(|this, _: &ComposerStash, window, cx| this.stash_prompt(window, cx)))
.on_action(cx.listener(|this, _: &FindInThread, window, cx| {
if !this.session.read(cx).items.is_empty() && this.diff.is_none() {
this.open_find(window, cx);
}
}))
.on_action(cx.listener(|this, _: &input::Escape, window, cx| this.escape(window, 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.),
}
}