Vectrum
Sign In

Timeline like T3 Code's

User messages are bubbles with their time and a copy button under them on
hover; a long one folds behind "Show full message". The agent's answer
shows a copy button and its time on hover. Tool calls are compact work-log
rows (Read README.md, cargo test, Searched for TODO) that unfold into their
input and output, and a run of them sits under one summary, "Ran 1 command,
changed 1 file, and performed 3 other actions", that unfolds into each; in
the live turn the run shows its latest call ("Running cargo"). The live
turn opens with "Working for 12s", a finished one folds under "Worked for
2m 14s" (its time and cost on hover). Claude's plans are cards with a Plan
badge, Copy to clipboard / Download as markdown / Save to workspace, and
Expand plan; answered questions read as question and answer; notices are
dividers ("Context compacted"), errors tinted boxes, background task
endings their own rows. The changed files card follows T3's, and the jump
button reads "Scroll to end".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 382ed4dcommitd508fca

Showing 6 changed files with 1209 additions and 672 deletions.

+29−0src/clock.rsView File
@@ -58,3 +58,32 @@ pub fn at_hour(days: i64, hour: u32) -> SystemTime {
5858 .map(SystemTime::from)
5959 .unwrap_or_else(SystemTime::now)
6060 }
61+
62+ /// A message's time as T3 Code's timeline shows it: `12:34 PM` today,
63+ /// `yesterday at 12:34 PM`, `8/13 12:34 PM`, or `8/13/2025 12:34 PM` in
64+ /// another year.
65+ pub fn day_aware(time: SystemTime) -> String {
66+ let local: DateTime<Local> = time.into();
67+ let today = Local::now().date_naive();
68+ let clock = clock(time);
69+ match (local.date_naive() - today).num_days() {
70+ 0 => clock,
71+ -1 => format!("yesterday at {clock}"),
72+ _ if local.year() == today.year() => format!("{} {clock}", local.format("%-m/%-d")),
73+ _ => format!("{} {clock}", local.format("%-m/%-d/%Y")),
74+ }
75+ }
76+
77+ /// The long form for a tooltip: `12:04 PM, 4th June 2026`.
78+ pub fn long_form(time: SystemTime) -> String {
79+ let local: DateTime<Local> = time.into();
80+ let day = local.day();
81+ let suffix = match (day % 10, day % 100) {
82+ (_, 11..=13) => "th",
83+ (1, _) => "st",
84+ (2, _) => "nd",
85+ (3, _) => "rd",
86+ _ => "th",
87+ };
88+ format!("{}, {day}{suffix} {}", clock(time), local.format("%B %Y"))
89+ }
+16−15src/ui/changes.rsView File
@@ -151,18 +151,19 @@ pub fn render_changes(
151151
152152 v_flex()
153153 .w_full()
154- .rounded(px(14.))
155- .raised(p)
154+ .mt(px(16.))
155+ .rounded(px(10.))
156+ .region(p)
156157 .child(
157158 h_flex()
158- .h(px(42.))
159- .pl(px(14.))
160- .pr(px(6.))
161- .gap(px(10.))
159+ .pl(px(12.))
160+ .pr(px(8.))
161+ .py(px(8.))
162+ .gap(px(12.))
162163 .child(
163164 div()
164- .text_size(px(13.5))
165- .font_weight(FontWeight::SEMIBOLD)
165+ .text_size(px(12.))
166+ .font_weight(FontWeight::MEDIUM)
166167 .text_color(p.ink)
167168 .child(format!("{count} changed file{}", if count == 1 { "" } else { "s" })),
168169 )
@@ -174,7 +175,7 @@ pub fn render_changes(
174175 .ghost()
175176 .mini()
176177 .icon(Lucide::ChevronsUpDown)
177- .tooltip(if any_open { "Collapse folders" } else { "Expand folders" })
178+ .tooltip(if any_open { "Collapse all folders" } else { "Expand all folders" })
178179 .on_click(toggle_all),
179180 )
180181 })
@@ -184,23 +185,23 @@ pub fn render_changes(
184185 .compact()
185186 .icon(Lucide::FileDiff)
186187 .label("Open diff")
188+ .tooltip("Open the full diff")
187189 .on_click(open_all),
188190 ),
189191 )
190- .child(metal::rule(p))
191- .child(v_flex().p(px(6.)).gap(px(1.)).children(rows.into_iter().enumerate().map(|(ix, row)| {
192+ .child(v_flex().p(px(8.)).pt(px(0.)).children(rows.into_iter().enumerate().map(|(ix, row)| {
192193 let base = h_flex()
193194 .id(ElementId::NamedInteger(format!("change-{end}").into(), ix as u64))
194- .h(px(30.))
195+ .py(px(6.))
195196 .pr(px(8.))
196197 .gap(px(8.))
197- .rounded(px(9.))
198+ .rounded(px(8.))
198199 .cursor_pointer()
199200 .hover(|style| style.bg(p.row_hover));
200201 match row {
201202 Row::Dir { path, name, depth, open, added, removed } => {
202203 let session = session.clone();
203- base.pl(px(8. + depth as f32 * 16.))
204+ base.pl(px(8. + depth as f32 * 14.))
204205 .on_click(move |_, _, cx| {
205206 session.update(cx, |session, cx| session.toggle_dir(end, path.clone(), cx))
206207 })
@@ -230,7 +231,7 @@ pub fn render_changes(
230231 Row::File { change, name, depth } => {
231232 let on_open = on_open.clone();
232233 let path = change.path.clone();
233- base.pl(px(28. + depth as f32 * 16.))
234+ base.pl(px(8. + depth as f32 * 14.))
234235 .on_click(move |_, window, cx| on_open(Some(path.clone()), window, cx))
235236 .child(file_icon(&name).size(px(14.)).text_color(p.ink3))
236237 .child(
+18−654src/ui/chat.rsView File
@@ -6,6 +6,7 @@
66 mod dock;
77 mod footer;
88 mod strip;
9+ mod timeline;
910
1011 use std::{
1112 path::PathBuf,
@@ -29,8 +30,7 @@ use crate::clock;
2930 use crate::git;
3031 use crate::models::{Catalog, ModelEntry, SlashCommand};
3132 use crate::session::{
32- ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session, SessionEvent,
33- SetupCancel, TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary,
33+ ChatItem, PermissionDecision, Session, SessionEvent, SetupCancel, tool_field,
3434 };
3535 use crate::worktree::StageStatus;
3636 use crate::settings::{self, FollowUp, WorkspaceMode};
@@ -177,6 +177,10 @@ pub struct ChatView {
177177 /// Where ↑/↓ are in the thread's sent prompts: the entry shown, and the
178178 /// text put in the composer for it.
179179 history: Option<(usize, String)>,
180+ /// Runs of tool calls unfolded in the timeline, by their first call.
181+ expanded_groups: std::collections::HashSet<usize>,
182+ /// Long user messages unfolded in the timeline.
183+ expanded_messages: std::collections::HashSet<usize>,
180184 /// Prompts put aside with Ctrl+S, oldest first.
181185 stash: Vec<Stashed>,
182186 stash_open: bool,
@@ -308,6 +312,8 @@ impl ChatView {
308312 files: None,
309313 files_task: Task::ready(()),
310314 history: None,
315+ expanded_groups: Default::default(),
316+ expanded_messages: Default::default(),
311317 stash: Vec::new(),
312318 stash_open: false,
313319 stash_selected: 0,
@@ -1373,8 +1379,9 @@ impl ChatView {
13731379 }))
13741380 .child(
13751381 MessageScroller::new("transcript", self.scroller.clone(), move |ix, window, cx| {
1376- render_item(&session, &scroller, &chat, ix, window, cx)
1382+ timeline::render_item(&session, &scroller, &chat, ix, window, cx)
13771383 })
1384+ .with_jump_button_label("Scroll to end")
13781385 .flex_1()
13791386 .w_full()
13801387 .with_row_style(StyleRefinement::default().pb_0())
@@ -1382,45 +1389,16 @@ impl ChatView {
13821389 )
13831390 }
13841391
1385- /// The line closing the conversation while the agent works, as T3 Code
1386- /// ends its timeline: "Working for 12s", or "Setting up worktree…"; and
1387- /// once it's done, whether the thread is snoozed or settled.
1392+ /// The line closing the conversation, as T3 Code ends its timeline: the
1393+ /// worktree being set up, or once the agent is done, whether the thread
1394+ /// is snoozed or settled. (While the agent works, "Working for 12s"
1395+ /// heads its turn in the timeline.)
13881396 fn render_status_line(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
13891397 let p = metal::palette(cx);
13901398 let session = self.session.read(cx);
13911399 if session.worktree_setup.is_some() {
13921400 return Some(self.render_worktree_setup(cx));
13931401 }
1394- if session.is_working() {
1395- if session.needs_attention() {
1396- return None;
1397- }
1398- let label = if session.preparing {
1399- "Setting up worktree…".to_string()
1400- } else {
1401- let elapsed = session.turn_started.map(|start| start.elapsed().as_secs()).unwrap_or(0);
1402- format!("Working for {}", format_elapsed(elapsed))
1403- };
1404- return Some(
1405- h_flex()
1406- .w_full()
1407- .max_w(px(CONTENT_WIDTH))
1408- .px(px(16.))
1409- .child(
1410- h_flex()
1411- .w_full()
1412- .h(px(24.))
1413- .px(px(4.))
1414- .gap(px(8.))
1415- .mb(px(8.))
1416- .text_size(px(14.))
1417- .text_color(p.ink3)
1418- .child(metal::led("working-led", Led::Live, true, p))
1419- .child(label),
1420- )
1421- .into_any_element(),
1422- );
1423- }
14241402 let snoozed = session.snoozed_until.filter(|_| session.is_snoozed());
14251403 let settled = session.settled;
14261404 if snoozed.is_none() && !settled {
@@ -1879,12 +1857,6 @@ pub fn display_path(path: &std::path::Path) -> String {
18791857 }
18801858 }
18811859
1882- /// A turn's end time: the clock today, and the day before that.
1883- fn clock_short(time: SystemTime) -> String {
1884- let age = time.elapsed().unwrap_or_default();
1885- if age < Duration::from_secs(12 * 3_600) { clock::clock(time) } else { clock::when(time) }
1886- }
1887-
18881860 fn format_tokens(tokens: u64) -> String {
18891861 let (value, unit) = match tokens {
18901862 0..1_000 => return tokens.to_string(),
@@ -1897,7 +1869,7 @@ fn format_tokens(tokens: u64) -> String {
18971869
18981870 /// Markdown in MetalUI's voice: code sits in pressed wells. Colors come from
18991871 /// the theme, which `metal` keeps in step with the theme.
1900- fn markdown_style(p: &Palette) -> TextViewStyle {
1872+ pub(super) fn markdown_style(p: &Palette) -> TextViewStyle {
19011873 let mut code_block = StyleRefinement::default()
19021874 .rounded(px(14.))
19031875 .bg(metal::vertical((p.well_top, p.well_bot)))
@@ -1917,7 +1889,7 @@ fn markdown_style(p: &Palette) -> TextViewStyle {
19171889 }
19181890
19191891 /// Markdown with a copy button on every code block.
1920- fn markdown(state: &Entity<gpui_kit::component::text::TextViewState>, p: &Palette) -> TextView {
1892+ pub(super) fn markdown(state: &Entity<gpui_kit::component::text::TextViewState>, p: &Palette) -> TextView {
19211893 TextView::new(state)
19221894 .style(markdown_style(p))
19231895 .selectable(true)
@@ -1939,605 +1911,7 @@ fn markdown(state: &Entity<gpui_kit::component::text::TextViewState>, p: &Palett
19391911 }
19401912
19411913 /// The row a finished turn folds into: "Worked for 11s ›".
1942- fn render_turn_summary(
1943- session: &Entity<Session>,
1944- scroller: &Entity<MessageScrollerState>,
1945- turn: &FinishedTurn,
1946- expanded: bool,
1947- p: &Palette,
1948- ) -> AnyElement {
1949- let end = turn.end;
1950- let toggle_session = session.clone();
1951- let scroller = scroller.clone();
1952- v_flex()
1953- .w_full()
1954- .gap(px(8.))
1955- .child(
1956- h_flex()
1957- .id(("turn-summary", end))
1958- .gap(px(6.))
1959- .text_size(px(13.5))
1960- .text_color(p.ink3)
1961- .child(format!("Worked for {}", format_duration(turn.duration_ms)))
1962- .child(div().text_size(px(12.)).child(format!("· {}", clock_short(turn.at))))
1963- .when(turn.has_work, |this| {
1964- this.cursor_pointer()
1965- .hover(|style| style.text_color(p.ink))
1966- .child(
1967- Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight })
1968- .size(px(13.)),
1969- )
1970- .on_click(move |_, _, cx| {
1971- toggle_session.update(cx, |session, cx| session.toggle_turn(end, cx));
1972- // Folding changes the height of every row in the turn.
1973- scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
1974- })
1975- })
1976- .child(div().flex_1())
1977- .when_some(
1978- turn.cost.filter(|cost| *cost > 0. && settings::get(|settings| settings.show_costs)),
1979- |this, cost| this.child(metal::engraved(format!("${cost:.4}"), p)),
1980- ),
1981- )
1982- .child(metal::rule(p).w_full())
1983- .into_any_element()
1984- }
1985-
1986- /// A question from an agent extension, answered with one click.
1987- fn render_question(question: &Question, p: &Palette) -> AnyElement {
1988- v_flex()
1989- .w_full()
1990- .gap(px(10.))
1991- .px(px(14.))
1992- .py(px(12.))
1993- .rounded(px(14.))
1994- .bg(metal::vertical(p.row_on))
1995- .shadow({
1996- let mut shadow = p.raise_sm.clone();
1997- if question.answer.is_none() {
1998- shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.)));
1999- }
2000- shadow
2001- })
2002- .child(
2003- h_flex()
2004- .gap(px(8.))
2005- .child(metal::engraved("Question", p).text_color(p.amber_ink))
2006- .child(
2007- div()
2008- .text_size(px(13.5))
2009- .font_weight(FontWeight::SEMIBOLD)
2010- .text_color(p.ink)
2011- .child(question.title.clone()),
2012- ),
2013- )
2014- .when_some(question.message.clone(), |this, message| {
2015- this.child(div().text_size(px(13.)).text_color(p.ink2).child(message))
2016- })
2017- .child(match &question.answer {
2018- Some(answer) => metal::engraved(format!("Answered: {answer}"), p),
2019- // Answered in the drawer above the composer.
2020- None => metal::engraved("Waiting for your answer", p).text_color(p.amber_ink),
2021- })
2022- .into_any_element()
2023- }
2024-
2025- /// What an AskUserQuestion call asked and what the user answered, as it
2026- /// stays in the transcript.
2027- fn render_ask_record(ix: usize, tool: &ToolCall, ask: &UserQuestions, p: &Palette) -> AnyElement {
2028- let state = tool.permission.as_ref().map(|prompt| prompt.state);
2029- let pending = state == Some(PermissionState::Pending);
2030- let status = match state {
2031- Some(PermissionState::Pending) => "Waiting for your answer",
2032- Some(PermissionState::Denied) => "Skipped",
2033- Some(PermissionState::Cancelled) => "Cancelled",
2034- _ => "Answered",
2035- };
2036- let mut shadow = p.raise_sm.clone();
2037- if pending {
2038- shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.)));
2039- }
2040- v_flex()
2041- .w_full()
2042- .gap(px(10.))
2043- .px(px(14.))
2044- .py(px(12.))
2045- .rounded(px(14.))
2046- .bg(metal::vertical(p.row_on))
2047- .shadow(shadow)
2048- .child(
2049- h_flex()
2050- .gap(px(8.))
2051- .child(Icon::new(Lucide::MessageCircleQuestionMark).size(px(14.)).text_color(if pending {
2052- p.amber_ink
2053- } else {
2054- p.ink3
2055- }))
2056- .child(metal::engraved(if ask.questions.len() > 1 { "Questions" } else { "Question" }, p).when(
2057- pending,
2058- |this| this.text_color(p.amber_ink),
2059- ))
2060- .child(div().flex_1())
2061- .child(metal::engraved(status, p).when(state == Some(PermissionState::Allowed), |this| {
2062- this.text_color(p.green_ink)
2063- })),
2064- )
2065- .children(ask.questions.iter().zip(&ask.answers).enumerate().map(|(q, (question, answer))| {
2066- // Headers sit in a fixed column so questions and answers line up.
2067- h_flex()
2068- .id(ElementId::NamedInteger(format!("ask-record-{ix}").into(), q as u64))
2069- .items_start()
2070- .gap(px(8.))
2071- .child(div().w(px(76.)).flex_none().pt(px(2.)).when(!question.header.is_empty(), |this| {
2072- this.child(metal::tag(question.header.clone(), p))
2073- }))
2074- .child(
2075- v_flex()
2076- .flex_1()
2077- .min_w_0()
2078- .gap(px(4.))
2079- .child(
2080- div()
2081- .text_size(px(13.5))
2082- .font_weight(FontWeight::SEMIBOLD)
2083- .text_color(p.ink)
2084- .child(question.question.clone()),
2085- )
2086- .when_some(answer.clone(), |this, answer| {
2087- this.child(
2088- h_flex()
2089- .items_start()
2090- .gap(px(6.))
2091- .text_size(px(13.))
2092- .text_color(p.ink2)
2093- .child(
2094- Icon::new(Lucide::CornerDownRight)
2095- .size(px(12.))
2096- .mt(px(3.))
2097- .text_color(p.green_ink),
2098- )
2099- .child(div().flex_1().min_w_0().child(answer)),
2100- )
2101- }),
2102- )
2103- }))
2104- .into_any_element()
2105- }
2106-
2107- /// Where a tool's background task stands, and the buttons to move a long
2108- /// command to the background or stop it there.
2109- fn render_task_controls(session: &Entity<Session>, ix: usize, tool: &ToolCall, p: &Palette) -> Option<AnyElement> {
2110- let session = session.clone();
2111- match (tool.background, &tool.task_id) {
2112- (Some(TaskStatus::Running), Some(task_id)) => {
2113- let task_id = task_id.clone();
2114- Some(
2115- h_flex()
2116- .gap(px(6.))
2117- .child(metal::tag("Background", p))
2118- .child(
2119- MetalButton::new(("tool-stop-task", ix))
2120- .ghost()
2121- .mini()
2122- .icon(IconName::Square)
2123- .tooltip("Stop this task")
2124- .on_click(move |_, _, cx| {
2125- cx.stop_propagation();
2126- session.update(cx, |session, cx| session.stop_task(&task_id, cx));
2127- }),
2128- )
2129- .into_any_element(),
2130- )
2131- }
2132- (Some(status), _) => Some(
2133- metal::engraved(
2134- match status {
2135- TaskStatus::Running | TaskStatus::Completed => "Ran in background",
2136- TaskStatus::Failed => "Failed in background",
2137- TaskStatus::Stopped => "Stopped",
2138- },
2139- p,
2140- )
2141- .into_any_element(),
2142- ),
2143- // A long foreground command can be sent to the background.
2144- (None, Some(_)) if tool.state == ToolState::Running => Some(
2145- MetalButton::new(("tool-background", ix))
2146- .ghost()
2147- .mini()
2148- .label("Background")
2149- .tooltip("Keep it running in the background; Claude carries on and hears when it finishes")
2150- .on_click(move |_, _, cx| {
2151- cx.stop_propagation();
2152- session.update(cx, |session, cx| session.background_tool(ix, cx));
2153- })
2154- .into_any_element(),
2155- ),
2156- _ => None,
2157- }
2158- }
2159-
2160- /// A background task finished. Claude is woken up by it, so it heads the
2161- /// turn that follows.
2162- fn render_task_event(ix: usize, summary: &SharedString, status: TaskStatus, p: &Palette) -> AnyElement {
2163- let (icon, color) = match status {
2164- TaskStatus::Completed => (IconName::CircleCheck, p.green_ink),
2165- TaskStatus::Running => (IconName::Info, p.blue_ink),
2166- TaskStatus::Failed => (IconName::CircleX, p.red),
2167- TaskStatus::Stopped => (IconName::Square, p.ink3),
2168- };
2169- h_flex()
2170- .id(("task-event", ix))
2171- .items_start()
2172- .gap(px(10.))
2173- .px(px(12.))
2174- .py(px(8.))
2175- .rounded(px(12.))
2176- .region(p)
2177- .child(Icon::new(icon).size(px(13.)).mt(px(2.)).text_color(color))
2178- .child(div().flex_1().min_w_0().text_size(px(13.)).text_color(p.ink2).child(summary.clone()))
2179- .child(div().pt(px(2.)).child(metal::engraved(
2180- match status {
2181- TaskStatus::Stopped => "Background task stopped",
2182- TaskStatus::Failed => "Background task failed",
2183- TaskStatus::Running => "Background task event",
2184- TaskStatus::Completed => "Background task",
2185- },
2186- p,
2187- )))
2188- .into_any_element()
2189- }
2190-
2191- /// Renders one transcript row. Runs inside the virtual list, so it only gets
2192- /// `App` and talks to the session entity directly.
2193- fn render_item(
2194- session: &Entity<Session>,
2195- scroller: &Entity<MessageScrollerState>,
2196- chat: &WeakEntity<ChatView>,
2197- ix: usize,
2198- _: &mut Window,
2199- cx: &mut App,
2200- ) -> AnyElement {
2201- let p = metal::palette(cx);
2202- let state = session.read(cx);
2203- let Some(item) = state.items.get(ix) else {
2204- return div().into_any_element();
2205- };
2206-
2207- // A finished turn shows only its answer (and any errors) under one
2208- // "Worked for" row, unless the user unfolds it. Its end shows what it
2209- // changed on disk.
2210- let turn = state.finished_turn(ix);
2211- // With folding off, turns start unfolded and a click folds them.
2212- let fold = settings::get(|settings| settings.fold_turns);
2213- let expanded = turn.as_ref().is_some_and(|turn| state.turn_expanded(turn.end) == fold);
2214- let summary = turn
2215- .as_ref()
2216- .filter(|turn| turn.first == ix)
2217- .map(|turn| render_turn_summary(session, scroller, turn, expanded, p));
2218- let visible = match (&turn, item) {
2219- (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(),
2220- (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true,
2221- (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true,
2222- (Some(turn), _) => expanded || Some(ix) == turn.answer,
2223- (None, _) => true,
2224- };
2225- if !visible && summary.is_none() {
2226- return div().into_any_element();
2227- }
2228-
2229- let content: Option<AnyElement> = visible.then(|| match item {
2230- ChatItem::User { text, attachments, .. } => h_flex()
2231- .w_full()
2232- .justify_end()
2233- .child(
2234- v_flex()
2235- .max_w(relative(0.8))
2236- .gap(px(8.))
2237- .px(px(16.))
2238- .py(px(10.))
2239- .rounded(px(18.))
2240- .raised(p)
2241- .text_size(px(14.))
2242- .line_height(px(21.))
2243- .text_color(p.ink)
2244- .when(!text.is_empty(), |this| this.child(text.clone()))
2245- .when(!attachments.is_empty(), |this| {
2246- this.child(h_flex().flex_wrap().gap(px(6.)).children(attachments.iter().map(|name| {
2247- h_flex()
2248- .h(px(22.))
2249- .px(px(8.))
2250- .gap(px(6.))
2251- .rounded(px(8.))
2252- .well(p)
2253- .text_size(px(12.))
2254- .text_color(p.ink2)
2255- .child(Icon::new(Lucide::Paperclip).size(px(12.)).text_color(p.ink3))
2256- .child(name.clone())
2257- })))
2258- }),
2259- )
2260- .into_any_element(),
2261- ChatItem::Assistant(answer) => div()
2262- .w_full()
2263- .px(px(4.))
2264- .text_size(px(14.))
2265- .line_height(px(22.))
2266- .text_color(p.ink)
2267- .child(markdown(&answer.view, p))
2268- .into_any_element(),
2269- ChatItem::Tool(tool) => render_tool(session, ix, tool, &state.cwd, cx),
2270- ChatItem::Question(question) => render_question(question, p),
2271- ChatItem::TaskEvent { summary, status } => render_task_event(ix, summary, *status, p),
2272- ChatItem::Notice(NoticeKind::Info, text) => h_flex()
2273- .items_start()
2274- .gap(px(8.))
2275- .px(px(6.))
2276- .text_size(px(12.5))
2277- .text_color(p.ink3)
2278- .child(Icon::new(IconName::Info).size(px(13.)).mt(px(2.)))
2279- .child(div().flex_1().min_w_0().child(text.clone()))
2280- .into_any_element(),
2281- ChatItem::Notice(NoticeKind::Error, text) => h_flex()
2282- .items_start()
2283- .gap(px(10.))
2284- .px(px(14.))
2285- .py(px(11.))
2286- .rounded(px(14.))
2287- .region(p)
2288- .bg(p.red.opacity(0.07))
2289- .text_size(px(13.))
2290- .text_color(p.ink)
2291- .child(div().pt(px(5.)).child(metal::led(("notice-led", ix), Led::Failed, false, p)))
2292- .child(div().flex_1().min_w_0().child(text.clone()))
2293- .into_any_element(),
2294- ChatItem::TurnEnd { .. } => match state.turn_changes(ix) {
2295- Some(changes) => {
2296- let chat = chat.clone();
2297- changes::render_changes(
2298- session,
2299- ix,
2300- changes,
2301- move |path, window, cx| {
2302- let _ = chat.update(cx, |this, cx| this.open_diff(ix, path, window, cx));
2303- },
2304- cx,
2305- )
2306- }
2307- None => div().into_any_element(),
2308- },
2309- });
2310-
2311- // Tool cards sit close together; a new user message starts a new turn.
2312- let (top, bottom) = match item {
2313- _ if !visible => (px(10.), px(6.)),
2314- ChatItem::User { .. } if ix > 0 => (px(28.), px(10.)),
2315- ChatItem::User { .. } => (px(20.), px(10.)),
2316- ChatItem::Tool(_) => (px(4.), px(4.)),
2317- ChatItem::TurnEnd { .. } => (px(10.), px(6.)),
2318- _ => (px(6.), px(6.)),
2319- };
2320- h_flex()
2321- .w_full()
2322- .justify_center()
2323- .px(px(28.))
2324- .pt(top)
2325- .pb(bottom)
2326- .child(
2327- v_flex()
2328- .w_full()
2329- .max_w(px(CONTENT_WIDTH))
2330- .gap(px(12.))
2331- .map(|this| match item {
2332- ChatItem::TaskEvent { .. } => this.children(content).children(summary),
2333- _ => this.children(summary).children(content),
2334- }),
2335- )
2336- .into_any_element()
2337- }
2338-
2339- fn render_tool(
2340- session: &Entity<Session>,
2341- ix: usize,
2342- tool: &ToolCall,
2343- cwd: &std::path::Path,
2344- cx: &App,
2345- ) -> AnyElement {
2346- let p = metal::palette(cx);
2347- if let Some(ask) = &tool.ask {
2348- return render_ask_record(ix, tool, ask, p);
2349- }
2350- let summary = tool_summary(&tool.name, &tool.input, cwd);
2351- let pending = tool
2352- .permission
2353- .as_ref()
2354- .is_some_and(|prompt| prompt.state == PermissionState::Pending);
2355- let expanded = tool.expanded;
2356-
2357- let status: AnyElement = match (tool.state, pending) {
2358- (_, true) => metal::led(("tool-led", ix), Led::Waiting, true, p),
2359- // A backgrounded tool returns at once; what counts is its task.
2360- _ if tool.background == Some(TaskStatus::Running) => metal::led(("tool-led", ix), Led::Live, true, p),
2361- _ if tool.background == Some(TaskStatus::Failed) => {
2362- Icon::new(IconName::CircleX).size(px(13.)).text_color(p.red).into_any_element()
2363- }
2364- _ if tool.background == Some(TaskStatus::Stopped) => {
2365- Icon::new(IconName::Square).size(px(11.)).text_color(p.ink3).into_any_element()
2366- }
2367- (ToolState::Running, _) => metal::led(("tool-led", ix), Led::Live, true, p),
2368- (ToolState::Done, _) => Icon::new(IconName::Check)
2369- .size(px(13.))
2370- .text_color(p.green_ink)
2371- .into_any_element(),
2372- (ToolState::Error, _) => Icon::new(IconName::CircleX)
2373- .size(px(13.))
2374- .text_color(p.red)
2375- .into_any_element(),
2376- };
2377- let decision = tool.permission.as_ref().and_then(|prompt| match prompt.state {
2378- PermissionState::Allowed => Some(("Approved", p.green_ink)),
2379- PermissionState::Denied => Some(("Denied", p.red)),
2380- PermissionState::Cancelled => Some(("Cancelled", p.ink3)),
2381- PermissionState::Pending => Some(("Awaiting approval", p.amber_ink)),
2382- });
2383-
2384- let toggle_session = session.clone();
2385- let header = h_flex()
2386- .id(("tool-header", ix))
2387- .h(px(36.))
2388- .px(px(12.))
2389- .gap(px(10.))
2390- .cursor_pointer()
2391- .rounded(px(14.))
2392- .hover(|this| this.bg(p.row_hover))
2393- .on_click(move |_, _, cx| toggle_session.update(cx, |session, cx| session.toggle_tool(ix, cx)))
2394- .child(div().w(px(14.)).flex().justify_center().child(status))
2395- .child(Icon::new(tool_icon(&tool.name)).size(px(14.)).text_color(p.ink3))
2396- .child(
2397- div()
2398- .flex_none()
2399- .text_size(px(13.))
2400- .font_weight(FontWeight::SEMIBOLD)
2401- .text_color(p.ink)
2402- .child(tool.name.clone()),
2403- )
2404- .child(
2405- div()
2406- .flex_1()
2407- .min_w_0()
2408- .truncate()
2409- .font_family(metal::MONO)
2410- .text_size(px(11.5))
2411- .text_color(p.ink3)
2412- .child(summary),
2413- )
2414- .when(tool.subagent_tool_count > 0, |this| {
2415- this.child(metal::engraved(format!("{} tool uses", tool.subagent_tool_count), p))
2416- })
2417- .when_some(decision, |this, (label, color)| {
2418- this.child(metal::engraved(label, p).text_color(color))
2419- })
2420- .children(render_task_controls(session, ix, tool, p))
2421- .child(
2422- Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight })
2423- .size(px(12.))
2424- .text_color(p.ink3),
2425- );
2426-
2427- let mut shadow = p.raise_sm.clone();
2428- if pending {
2429- shadow.push(BoxShadow::new(px(0.), px(0.), p.amber.opacity(0.7)).spread_radius(px(1.)));
2430- }
2431-
2432- v_flex()
2433- .w_full()
2434- .rounded(px(14.))
2435- .bg(metal::vertical(p.row_on))
2436- .shadow(shadow)
2437- .child(header)
2438- .when(expanded, |this| this.child(render_tool_details(ix, tool, cx)))
2439- .into_any_element()
2440- }
2441-
2442- fn render_tool_details(ix: usize, tool: &ToolCall, cx: &App) -> impl IntoElement {
2443- let p = metal::palette(cx);
2444- // Claude and Pi name the same arguments differently.
2445- let field = |keys: &[&str]| tool_field(&tool.input, keys);
2446- let old_new = (field(&["old_string", "oldText"]), field(&["new_string", "newText"]));
2447-
2448- let mut sections: Vec<(&'static str, String)> = Vec::new();
2449- let mut plan: Option<String> = None;
2450- match tool.name.to_ascii_lowercase().as_str() {
2451- "bash" => {
2452- if let Some(description) = field(&["description"]) {
2453- sections.push(("Description", description));
2454- }
2455- sections.push(("Command", field(&["command"]).unwrap_or_default()));
2456- }
2457- "edit" if old_new.0.is_some() || old_new.1.is_some() => {
2458- sections.push(("File", field(&["file_path", "path"]).unwrap_or_default()));
2459- sections.push(("Replace", old_new.0.unwrap_or_default()));
2460- sections.push(("With", old_new.1.unwrap_or_default()));
2461- }
2462- "write" => {
2463- sections.push(("File", field(&["file_path", "path"]).unwrap_or_default()));
2464- sections.push(("Content", field(&["content"]).unwrap_or_default()));
2465- }
2466- "exitplanmode" => plan = field(&["plan"]),
2467- _ => {
2468- let input = if tool.input.is_null() && !tool.partial_input.is_empty() {
2469- tool.partial_input.clone()
2470- } else {
2471- serde_json::to_string_pretty(&tool.input).unwrap_or_default()
2472- };
2473- sections.push(("Input", input));
2474- }
2475- }
2476- if let Some(result) = &tool.result {
2477- sections.push((
2478- if tool.state == ToolState::Error { "Error" } else { "Output" },
2479- truncate(result, MAX_TOOL_OUTPUT),
2480- ));
2481- }
2482-
2483- v_flex()
2484- .gap(px(10.))
2485- .px(px(12.))
2486- .pb(px(12.))
2487- .pt(px(2.))
2488- .when_some(plan, |this, plan| {
2489- this.child(
2490- div()
2491- .px(px(16.))
2492- .py(px(12.))
2493- .rounded(px(12.))
2494- .well(p)
2495- .text_size(px(14.))
2496- .text_color(p.ink)
2497- .child(TextView::markdown(("plan", ix), plan).style(markdown_style(p)).selectable(true)),
2498- )
2499- })
2500- .children(sections.into_iter().enumerate().map(|(section_ix, (label, body))| {
2501- v_flex()
2502- .gap(px(6.))
2503- .child(div().px(px(2.)).child(metal::engraved(label, p)))
2504- .child(
2505- div()
2506- .id(ElementId::NamedInteger(
2507- format!("tool-{ix}-section").into(),
2508- section_ix as u64,
2509- ))
2510- .max_h(px(280.))
2511- .overflow_y_scroll()
2512- .px(px(12.))
2513- .py(px(9.))
2514- .rounded(px(12.))
2515- .well(p)
2516- .font_family(metal::MONO)
2517- .text_size(px(11.5))
2518- .line_height(px(17.))
2519- .text_color(p.ink2)
2520- .child(if body.is_empty() { "(empty)".to_string() } else { body }),
2521- )
2522- }))
2523- }
2524-
2525- fn tool_icon(name: &str) -> IconName {
2526- match name.to_ascii_lowercase().as_str() {
2527- "bash" | "bashoutput" | "killshell" => IconName::SquareTerminal,
2528- "read" => IconName::FileText,
2529- "write" | "edit" | "multiedit" | "notebookedit" => IconName::File,
2530- "glob" | "grep" | "find" | "ls" => IconName::Search,
2531- "webfetch" | "websearch" => IconName::Globe,
2532- "task" | "agent" => IconName::Bot,
2533- "todowrite" => IconName::CircleCheck,
2534- "exitplanmode" => IconName::Map,
2535- "monitor" => IconName::Eye,
2536- _ => IconName::Settings2,
2537- }
2538- }
2539-
2540- fn truncate(text: &str, max_chars: usize) -> String {
1914+ pub(super) fn truncate(text: &str, max_chars: usize) -> String {
25411915 if text.chars().count() <= max_chars {
25421916 return text.to_string();
25431917 }
@@ -2546,16 +1920,6 @@ fn truncate(text: &str, max_chars: usize) -> String {
25461920 truncated
25471921 }
25481922
2549- fn format_duration(ms: u64) -> String {
2550- let secs = ms / 1000;
2551- if secs < 1 {
2552- format!("{ms}ms")
2553- } else if secs < 60 {
2554- format!("{:.1}s", ms as f64 / 1000.)
2555- } else {
2556- format!("{}m {}s", secs / 60, secs % 60)
2557- }
2558- }
25591923
25601924 /// The files and folders of `cwd` for the `@` menu: what git tracks (or
25611925 /// would, untracked files included), else a shallow walk. Folders end in `/`.
@@ -2601,7 +1965,7 @@ fn list_files(cwd: &std::path::Path) -> Vec<String> {
26011965 }
26021966
26031967 /// `12s`, `1m 5s`, `1h 2m`, as T3 Code's working timer reads.
2604- fn format_elapsed(seconds: u64) -> String {
1968+ pub(super) fn format_elapsed(seconds: u64) -> String {
26051969 match seconds {
26061970 0..60 => format!("{seconds}s"),
26071971 60..3600 => format!("{}m {}s", seconds / 60, seconds % 60),
+1138−0src/ui/chat/timeline.rsaddedView File
@@ -0,0 +1,1138 @@
1+ //! The conversation, laid out like T3 Code's timeline: user messages as
2+ //! bubbles with their time and a copy button on hover, the agent's answers,
3+ //! its tool calls as compact work-log rows (consecutive ones grouped under
4+ //! one summary, "Ran 2 commands and read 3 files"), a finished turn folded
5+ //! under "Worked for 2m 14s", Claude's plans as cards, and notices as
6+ //! dividers.
7+
8+ use std::collections::HashSet;
9+ use std::path::Path;
10+ use std::time::SystemTime;
11+
12+ use gpui_kit::component::{
13+ Icon, IconName, h_flex,
14+ menu::{DropdownMenu as _, PopupMenuItem},
15+ message_scroller::MessageScrollerState,
16+ text::TextView,
17+ tooltip::Tooltip,
18+ v_flex,
19+ };
20+ use gpui_kit::prelude::FluentBuilder as _;
21+ use gpui_kit::*;
22+
23+ use super::{ChatView, MAX_TOOL_OUTPUT, markdown, markdown_style, truncate};
24+ use crate::clock;
25+ use crate::session::{
26+ ChatItem, FinishedTurn, NoticeKind, PermissionState, Question, Session, TaskStatus, ToolCall, ToolState,
27+ UserQuestions, tool_field,
28+ };
29+ use crate::settings;
30+ use crate::ui::changes;
31+ use crate::ui::composer::CONTENT_WIDTH;
32+ use crate::ui::icons::Lucide;
33+ use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
34+
35+ /// A user message longer than this folds behind "Show full message".
36+ const LONG_MESSAGE_CHARS: usize = 600;
37+ const LONG_MESSAGE_LINES: usize = 8;
38+ /// A plan longer than this folds behind "Expand plan".
39+ const LONG_PLAN_CHARS: usize = 900;
40+ const LONG_PLAN_LINES: usize = 20;
41+
42+ /// What a tool call does, as T3 Code sums up a group of them.
43+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
44+ enum Action {
45+ Read,
46+ Edit,
47+ Command,
48+ WebSearch,
49+ CodeSearch,
50+ Other,
51+ }
52+
53+ impl Action {
54+ /// The icon a row or group of this kind shows.
55+ fn icon(self) -> Icon {
56+ match self {
57+ Action::Read => Icon::new(IconName::Eye),
58+ Action::Edit => Icon::new(Lucide::SquarePen),
59+ Action::Command => Icon::new(Lucide::Terminal),
60+ Action::WebSearch => Icon::new(IconName::Globe),
61+ Action::CodeSearch => Icon::new(IconName::Search),
62+ Action::Other => Icon::new(Lucide::Wrench),
63+ }
64+ }
65+
66+ /// `Ran 2 commands`, `Read 1 file`.
67+ fn summary(self, count: usize) -> String {
68+ let s = |one: &str, many: &str| if count == 1 { one.to_string() } else { many.to_string() };
69+ match self {
70+ Action::Read => format!("Read {count} {}", s("file", "files")),
71+ Action::Edit => format!("Changed {count} {}", s("file", "files")),
72+ Action::Command => format!("Ran {count} {}", s("command", "commands")),
73+ Action::WebSearch => format!("Searched the web {count} {}", s("time", "times")),
74+ Action::CodeSearch => format!("Searched code {count} {}", s("time", "times")),
75+ Action::Other => format!("Used {count} {}", s("tool", "tools")),
76+ }
77+ }
78+
79+ /// Commands and edits lead a group's summary, odds and ends trail it.
80+ fn priority(self) -> u8 {
81+ match self {
82+ Action::Command | Action::Edit => 0,
83+ Action::Other => 2,
84+ _ => 1,
85+ }
86+ }
87+ }
88+
89+ /// How a tool call reads in the timeline.
90+ struct Presentation {
91+ icon: Icon,
92+ label: String,
93+ action: Action,
94+ }
95+
96+ /// A path relative to the thread's folder.
97+ fn relative_path(path: &str, cwd: &Path) -> String {
98+ Path::new(path).strip_prefix(cwd).map(|relative| relative.display().to_string()).unwrap_or_else(|_| path.to_string())
99+ }
100+
101+ fn present(tool: &ToolCall, cwd: &Path) -> Presentation {
102+ let field = |keys: &[&str]| tool_field(&tool.input, keys);
103+ let first_line = |text: String| text.lines().next().unwrap_or_default().chars().take(200).collect::<String>();
104+ let name = tool.name.as_str();
105+ let (action, label) = match name.to_ascii_lowercase().as_str() {
106+ "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())),
107+ "bashoutput" | "killshell" => (Action::Command, name.to_string()),
108+ "read" | "notebookread" => (
109+ Action::Read,
110+ field(&["file_path", "path", "notebook_path"]).map(|path| format!("Read {}", relative_path(&path, cwd))).unwrap_or_else(|| "Read file".into()),
111+ ),
112+ "write" | "edit" | "multiedit" | "notebookedit" => (
113+ Action::Edit,
114+ field(&["file_path", "path", "notebook_path"]).map(|path| relative_path(&path, cwd)).unwrap_or_else(|| "Changed a file".into()),
115+ ),
116+ "grep" | "glob" | "find" => (
117+ Action::CodeSearch,
118+ field(&["pattern", "query"]).map(|pattern| format!("Searched for {}", first_line(pattern))).unwrap_or_else(|| "Searched code".into()),
119+ ),
120+ "ls" => (Action::CodeSearch, field(&["path"]).map(|path| format!("Listed {}", relative_path(&path, cwd))).unwrap_or_else(|| "Listed files".into())),
121+ "websearch" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())),
122+ "webfetch" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())),
123+ "task" | "agent" => (Action::Other, field(&["description"]).unwrap_or_else(|| "Subagent".into())),
124+ "monitor" => (Action::Other, field(&["description", "command"]).map(first_line).unwrap_or_else(|| "Monitor".into())),
125+ _ if name.starts_with("mcp__") => {
126+ let mut parts = name.trim_start_matches("mcp__").splitn(2, "__");
127+ let server = parts.next().unwrap_or_default().to_string();
128+ let tool_name = parts.next().unwrap_or_default().replace('_', " ");
129+ (Action::Other, format!("{server}: {tool_name}"))
130+ }
131+ _ => {
132+ let summary = crate::session::tool_summary(name, &tool.input, cwd);
133+ (Action::Other, if summary.is_empty() { name.to_string() } else { format!("{name} {summary}") })
134+ }
135+ };
136+ let icon = match name.to_ascii_lowercase().as_str() {
137+ "task" | "agent" => Icon::new(IconName::Bot),
138+ "monitor" => Icon::new(IconName::Eye),
139+ _ if name.starts_with("mcp__") => Icon::new(Lucide::Hammer),
140+ _ => action.icon(),
141+ };
142+ Presentation { icon, label, action }
143+ }
144+
145+ /// The program a command runs, for "Running npm".
146+ fn program(tool: &ToolCall) -> Option<String> {
147+ let command = tool_field(&tool.input, &["command"])?;
148+ command
149+ .split_whitespace()
150+ .find(|word| !word.contains('=') && !matches!(*word, "sudo" | "env" | "time" | "cd"))
151+ .map(|word| word.rsplit('/').next().unwrap_or(word).to_string())
152+ }
153+
154+ /// T3 Code's summary of a group of tool calls: up to two kinds, then the
155+ /// rest. "Ran 2 commands and read 3 files", "Changed 1 file, ran 4
156+ /// commands, and performed 2 other actions".
157+ fn group_summary(actions: &[Action]) -> String {
158+ let mut counts: Vec<(Action, usize)> = Vec::new();
159+ for action in actions {
160+ match counts.iter_mut().find(|(known, _)| known == action) {
161+ Some((_, count)) => *count += 1,
162+ None => counts.push((*action, 1)),
163+ }
164+ }
165+ counts.sort_by_key(|(action, _)| action.priority());
166+ let mut parts: Vec<String> = counts.iter().take(2).map(|(action, count)| action.summary(*count)).collect();
167+ let rest: usize = counts.iter().skip(2).map(|(_, count)| count).sum();
168+ if rest > 0 {
169+ parts.push(format!("Performed {rest} other {}", if rest == 1 { "action" } else { "actions" }));
170+ }
171+ let parts: Vec<String> = parts
172+ .into_iter()
173+ .enumerate()
174+ .map(|(ix, part)| {
175+ if ix == 0 {
176+ return part;
177+ }
178+ let mut chars = part.chars();
179+ chars.next().map(|first| first.to_lowercase().collect::<String>() + chars.as_str()).unwrap_or_default()
180+ })
181+ .collect();
182+ match parts.len() {
183+ 0 => String::new(),
184+ 1 => parts[0].clone(),
185+ 2 => format!("{} and {}", parts[0], parts[1]),
186+ n => format!("{}, and {}", parts[..n - 1].join(", "), parts[n - 1]),
187+ }
188+ }
189+
190+ /// `4.2s`, `42s`, `2m 14s`, `1h 3m 20s`, as T3 Code writes durations.
191+ pub(super) fn format_duration(ms: u64) -> String {
192+ if ms < 1_000 {
193+ return format!("{}ms", ms.max(1));
194+ }
195+ if ms < 10_000 {
196+ return format!("{:.1}s", ms as f64 / 1_000.).replace(".0s", "s");
197+ }
198+ let secs = ms / 1_000;
199+ if secs < 60 {
200+ return format!("{secs}s");
201+ }
202+ let (hours, minutes, seconds) = (secs / 3_600, secs % 3_600 / 60, secs % 60);
203+ [(hours, "h"), (minutes, "m"), (seconds, "s")]
204+ .iter()
205+ .filter(|(value, _)| *value > 0)
206+ .map(|(value, unit)| format!("{value}{unit}"))
207+ .collect::<Vec<_>>()
208+ .join(" ")
209+ }
210+
211+ /// Whether an item shows as a work-log row.
212+ fn is_work(item: &ChatItem) -> bool {
213+ matches!(item, ChatItem::Tool(tool) if tool.ask.is_none() && !matches!(tool.name.as_str(), "ExitPlanMode" | "TodoWrite"))
214+ }
215+
216+ /// Items the timeline leaves out altogether (the task list lives above the
217+ /// composer), and which don't break a group of tool calls.
218+ fn is_hidden(item: &ChatItem) -> bool {
219+ matches!(item, ChatItem::Tool(tool) if tool.name == "TodoWrite")
220+ }
221+
222+ /// The run of consecutive tool calls `ix` belongs to: its work rows' indices.
223+ fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> {
224+ let mut start = ix;
225+ while start > 0 && (is_work(&items[start - 1]) || is_hidden(&items[start - 1])) {
226+ start -= 1;
227+ }
228+ let mut end = ix;
229+ while end + 1 < items.len() && (is_work(&items[end + 1]) || is_hidden(&items[end + 1])) {
230+ end += 1;
231+ }
232+ (start..=end).filter(|&member| is_work(&items[member])).collect()
233+ }
234+
235+ /// A work-log row: a 24px icon slot, the label, and whatever trails it.
236+ fn work_row(id: impl Into<ElementId>, icon: Icon, icon_color: Hsla, p: &Palette) -> Stateful<Div> {
237+ h_flex()
238+ .id(id)
239+ .w_full()
240+ .min_w_0()
241+ .min_h(px(24.))
242+ .px(px(2.))
243+ .gap(px(6.))
244+ .rounded(px(8.))
245+ .text_size(px(14.))
246+ .line_height(px(22.))
247+ .child(
248+ div()
249+ .flex()
250+ .flex_none()
251+ .items_center()
252+ .justify_center()
253+ .size(px(24.))
254+ .child(icon.size(px(16.)).text_color(icon_color)),
255+ )
256+ .text_color(p.ink2)
257+ }
258+
259+ /// The chevron a row that unfolds ends with.
260+ fn row_chevron(expanded: bool, visible: bool, p: &Palette) -> Div {
261+ div()
262+ .flex()
263+ .flex_none()
264+ .items_center()
265+ .justify_center()
266+ .size(px(16.))
267+ .when(visible, |this| {
268+ this.child(
269+ Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight })
270+ .size(px(12.))
271+ .text_color(p.ink3.opacity(0.7)),
272+ )
273+ })
274+ }
275+
276+ /// A time that shows while the pointer is over its row.
277+ fn hover_time(group: &'static str, time: SystemTime, p: &Palette) -> Stateful<Div> {
278+ let tooltip: SharedString = clock::long_form(time).into();
279+ div()
280+ .id(SharedString::from(format!("{group}-time")))
281+ .mr(px(4.))
282+ .flex_none()
283+ .text_size(px(12.))
284+ .text_color(p.ink3)
285+ .opacity(0.)
286+ .group_hover(group, |style| style.opacity(1.))
287+ .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
288+ .child(clock::day_aware(time))
289+ }
290+
291+ /// The copy button under a message.
292+ fn copy_button(id: impl Into<ElementId>, text: String) -> MetalButton {
293+ MetalButton::new(id).ghost().mini().size(px(24.)).icon(IconName::Copy).tooltip("Copy message").on_click(move |_, _, cx| {
294+ cx.write_to_clipboard(ClipboardItem::new_string(text.clone()));
295+ })
296+ }
297+
298+ /// The ChatView's fold state for groups and long messages.
299+ fn view_state(chat: &WeakEntity<ChatView>, cx: &App) -> (HashSet<usize>, HashSet<usize>) {
300+ chat.upgrade()
301+ .map(|chat| {
302+ let chat = chat.read(cx);
303+ (chat.expanded_groups.clone(), chat.expanded_messages.clone())
304+ })
305+ .unwrap_or_default()
306+ }
307+
308+ /// Renders one transcript row. Runs inside the virtual list, so it only gets
309+ /// `App` and talks to the session entity directly.
310+ pub(super) fn render_item(
311+ session: &Entity<Session>,
312+ scroller: &Entity<MessageScrollerState>,
313+ chat: &WeakEntity<ChatView>,
314+ ix: usize,
315+ _: &mut Window,
316+ cx: &mut App,
317+ ) -> AnyElement {
318+ let p = metal::palette(cx);
319+ let state = session.read(cx);
320+ let Some(item) = state.items.get(ix) else {
321+ return div().into_any_element();
322+ };
323+ if is_hidden(item) {
324+ return div().into_any_element();
325+ }
326+ let (expanded_groups, expanded_messages) = view_state(chat, cx);
327+
328+ // A finished turn shows only its answer (and any errors) under one
329+ // "Worked for" row, unless the user unfolds it. Its end shows what it
330+ // changed on disk.
331+ let turn = state.finished_turn(ix);
332+ // With folding off, turns start unfolded and a click folds them.
333+ let fold = settings::get(|settings| settings.fold_turns);
334+ let expanded = turn.as_ref().is_some_and(|turn| state.turn_expanded(turn.end) == fold);
335+ let summary = turn
336+ .as_ref()
337+ .filter(|turn| turn.first == ix)
338+ .map(|turn| render_turn_fold(session, scroller, turn, expanded, p));
339+ let visible = match (&turn, item) {
340+ (_, ChatItem::TurnEnd { .. }) => state.turn_changes(ix).is_some(),
341+ (_, ChatItem::Notice(NoticeKind::Error, _) | ChatItem::Question(_)) => true,
342+ (_, ChatItem::Tool(ToolCall { ask: Some(_), .. }) | ChatItem::TaskEvent { .. }) => true,
343+ (Some(turn), _) => expanded || Some(ix) == turn.answer,
344+ (None, _) => true,
345+ };
346+ // The live turn opens under its user message with "Working for 12s".
347+ let last_user = state.items.iter().rposition(|item| matches!(item, ChatItem::User { .. }));
348+ let working = (state.is_working() && Some(ix) == last_user && !state.preparing && !state.needs_attention())
349+ .then(|| render_working(state, p));
350+ if !visible && summary.is_none() {
351+ return div().into_any_element();
352+ }
353+
354+ let mut bottom = px(8.);
355+ let content: Option<AnyElement> = if !visible {
356+ None
357+ } else {
358+ Some(match item {
359+ ChatItem::User { text, attachments, at } => {
360+ bottom = px(16.);
361+ render_user(chat, ix, text, attachments, *at, expanded_messages.contains(&ix), p)
362+ }
363+ ChatItem::Assistant(answer) => {
364+ let meta = turn.as_ref().filter(|turn| turn.answer == Some(ix)).map(|turn| turn.at);
365+ if meta.is_some() {
366+ bottom = px(16.);
367+ }
368+ render_assistant(ix, &answer.text, &answer.view, meta, p)
369+ }
370+ ChatItem::Tool(tool) if tool.ask.is_some() => render_answers(ix, tool, tool.ask.as_ref().unwrap_or_else(|| unreachable!()), p),
371+ ChatItem::Tool(tool) if tool.name == "ExitPlanMode" => {
372+ bottom = px(16.);
373+ render_plan(session, ix, tool, &state.cwd, p)
374+ }
375+ ChatItem::Tool(_) => {
376+ bottom = px(0.);
377+ render_work(session, scroller, chat, ix, &state.items, &state.cwd, &expanded_groups, state.is_working(), p, cx)
378+ }
379+ ChatItem::Question(question) => render_extension_record(question, p),
380+ ChatItem::TaskEvent { summary, status } => render_task_event(ix, summary, *status, p),
381+ ChatItem::Notice(NoticeKind::Info, text) => render_divider(text, p),
382+ ChatItem::Notice(NoticeKind::Error, text) => render_error(text, p),
383+ ChatItem::TurnEnd { .. } => {
384+ bottom = px(16.);
385+ match state.turn_changes(ix) {
386+ Some(changes) => {
387+ let chat = chat.clone();
388+ changes::render_changes(
389+ session,
390+ ix,
391+ changes,
392+ move |path, window, cx| {
393+ let _ = chat.update(cx, |this, cx| this.open_diff(ix, path, window, cx));
394+ },
395+ cx,
396+ )
397+ }
398+ None => div().into_any_element(),
399+ }
400+ }
401+ })
402+ };
403+
404+ let top = match item {
405+ ChatItem::User { .. } if ix == 0 => px(16.),
406+ _ => px(0.),
407+ };
408+ h_flex()
409+ .w_full()
410+ .justify_center()
411+ .px(px(28.))
412+ .pt(top)
413+ .pb(if content.is_none() { px(6.) } else { bottom })
414+ .child(
415+ v_flex()
416+ .w_full()
417+ .max_w(px(CONTENT_WIDTH))
418+ .map(|this| match item {
419+ ChatItem::TaskEvent { .. } => this.children(content).children(summary.map(|summary| div().pt(px(8.)).child(summary))),
420+ _ => this.children(summary.map(|summary| div().pb(px(6.)).child(summary))).children(content),
421+ })
422+ .children(working),
423+ )
424+ .into_any_element()
425+ }
426+
427+ /// A user message: a bubble on the right, its time and a copy button
428+ /// under it on hover. A long one folds behind "Show full message".
429+ fn render_user(
430+ chat: &WeakEntity<ChatView>,
431+ ix: usize,
432+ text: &SharedString,
433+ attachments: &[SharedString],
434+ at: SystemTime,
435+ unfolded: bool,
436+ p: &Palette,
437+ ) -> AnyElement {
438+ let group: &'static str = "user-row";
439+ let long = text.chars().count() > LONG_MESSAGE_CHARS || text.lines().count() > LONG_MESSAGE_LINES;
440+ let shown: String = if long && !unfolded {
441+ let mut shown: String = text.lines().take(LONG_MESSAGE_LINES).collect::<Vec<_>>().join("\n");
442+ if shown.chars().count() > LONG_MESSAGE_CHARS {
443+ shown = shown.chars().take(LONG_MESSAGE_CHARS).collect();
444+ }
445+ shown.push('…');
446+ shown
447+ } else {
448+ text.to_string()
449+ };
450+ let toggle = chat.clone();
451+ v_flex()
452+ .w_full()
453+ .items_end()
454+ .gap(px(4.))
455+ .group(group)
456+ .child(
457+ v_flex()
458+ .max_w(relative(0.8))
459+ .p(px(12.))
460+ .rounded(px(18.))
461+ .raised(p)
462+ .text_size(px(14.))
463+ .line_height(px(22.))
464+ .text_color(p.ink)
465+ .when(!attachments.is_empty(), |this| {
466+ this.child(v_flex().mb(px(8.)).gap(px(4.)).children(attachments.iter().map(|name| {
467+ h_flex()
468+ .gap(px(8.))
469+ .py(px(4.))
470+ .text_size(px(14.))
471+ .child(Icon::new(IconName::File).size(px(16.)).text_color(p.ink3))
472+ .child(div().min_w_0().truncate().child(name.clone()))
473+ })))
474+ })
475+ .when(!text.is_empty(), |this| this.child(shown))
476+ .when(long, |this| {
477+ this.child(
478+ h_flex().mt(px(6.)).justify_end().child(
479+ MetalButton::new(("user-unfold", ix))
480+ .ghost()
481+ .mini()
482+ .label(if unfolded { "Show less" } else { "Show full message" })
483+ .on_click(move |_, _, cx| {
484+ let _ = toggle.update(cx, |this, cx| {
485+ if !this.expanded_messages.remove(&ix) {
486+ this.expanded_messages.insert(ix);
487+ }
488+ this.scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
489+ cx.notify();
490+ });
491+ }),
492+ ),
493+ )
494+ }),
495+ )
496+ .child(
497+ h_flex()
498+ .max_w(relative(0.8))
499+ .justify_end()
500+ .pr(px(4.))
501+ .gap(px(8.))
502+ .text_size(px(12.))
503+ .opacity(0.)
504+ .group_hover(group, |style| style.opacity(1.))
505+ .child(
506+ div()
507+ .id(("user-time", ix))
508+ .text_color(p.ink3)
509+ .tooltip({
510+ let tooltip: SharedString = clock::long_form(at).into();
511+ move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)
512+ })
513+ .child(clock::day_aware(at)),
514+ )
515+ .child(copy_button(("user-copy", ix), text.to_string())),
516+ )
517+ .into_any_element()
518+ }
519+
520+ /// The agent's message. The turn's answer shows a copy button and its time
521+ /// on hover.
522+ fn render_assistant(
523+ ix: usize,
524+ text: &str,
525+ view: &Entity<gpui_kit::component::text::TextViewState>,
526+ meta: Option<SystemTime>,
527+ p: &Palette,
528+ ) -> AnyElement {
529+ let group: &'static str = "assistant-row";
530+ v_flex()
531+ .w_full()
532+ .group(group)
533+ .child(
534+ div()
535+ .w_full()
536+ .px(px(4.))
537+ .py(px(2.))
538+ .text_size(px(14.))
539+ .line_height(px(22.75))
540+ .text_color(p.ink)
541+ .child(if text.trim().is_empty() {
542+ div().text_color(p.ink3).child("(empty response)").into_any_element()
543+ } else {
544+ markdown(view, p).into_any_element()
545+ }),
546+ )
547+ .when_some(meta, |this, at| {
548+ this.child(
549+ h_flex()
550+ .mt(px(6.))
551+ .px(px(4.))
552+ .gap(px(8.))
553+ .text_size(px(12.))
554+ .opacity(0.)
555+ .group_hover(group, |style| style.opacity(1.))
556+ .child(copy_button(("assistant-copy", ix), text.to_string()))
557+ .child(
558+ div()
559+ .id(("assistant-time", ix))
560+ .text_color(p.ink3)
561+ .tooltip({
562+ let tooltip: SharedString = clock::long_form(at).into();
563+ move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)
564+ })
565+ .child(clock::day_aware(at)),
566+ ),
567+ )
568+ })
569+ .into_any_element()
570+ }
571+
572+ /// "Worked for 2m 14s", the row a finished turn folds under.
573+ fn render_turn_fold(
574+ session: &Entity<Session>,
575+ scroller: &Entity<MessageScrollerState>,
576+ turn: &FinishedTurn,
577+ expanded: bool,
578+ p: &Palette,
579+ ) -> AnyElement {
580+ let end = turn.end;
581+ let toggle_session = session.clone();
582+ let scroller = scroller.clone();
583+ let group: &'static str = "turn-fold";
584+ let cost = turn.cost.filter(|cost| *cost > 0. && settings::get(|settings| settings.show_costs));
585+ h_flex()
586+ .id(("turn-fold", end))
587+ .group(group)
588+ .w_full()
589+ .gap(px(4.))
590+ .pt(px(4.))
591+ .pb(px(8.))
592+ .pr(px(2.))
593+ .border_b_1()
594+ .border_color(p.rule)
595+ .child(
596+ h_flex()
597+ .id(("turn-fold-toggle", end))
598+ .gap(px(4.))
599+ .px(px(4.))
600+ .rounded(px(8.))
601+ .text_size(px(14.))
602+ .line_height(px(22.75))
603+ .text_color(p.ink3)
604+ .child(format!("Worked for {}", format_duration(turn.duration_ms)))
605+ .when(turn.has_work, |this| {
606+ this.cursor_pointer()
607+ .hover(|style| style.text_color(p.ink))
608+ .child(
609+ Icon::new(if expanded { IconName::ChevronDown } else { IconName::ChevronRight }).size(px(14.)),
610+ )
611+ .on_click(move |_, _, cx| {
612+ toggle_session.update(cx, |session, cx| session.toggle_turn(end, cx));
613+ // Folding changes the height of every row in the turn.
614+ scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
615+ })
616+ }),
617+ )
618+ .child(div().flex_1())
619+ .when_some(cost, |this, cost| {
620+ this.child(
621+ div()
622+ .text_size(px(12.))
623+ .text_color(p.ink3)
624+ .opacity(0.)
625+ .group_hover(group, |style| style.opacity(1.))
626+ .child(format!("${cost:.4}")),
627+ )
628+ })
629+ .child(hover_time(group, turn.at, p))
630+ .into_any_element()
631+ }
632+
633+ /// "Working for 12s", heading the live turn.
634+ fn render_working(state: &Session, p: &Palette) -> AnyElement {
635+ let elapsed = state.turn_started.map(|start| start.elapsed().as_secs()).unwrap_or(0);
636+ let label = if state.turn_started.is_some() {
637+ format!("Working for {}", super::format_elapsed(elapsed))
638+ } else {
639+ "Working...".to_string()
640+ };
641+ h_flex()
642+ .w_full()
643+ .mt(px(12.))
644+ .min_h(px(24.))
645+ .px(px(4.))
646+ .mb(px(8.))
647+ .pb(px(8.))
648+ .gap(px(8.))
649+ .border_b_1()
650+ .border_color(p.rule)
651+ .text_size(px(14.))
652+ .text_color(p.ink3)
653+ .child(metal::led("working-led", Led::Live, true, p))
654+ .child(label)
655+ .into_any_element()
656+ }
657+
658+ /// A tool call as a work-log row, or (heading a run of them) the run's
659+ /// summary, which unfolds into every call.
660+ #[allow(clippy::too_many_arguments)]
661+ fn render_work(
662+ session: &Entity<Session>,
663+ scroller: &Entity<MessageScrollerState>,
664+ chat: &WeakEntity<ChatView>,
665+ ix: usize,
666+ items: &[ChatItem],
667+ cwd: &Path,
668+ expanded_groups: &HashSet<usize>,
669+ working: bool,
670+ p: &Palette,
671+ cx: &App,
672+ ) -> AnyElement {
673+ let members = group_of(items, ix);
674+ let Some(ChatItem::Tool(tool)) = items.get(ix) else { return div().into_any_element() };
675+ if members.len() < 2 {
676+ return v_flex().pb(px(8.)).child(render_entry(session, ix, tool, cwd, p, cx)).into_any_element();
677+ }
678+ let start = members[0];
679+ let open = expanded_groups.contains(&start);
680+ let is_last = members.last() == Some(&ix);
681+ let entry = open.then(|| render_entry(session, ix, tool, cwd, p, cx));
682+ if ix != start {
683+ return v_flex().when(is_last && open, |this| this.pb(px(8.))).children(entry).into_any_element();
684+ }
685+ // The live turn's trailing run shows its latest call; a settled one
686+ // sums them up.
687+ let live = working
688+ && items[members.last().copied().unwrap_or(ix) + 1..].iter().all(|item| is_hidden(item) || matches!(item, ChatItem::TaskEvent { .. }));
689+ let tools: Vec<&ToolCall> = members
690+ .iter()
691+ .filter_map(|&member| match &items[member] {
692+ ChatItem::Tool(tool) => Some(tool),
693+ _ => None,
694+ })
695+ .collect();
696+ let presentations: Vec<Presentation> = tools.iter().map(|tool| present(tool, cwd)).collect();
697+ let failed = tools.iter().any(|tool| tool.state == ToolState::Error);
698+ let (icon, label, running) = if live {
699+ let latest = tools.iter().rposition(|tool| tool.state == ToolState::Running).unwrap_or(tools.len() - 1);
700+ let tool = tools[latest];
701+ let running = tool.state == ToolState::Running;
702+ let label = match (tool.name.as_str(), running, program(tool)) {
703+ ("Bash", true, Some(program)) => format!("Running {program}"),
704+ ("Bash", false, Some(program)) if tool.state == ToolState::Error => format!("Failed {program}"),
705+ ("Bash", false, Some(program)) => format!("Ran {program}"),
706+ _ => presentations[latest].label.clone(),
707+ };
708+ (present(tool, cwd).icon, label, running)
709+ } else {
710+ let actions: Vec<Action> = presentations.iter().map(|presentation| presentation.action).collect();
711+ let dominant = actions
712+ .iter()
713+ .min_by_key(|action| (action.priority(), std::cmp::Reverse(actions.iter().filter(|other| other == action).count())))
714+ .copied()
715+ .unwrap_or(Action::Other);
716+ (dominant.icon(), group_summary(&actions), false)
717+ };
718+ let chat = chat.clone();
719+ let scroller = scroller.clone();
720+ v_flex()
721+ .when(!open, |this| this.pb(px(8.)))
722+ .child(
723+ work_row(("work-group", start), icon, if failed && !running { p.red.opacity(0.4) } else { p.ink3 }, p)
724+ .cursor_pointer()
725+ .hover(|style| style.bg(p.row_hover))
726+ .on_click(move |_, _, cx| {
727+ let _ = chat.update(cx, |this, cx| {
728+ if !this.expanded_groups.remove(&start) {
729+ this.expanded_groups.insert(start);
730+ }
731+ cx.notify();
732+ });
733+ scroller.update(cx, |scroller, cx| scroller.remeasure(cx));
734+ })
735+ .child(
736+ div()
737+ .flex_1()
738+ .min_w_0()
739+ .truncate()
740+ .when(running, |this| this.text_color(p.ink))
741+ .child(label),
742+ )
743+ .when(running, |this| this.child(metal::led(("work-group-led", start), Led::Live, true, p))),
744+ )
745+ .children(entry)
746+ .into_any_element()
747+ }
748+
749+ /// One tool call's row, unfolding into its input and output.
750+ fn render_entry(session: &Entity<Session>, ix: usize, tool: &ToolCall, cwd: &Path, p: &Palette, cx: &App) -> AnyElement {
751+ let presentation = present(tool, cwd);
752+ let pending = tool.permission.as_ref().is_some_and(|prompt| prompt.state == PermissionState::Pending);
753+ let running = tool.state == ToolState::Running && !pending || tool.background == Some(TaskStatus::Running);
754+ let failed = tool.state == ToolState::Error || tool.background == Some(TaskStatus::Failed);
755+ let decision = tool.permission.as_ref().and_then(|prompt| match prompt.state {
756+ PermissionState::Pending => Some(("Awaiting approval", p.amber_ink)),
757+ PermissionState::Denied => Some(("Declined", p.red)),
758+ PermissionState::Cancelled => Some(("Cancelled", p.ink3)),
759+ PermissionState::Allowed => None,
760+ });
761+ let toggle_session = session.clone();
762+ let expanded = tool.expanded;
763+ v_flex()
764+ .w_full()
765+ .child(
766+ work_row(("work-entry", ix), presentation.icon, if failed { p.red.opacity(0.4) } else { p.ink3 }, p)
767+ .group("work-entry")
768+ .cursor_pointer()
769+ .hover(|style| style.bg(p.row_hover))
770+ .on_click(move |_, _, cx| toggle_session.update(cx, |session, cx| session.toggle_tool(ix, cx)))
771+ .child(
772+ div()
773+ .flex_1()
774+ .min_w_0()
775+ .truncate()
776+ .when(running, |this| this.text_color(p.ink))
777+ .child(presentation.label),
778+ )
779+ .when(tool.subagent_tool_count > 0, |this| {
780+ this.child(
781+ div()
782+ .flex_none()
783+ .text_size(px(12.))
784+ .text_color(p.ink3)
785+ .child(format!("{} tool uses", tool.subagent_tool_count)),
786+ )
787+ })
788+ .when_some(decision, |this, (label, color)| {
789+ this.child(div().flex_none().text_size(px(12.)).text_color(color).child(label))
790+ })
791+ .children(render_task_controls(session, ix, tool, p))
792+ .when(running, |this| this.child(metal::led(("work-led", ix), Led::Live, true, p)))
793+ .child(row_chevron(expanded, true, p)),
794+ )
795+ .when(expanded, |this| this.child(div().pl(px(28.)).pb(px(4.)).child(render_tool_details(ix, tool, cx))))
796+ .into_any_element()
797+ }
798+
799+ /// Claude's plan as a card: "Plan" and its title, a menu to copy, download
800+ /// or save it, and the plan itself (a long one folds behind "Expand plan").
801+ fn render_plan(session: &Entity<Session>, ix: usize, tool: &ToolCall, cwd: &Path, p: &Palette) -> AnyElement {
802+ let plan = tool_field(&tool.input, &["plan"]).unwrap_or_default();
803+ let title = super::dock::plan_title(tool);
804+ let long = plan.chars().count() > LONG_PLAN_CHARS || plan.lines().count() > LONG_PLAN_LINES;
805+ let unfolded = tool.expanded;
806+ let shown = if long && !unfolded { plan.lines().take(10).collect::<Vec<_>>().join("\n") } else { plan.clone() };
807+ let (copy, download, save) = (plan.clone(), plan.clone(), plan.clone());
808+ let file_name = format!("{}.md", crate::worktree::sanitize_fragment(&title).replace('/', "-"));
809+ let (download_name, save_name, save_dir) = (file_name.clone(), file_name, cwd.to_path_buf());
810+ let toggle = session.clone();
811+ v_flex()
812+ .w_full()
813+ .px(px(4.))
814+ .child(
815+ v_flex()
816+ .w_full()
817+ .p(px(20.))
818+ .rounded(px(22.))
819+ .raised(p)
820+ .child(
821+ h_flex()
822+ .w_full()
823+ .justify_between()
824+ .gap(px(12.))
825+ .child(
826+ h_flex()
827+ .gap(px(8.))
828+ .min_w_0()
829+ .child(metal::tag("Plan", p))
830+ .child(div().min_w_0().truncate().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(title)),
831+ )
832+ .child(
833+ MetalButton::new(("plan-actions", ix))
834+ .compact()
835+ .size(px(24.))
836+ .icon(IconName::Ellipsis)
837+ .tooltip("Plan actions")
838+ .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
839+ let (copy, download, save) = (copy.clone(), download.clone(), save.clone());
840+ let (download_name, save_name, save_dir) =
841+ (download_name.clone(), save_name.clone(), save_dir.clone());
842+ menu.item(PopupMenuItem::new("Copy to clipboard").on_click(move |_, _, cx| {
843+ cx.write_to_clipboard(ClipboardItem::new_string(copy.clone()));
844+ }))
845+ .item(PopupMenuItem::new("Download as markdown").on_click(move |_, window, cx| {
846+ let dir = std::env::var_os("HOME")
847+ .map(|home| std::path::PathBuf::from(home).join("Downloads"))
848+ .filter(|dir| dir.is_dir())
849+ .unwrap_or_else(std::env::temp_dir);
850+ save_plan(&dir.join(&download_name), &download, window, cx);
851+ }))
852+ .item(PopupMenuItem::new("Save to workspace").on_click(move |_, window, cx| {
853+ save_plan(&save_dir.join(&save_name), &save, window, cx);
854+ }))
855+ }),
856+ ),
857+ )
858+ .child(
859+ div()
860+ .mt(px(16.))
861+ .text_size(px(14.))
862+ .text_color(p.ink)
863+ .child(TextView::markdown(("plan", ix), shown).style(markdown_style(p)).selectable(true)),
864+ )
865+ .when(long, |this| {
866+ this.child(
867+ h_flex().mt(px(16.)).justify_center().child(
868+ MetalButton::new(("plan-unfold", ix))
869+ .compact()
870+ .label(if unfolded { "Collapse plan" } else { "Expand plan" })
871+ .on_click(move |_, _, cx| toggle.update(cx, |session, cx| session.toggle_tool(ix, cx))),
872+ ),
873+ )
874+ }),
875+ )
876+ .into_any_element()
877+ }
878+
879+ /// Writes a plan to a file, saying where (or why not).
880+ fn save_plan(path: &Path, plan: &str, window: &mut Window, cx: &mut App) {
881+ use gpui_kit::component::WindowExt as _;
882+ use gpui_kit::component::notification::Notification;
883+ match std::fs::write(path, plan) {
884+ Ok(()) => window.push_notification(Notification::success(format!("Plan saved to {}", path.display())), cx),
885+ Err(err) => window.push_notification(Notification::error(format!("Could not save plan: {err}")), cx),
886+ }
887+ }
888+
889+ /// What an AskUserQuestion call asked and what the user answered: a row
890+ /// per question, the answer after it.
891+ fn render_answers(ix: usize, tool: &ToolCall, ask: &UserQuestions, p: &Palette) -> AnyElement {
892+ let state = tool.permission.as_ref().map(|prompt| prompt.state);
893+ let status = match state {
894+ Some(PermissionState::Pending) => Some(("Waiting for your answer", p.amber_ink)),
895+ Some(PermissionState::Denied) => Some(("Dismissed", p.ink3)),
896+ Some(PermissionState::Cancelled) => Some(("Cancelled", p.ink3)),
897+ _ => None,
898+ };
899+ v_flex()
900+ .w_full()
901+ .pb(px(8.))
902+ .children(ask.questions.iter().zip(&ask.answers).enumerate().map(|(q, (question, answer))| {
903+ work_row(ElementId::NamedInteger(format!("answer-{ix}").into(), q as u64), Icon::new(Lucide::MessageCircle), p.ink3, p)
904+ .child(
905+ h_flex()
906+ .flex_1()
907+ .min_w_0()
908+ .gap(px(6.))
909+ .child(div().min_w_0().truncate().child(question.question.clone()))
910+ .when_some(answer.clone(), |this, answer| {
911+ this.child(div().min_w_0().truncate().text_color(p.ink).child(answer))
912+ }),
913+ )
914+ .when_some(status.filter(|_| q == 0), |this, (label, color)| {
915+ this.child(div().flex_none().text_size(px(12.)).text_color(color).child(label))
916+ })
917+ }))
918+ .into_any_element()
919+ }
920+
921+ /// A Pi extension's question and its answer, as it stays in the transcript.
922+ fn render_extension_record(question: &Question, p: &Palette) -> AnyElement {
923+ v_flex()
924+ .w_full()
925+ .pb(px(8.))
926+ .child(
927+ work_row("extension-question", Icon::new(Lucide::MessageCircle), p.ink3, p).child(
928+ h_flex()
929+ .flex_1()
930+ .min_w_0()
931+ .gap(px(6.))
932+ .child(div().min_w_0().truncate().child(question.title.clone()))
933+ .child(match &question.answer {
934+ Some(answer) => div().min_w_0().truncate().text_color(p.ink).child(answer.clone()),
935+ None => div().flex_none().text_size(px(12.)).text_color(p.amber_ink).child("Waiting for your answer"),
936+ }),
937+ ),
938+ )
939+ .into_any_element()
940+ }
941+
942+ /// A background task's end, which wakes the agent: a row of its own.
943+ fn render_task_event(ix: usize, summary: &SharedString, status: TaskStatus, p: &Palette) -> AnyElement {
944+ let (color, outcome) = match status {
945+ TaskStatus::Completed => (p.ink3, "Finished"),
946+ TaskStatus::Running => (p.ink3, "Updated"),
947+ TaskStatus::Failed => (p.red, "Failed"),
948+ TaskStatus::Stopped => (p.ink3, "Stopped"),
949+ };
950+ v_flex()
951+ .w_full()
952+ .pb(px(8.))
953+ .child(
954+ work_row(("task-event", ix), Icon::new(Lucide::Zap), color, p)
955+ .child(div().flex_1().min_w_0().truncate().child(summary.clone()))
956+ .child(div().flex_none().text_size(px(12.)).text_color(color).child(outcome)),
957+ )
958+ .into_any_element()
959+ }
960+
961+ /// An informational notice, as a divider across the conversation.
962+ fn render_divider(text: &SharedString, p: &Palette) -> AnyElement {
963+ let compacted = text.starts_with("Conversation compacted");
964+ let label: SharedString = if compacted { "Context compacted".into() } else { text.clone() };
965+ h_flex()
966+ .w_full()
967+ .gap(px(8.))
968+ .py(px(8.))
969+ .text_size(px(11.))
970+ .text_color(p.ink3)
971+ .child(div().flex_1().h(px(1.)).bg(p.rule))
972+ .child(
973+ h_flex()
974+ .max_w(relative(0.8))
975+ .gap(px(6.))
976+ .px(px(8.))
977+ .when(compacted, |this| this.child(Icon::new(Lucide::Minimize2).size(px(12.))))
978+ .child(div().min_w_0().font_weight(FontWeight::MEDIUM).child(label)),
979+ )
980+ .child(div().flex_1().h(px(1.)).bg(p.rule))
981+ .into_any_element()
982+ }
983+
984+ /// An error: a tinted box with its message.
985+ fn render_error(text: &SharedString, p: &Palette) -> AnyElement {
986+ h_flex()
987+ .w_full()
988+ .items_start()
989+ .gap(px(8.))
990+ .px(px(10.))
991+ .py(px(6.))
992+ .rounded(px(8.))
993+ .bg(p.red.opacity(0.05))
994+ .shadow(vec![BoxShadow::new(px(0.), px(0.), p.red.opacity(0.25)).spread_radius(px(1.)).inset()])
995+ .text_size(px(12.))
996+ .line_height(px(18.))
997+ .text_color(p.ink)
998+ .child(div().pt(px(2.)).child(Icon::new(IconName::CircleAlert).size(px(14.)).text_color(p.red)))
999+ .child(div().flex_1().min_w_0().child(text.clone()))
1000+ .into_any_element()
1001+ }
1002+
1003+ /// Where a tool's background task stands, and the buttons to move a long
1004+ /// command to the background or stop it there.
1005+ fn render_task_controls(session: &Entity<Session>, ix: usize, tool: &ToolCall, p: &Palette) -> Option<AnyElement> {
1006+ let session = session.clone();
1007+ match (tool.background, &tool.task_id) {
1008+ (Some(TaskStatus::Running), Some(task_id)) => {
1009+ let task_id = task_id.clone();
1010+ Some(
1011+ h_flex()
1012+ .gap(px(6.))
1013+ .child(metal::tag("Background", p))
1014+ .child(
1015+ MetalButton::new(("tool-stop-task", ix))
1016+ .ghost()
1017+ .mini()
1018+ .icon(IconName::Square)
1019+ .tooltip("Stop this task")
1020+ .on_click(move |_, _, cx| {
1021+ cx.stop_propagation();
1022+ session.update(cx, |session, cx| session.stop_task(&task_id, cx));
1023+ }),
1024+ )
1025+ .into_any_element(),
1026+ )
1027+ }
1028+ (Some(status), _) => Some(
1029+ metal::engraved(
1030+ match status {
1031+ TaskStatus::Running | TaskStatus::Completed => "Ran in background",
1032+ TaskStatus::Failed => "Failed in background",
1033+ TaskStatus::Stopped => "Stopped",
1034+ },
1035+ p,
1036+ )
1037+ .into_any_element(),
1038+ ),
1039+ // A long foreground command can be sent to the background.
1040+ (None, Some(_)) if tool.state == ToolState::Running => Some(
1041+ MetalButton::new(("tool-background", ix))
1042+ .ghost()
1043+ .mini()
1044+ .label("Background")
1045+ .tooltip("Keep it running in the background; Claude carries on and hears when it finishes")
1046+ .on_click(move |_, _, cx| {
1047+ cx.stop_propagation();
1048+ session.update(cx, |session, cx| session.background_tool(ix, cx));
1049+ })
1050+ .into_any_element(),
1051+ ),
1052+ _ => None,
1053+ }
1054+ }
1055+
1056+ fn render_tool_details(ix: usize, tool: &ToolCall, cx: &App) -> impl IntoElement {
1057+ let p = metal::palette(cx);
1058+ // Claude and Pi name the same arguments differently.
1059+ let field = |keys: &[&str]| tool_field(&tool.input, keys);
1060+ let old_new = (field(&["old_string", "oldText"]), field(&["new_string", "newText"]));
1061+
1062+ let mut sections: Vec<(&'static str, String)> = Vec::new();
1063+ let mut plan: Option<String> = None;
1064+ match tool.name.to_ascii_lowercase().as_str() {
1065+ "bash" => {
1066+ if let Some(description) = field(&["description"]) {
1067+ sections.push(("Description", description));
1068+ }
1069+ sections.push(("Command", field(&["command"]).unwrap_or_default()));
1070+ }
1071+ "edit" if old_new.0.is_some() || old_new.1.is_some() => {
1072+ sections.push(("File", field(&["file_path", "path"]).unwrap_or_default()));
1073+ sections.push(("Replace", old_new.0.unwrap_or_default()));
1074+ sections.push(("With", old_new.1.unwrap_or_default()));
1075+ }
1076+ "write" => {
1077+ sections.push(("File", field(&["file_path", "path"]).unwrap_or_default()));
1078+ sections.push(("Content", field(&["content"]).unwrap_or_default()));
1079+ }
1080+ "exitplanmode" => plan = field(&["plan"]),
1081+ _ => {
1082+ let input = if tool.input.is_null() && !tool.partial_input.is_empty() {
1083+ tool.partial_input.clone()
1084+ } else {
1085+ serde_json::to_string_pretty(&tool.input).unwrap_or_default()
1086+ };
1087+ sections.push(("Input", input));
1088+ }
1089+ }
1090+ if let Some(result) = &tool.result {
1091+ sections.push((
1092+ if tool.state == ToolState::Error { "Error" } else { "Output" },
1093+ truncate(result, MAX_TOOL_OUTPUT),
1094+ ));
1095+ }
1096+
1097+ v_flex()
1098+ .gap(px(10.))
1099+ .px(px(12.))
1100+ .pb(px(12.))
1101+ .pt(px(2.))
1102+ .when_some(plan, |this, plan| {
1103+ this.child(
1104+ div()
1105+ .px(px(16.))
1106+ .py(px(12.))
1107+ .rounded(px(12.))
1108+ .well(p)
1109+ .text_size(px(14.))
1110+ .text_color(p.ink)
1111+ .child(TextView::markdown(("plan", ix), plan).style(markdown_style(p)).selectable(true)),
1112+ )
1113+ })
1114+ .children(sections.into_iter().enumerate().map(|(section_ix, (label, body))| {
1115+ v_flex()
1116+ .gap(px(6.))
1117+ .child(div().px(px(2.)).child(metal::engraved(label, p)))
1118+ .child(
1119+ div()
1120+ .id(ElementId::NamedInteger(
1121+ format!("tool-{ix}-section").into(),
1122+ section_ix as u64,
1123+ ))
1124+ .max_h(px(280.))
1125+ .overflow_y_scroll()
1126+ .px(px(12.))
1127+ .py(px(9.))
1128+ .rounded(px(12.))
1129+ .well(p)
1130+ .font_family(metal::MONO)
1131+ .text_size(px(11.5))
1132+ .line_height(px(17.))
1133+ .text_color(p.ink2)
1134+ .child(if body.is_empty() { "(empty)".to_string() } else { body }),
1135+ )
1136+ }))
1137+ }
1138+
+2−3src/ui/composer.rsView File
@@ -6,7 +6,7 @@ use gpui_kit::component::{Icon, IconName, h_flex, tooltip::Tooltip, v_flex};
66 use gpui_kit::prelude::FluentBuilder as _;
77 use gpui_kit::*;
88
9- use crate::ui::metal::{self, MetalButton, Palette};
9+ use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};
1010
1111 /// How far docked banners and the context strip sit in from the composer's
1212 /// sides.
@@ -145,8 +145,7 @@ pub fn strip(p: &Palette) -> Div {
145145 .pr(px(8.))
146146 .gap(px(4.))
147147 .rounded_b(px(18.))
148- .bg(metal::vertical(p.region))
149- .shadow(p.region_sh.clone())
148+ .region(p)
150149 .text_size(px(12.))
151150 .text_color(p.ink3)
152151 }
+6−0src/ui/icons.rsView File
@@ -77,6 +77,12 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
7777 RotateCcwClock,
7878 Scale,
7979 Shield,
80+ Hammer,
81+ MessageCircle,
82+ Terminal,
83+ Wrench,
84+ Quote,
85+ Download,
8086 ]);
8187
8288 /// The app's asset source: the extra icons, then gpui-component's defaults.