Each Task/Agent call now tracks when it started and finished, its tokens, its latest tool call and the CLI's progress summary (task_progress), saved with the thread so remote clients see it too. The panel (Ctrl+Alt+S, or the robot in the top bar) lists the open thread's working subagents with what each is doing, then the finished and failed ones, then subagents working in other threads; a click jumps to the call in its conversation. Also fixes a crash when a dimmed working thread card got a second hover style in the sidebar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 10 changed files with 572 additions and 22 deletions.
| @@ -104,6 +104,12 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 104 | 104 | Sublime Text, Kate or the file manager, whichever are installed), the branch with a |
| 105 | 105 | switcher, **Commit** (asks the agent to commit, or commit and push), the size of the |
| 106 | 106 | uncommitted changes, and the thread's cost. |
| 107 | + - A subagents panel (Ctrl+Alt+S, or the robot in the top bar), in the project panel's | |
| 108 | + place: every subagent Claude started in the thread, the working ones first with what each | |
| 109 | + is doing right now (its latest tool call and progress summary), how long it has run, its | |
| 110 | + kind, tool uses and tokens, then the finished and failed ones. Subagents working in other | |
| 111 | + threads list underneath. Click one to jump to its call in the conversation. While the | |
| 112 | + panel is closed, a light on the robot says subagents are working. | |
| 107 | 113 | - Background tasks: when a command, monitor or subagent Claude left running finishes, the |
| 108 | 114 | transcript says so and Claude picks the work back up and reports back on its own. A |
| 109 | 115 | long-running command can be sent to the background from its tool card, like Ctrl+B in |
| @@ -254,6 +260,7 @@ The shortcuts follow T3 Code's default keymap. | ||
| 254 | 260 | | Ctrl+Shift+C / Ctrl+Shift+V | Copy / paste in the terminal | |
| 255 | 261 | | Ctrl+Alt+B | Show or hide the right panel (the browser) | |
| 256 | 262 | | Ctrl+Shift+B | Show or hide the thread details (project panel) | |
| 263 | + | Ctrl+Alt+S | Show or hide the subagents panel | | |
| 257 | 264 | | Ctrl+O | Add a project folder | |
| 258 | 265 | | Ctrl+, | Open or close settings | |
| 259 | 266 | | Ctrl+Q | Quit | |
| @@ -39,6 +39,7 @@ gpui_kit::actions!( | ||
| 39 | 39 | Interrupt, |
| 40 | 40 | ToggleSidebar, |
| 41 | 41 | ToggleProjectPanel, |
| 42 | + ToggleSubagents, | |
| 42 | 43 | ToggleBrowser, |
| 43 | 44 | FocusSearch, |
| 44 | 45 | OpenSettings, |
| @@ -127,6 +128,7 @@ fn main() { | ||
| 127 | 128 | // The app's own. |
| 128 | 129 | KeyBinding::new("ctrl-o", OpenInEditor, None), |
| 129 | 130 | KeyBinding::new("ctrl-shift-b", ToggleProjectPanel, None), |
| 131 | + KeyBinding::new("ctrl-alt-s", ToggleSubagents, None), | |
| 130 | 132 | KeyBinding::new("ctrl-shift-j", ToggleBrowser, None), |
| 131 | 133 | KeyBinding::new("ctrl-,", OpenSettings, None), |
| 132 | 134 | KeyBinding::new("ctrl-q", Quit, None), |
| @@ -108,6 +108,78 @@ pub struct ToolCall { | ||
| 108 | 108 | pub task_id: Option<String>, |
| 109 | 109 | /// Set once the tool runs as a background task. |
| 110 | 110 | pub background: Option<TaskStatus>, |
| 111 | + /// What a subagent (Task or Agent tool) is up to; `None` for other tools. | |
| 112 | + pub subagent: Option<SubagentProgress>, | |
| 113 | + } | |
| 114 | + | |
| 115 | + impl ToolCall { | |
| 116 | + /// Where a subagent stands: running until its tool returns, or, sent to | |
| 117 | + /// the background, until its task ends. `None` for other tools. | |
| 118 | + pub fn subagent_status(&self) -> Option<TaskStatus> { | |
| 119 | + self.subagent.as_ref()?; | |
| 120 | + Some(match (self.background, self.state) { | |
| 121 | + (Some(status), ToolState::Running | ToolState::Done) => status, | |
| 122 | + (_, ToolState::Error) => TaskStatus::Failed, | |
| 123 | + (None, ToolState::Running) => TaskStatus::Running, | |
| 124 | + (None, ToolState::Done) => TaskStatus::Completed, | |
| 125 | + }) | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + /// A subagent's progress, for the subagents panel. It's saved with the | |
| 130 | + /// thread, so it reaches remote clients too. | |
| 131 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 132 | + pub struct SubagentProgress { | |
| 133 | + /// When it started and finished, in milliseconds since the epoch. | |
| 134 | + pub started: u64, | |
| 135 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 136 | + pub finished: Option<u64>, | |
| 137 | + /// The tokens it has used, as the CLI reports them. | |
| 138 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 139 | + pub tokens: Option<u64>, | |
| 140 | + /// Its latest tool call: the tool and the input's short text fields. | |
| 141 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 142 | + pub tool: Option<(String, Value)>, | |
| 143 | + /// The CLI's running summary of what it's doing. | |
| 144 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 145 | + pub summary: Option<String>, | |
| 146 | + } | |
| 147 | + | |
| 148 | + impl SubagentProgress { | |
| 149 | + fn finish(&mut self) { | |
| 150 | + self.finished.get_or_insert_with(|| to_millis(SystemTime::now())); | |
| 151 | + } | |
| 152 | + | |
| 153 | + /// How long it has run, or ran, if that's known: threads saved before | |
| 154 | + /// subagents were timed, and ones cut off by a restart, don't say. | |
| 155 | + pub fn elapsed(&self, running: bool) -> Option<Duration> { | |
| 156 | + let end = match self.finished { | |
| 157 | + Some(finished) => finished, | |
| 158 | + None if running => to_millis(SystemTime::now()), | |
| 159 | + None => return None, | |
| 160 | + }; | |
| 161 | + (self.started > 0).then(|| Duration::from_millis(end.saturating_sub(self.started))) | |
| 162 | + } | |
| 163 | + } | |
| 164 | + | |
| 165 | + /// Whether a tool starts a subagent. | |
| 166 | + pub fn is_subagent_tool(name: &str) -> bool { | |
| 167 | + matches!(name, "Task" | "Agent") | |
| 168 | + } | |
| 169 | + | |
| 170 | + /// A tool input cut down to its short text fields: enough to say what the | |
| 171 | + /// call does, without a file's contents. | |
| 172 | + fn brief_input(input: &Value) -> Value { | |
| 173 | + let Some(fields) = input.as_object() else { return Value::Null }; | |
| 174 | + fields | |
| 175 | + .iter() | |
| 176 | + .filter_map(|(key, value)| { | |
| 177 | + let text = value.as_str()?; | |
| 178 | + let first = text.lines().next().unwrap_or_default(); | |
| 179 | + Some((key.clone(), Value::String(first.chars().take(200).collect()))) | |
| 180 | + }) | |
| 181 | + .collect::<serde_json::Map<_, _>>() | |
| 182 | + .into() | |
| 111 | 183 | } |
| 112 | 184 | |
| 113 | 185 | /// Where a background task stands. |
| @@ -2188,6 +2260,27 @@ impl Session { | ||
| 2188 | 2260 | } |
| 2189 | 2261 | } |
| 2190 | 2262 | } |
| 2263 | + // A subagent's running totals, as it works. | |
| 2264 | + Some("task_progress") => { | |
| 2265 | + let Some(tool) = self.find_tool_mut(text("tool_use_id").unwrap_or_default()) else { return }; | |
| 2266 | + let uses = message.pointer("/usage/tool_uses").and_then(Value::as_u64); | |
| 2267 | + let tokens = message.pointer("/usage/total_tokens").and_then(Value::as_u64); | |
| 2268 | + if let Some(uses) = uses { | |
| 2269 | + tool.subagent_tool_count = tool.subagent_tool_count.max(uses as usize); | |
| 2270 | + } | |
| 2271 | + if let Some(progress) = tool.subagent.as_mut() { | |
| 2272 | + progress.tokens = tokens.or(progress.tokens); | |
| 2273 | + // A background subagent's own messages don't come through. | |
| 2274 | + if let Some(name) = text("last_tool_name") | |
| 2275 | + && progress.tool.as_ref().is_none_or(|(tool, _)| tool != name) | |
| 2276 | + { | |
| 2277 | + progress.tool = Some((name.to_string(), Value::Null)); | |
| 2278 | + } | |
| 2279 | + if let Some(summary) = text("summary").filter(|summary| !summary.trim().is_empty()) { | |
| 2280 | + progress.summary = Some(summary.trim().to_string()); | |
| 2281 | + } | |
| 2282 | + } | |
| 2283 | + } | |
| 2191 | 2284 | Some("task_updated") => { |
| 2192 | 2285 | let backgrounded = message.pointer("/patch/is_backgrounded").and_then(Value::as_bool) == Some(true); |
| 2193 | 2286 | let tool_use_id = text("task_id") |
| @@ -2232,6 +2325,14 @@ impl Session { | ||
| 2232 | 2325 | if tool.background.is_some() { |
| 2233 | 2326 | tool.background = Some(status); |
| 2234 | 2327 | } |
| 2328 | + if let Some(progress) = tool.subagent.as_mut() { | |
| 2329 | + if let Some(tokens) = message.pointer("/usage/total_tokens").and_then(Value::as_u64) { | |
| 2330 | + progress.tokens = Some(tokens); | |
| 2331 | + } | |
| 2332 | + if finished && tool.background.is_some() { | |
| 2333 | + progress.finish(); | |
| 2334 | + } | |
| 2335 | + } | |
| 2235 | 2336 | } |
| 2236 | 2337 | // Long foreground commands report finishing too; only |
| 2237 | 2338 | // background tasks wake Claude. |
| @@ -2353,19 +2454,21 @@ impl Session { | ||
| 2353 | 2454 | } |
| 2354 | 2455 | } |
| 2355 | 2456 | |
| 2457 | + /// A subagent's own message: counts its tool calls and notes the latest. | |
| 2356 | 2458 | fn count_subagent_tools(&mut self, parent: &str, message: &Value) { |
| 2357 | - let count = message | |
| 2459 | + let calls: Vec<&Value> = message | |
| 2358 | 2460 | .get("content") |
| 2359 | 2461 | .and_then(Value::as_array) |
| 2360 | - .map(|blocks| { | |
| 2361 | - blocks | |
| 2362 | - .iter() | |
| 2363 | - .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) | |
| 2364 | - .count() | |
| 2365 | - }) | |
| 2366 | - .unwrap_or(0); | |
| 2462 | + .into_iter() | |
| 2463 | + .flatten() | |
| 2464 | + .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) | |
| 2465 | + .collect(); | |
| 2367 | 2466 | if let Some(tool) = self.find_tool_mut(parent) { |
| 2368 | - tool.subagent_tool_count += count; | |
| 2467 | + tool.subagent_tool_count += calls.len(); | |
| 2468 | + if let (Some(progress), Some(call)) = (tool.subagent.as_mut(), calls.last()) { | |
| 2469 | + let name = call.get("name").and_then(Value::as_str).unwrap_or("Tool").to_string(); | |
| 2470 | + progress.tool = Some((name, brief_input(&call["input"]))); | |
| 2471 | + } | |
| 2369 | 2472 | } |
| 2370 | 2473 | } |
| 2371 | 2474 | |
| @@ -2384,6 +2487,12 @@ impl Session { | ||
| 2384 | 2487 | if let Some(tool) = self.find_tool_mut(id) { |
| 2385 | 2488 | tool.result = Some(text); |
| 2386 | 2489 | tool.state = if is_error { ToolState::Error } else { ToolState::Done }; |
| 2490 | + // A background subagent's tool returns as it starts. | |
| 2491 | + if (is_error || tool.background.is_none()) | |
| 2492 | + && let Some(progress) = tool.subagent.as_mut() | |
| 2493 | + { | |
| 2494 | + progress.finish(); | |
| 2495 | + } | |
| 2387 | 2496 | if let Some(prompt) = tool.permission.as_mut() |
| 2388 | 2497 | && prompt.state == PermissionState::Pending |
| 2389 | 2498 | { |
| @@ -3073,6 +3182,8 @@ impl Session { | ||
| 3073 | 3182 | ask: None, |
| 3074 | 3183 | task_id: None, |
| 3075 | 3184 | background: None, |
| 3185 | + subagent: is_subagent_tool(name) | |
| 3186 | + .then(|| SubagentProgress { started: to_millis(SystemTime::now()), ..Default::default() }), | |
| 3076 | 3187 | })); |
| 3077 | 3188 | self.items.len() - 1 |
| 3078 | 3189 | } |
| @@ -3131,6 +3242,9 @@ impl Session { | ||
| 3131 | 3242 | && tool.background == Some(TaskStatus::Running) |
| 3132 | 3243 | { |
| 3133 | 3244 | tool.background = Some(TaskStatus::Stopped); |
| 3245 | + if let Some(progress) = tool.subagent.as_mut() { | |
| 3246 | + progress.finish(); | |
| 3247 | + } | |
| 3134 | 3248 | } |
| 3135 | 3249 | } |
| 3136 | 3250 | } |
| @@ -3142,6 +3256,9 @@ impl Session { | ||
| 3142 | 3256 | { |
| 3143 | 3257 | tool.state = ToolState::Error; |
| 3144 | 3258 | tool.result.get_or_insert_with(|| reason.to_string()); |
| 3259 | + if let Some(progress) = tool.subagent.as_mut() { | |
| 3260 | + progress.finish(); | |
| 3261 | + } | |
| 3145 | 3262 | } |
| 3146 | 3263 | } |
| 3147 | 3264 | } |
| @@ -3232,6 +3349,8 @@ pub enum SavedItem { | ||
| 3232 | 3349 | /// background. |
| 3233 | 3350 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 3234 | 3351 | background: Option<String>, |
| 3352 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 3353 | + subagent: Option<SubagentProgress>, | |
| 3235 | 3354 | }, |
| 3236 | 3355 | Notice { error: bool, text: String }, |
| 3237 | 3356 | TaskEvent { summary: String, status: String }, |
| @@ -3308,6 +3427,7 @@ impl Session { | ||
| 3308 | 3427 | .as_ref() |
| 3309 | 3428 | .map(|ask| ask.answers.iter().map(|answer| answer.as_ref().map(ToString::to_string)).collect()), |
| 3310 | 3429 | background: tool.background.map(|status| status.key().to_string()), |
| 3430 | + subagent: tool.subagent.clone(), | |
| 3311 | 3431 | }, |
| 3312 | 3432 | ChatItem::Notice(kind, text) => { |
| 3313 | 3433 | SavedItem::Notice { error: *kind == NoticeKind::Error, text: text.to_string() } |
| @@ -3448,6 +3568,7 @@ impl Session { | ||
| 3448 | 3568 | subagent_tool_count, |
| 3449 | 3569 | answers, |
| 3450 | 3570 | background, |
| 3571 | + subagent, | |
| 3451 | 3572 | } => { |
| 3452 | 3573 | let pending = live && permission.as_deref() == Some("pending"); |
| 3453 | 3574 | let ask = answers.and_then(|answers| { |
| @@ -3458,6 +3579,8 @@ impl Session { | ||
| 3458 | 3579 | ask.current = ask.answers.iter().position(Option::is_none).unwrap_or(0); |
| 3459 | 3580 | Some(ask) |
| 3460 | 3581 | }); |
| 3582 | + // Threads saved before subagents were tracked still list them. | |
| 3583 | + let subagent = subagent.or_else(|| is_subagent_tool(&name).then(SubagentProgress::default)); | |
| 3461 | 3584 | let background = background.map(|status| match TaskStatus::from_cli(&status) { |
| 3462 | 3585 | // Background tasks die with the agent process. |
| 3463 | 3586 | TaskStatus::Running if !live => TaskStatus::Stopped, |
| @@ -3491,6 +3614,8 @@ impl Session { | ||
| 3491 | 3614 | ask, |
| 3492 | 3615 | task_id: None, |
| 3493 | 3616 | background, |
| 3617 | + // Threads saved before subagents were tracked still list them. | |
| 3618 | + subagent, | |
| 3494 | 3619 | }) |
| 3495 | 3620 | } |
| 3496 | 3621 | SavedItem::Notice { error, text } => { |
| @@ -9,6 +9,8 @@ mod footer; | ||
| 9 | 9 | mod strip; |
| 10 | 10 | mod timeline; |
| 11 | 11 | |
| 12 | + pub(crate) use timeline::{describe_call, format_duration}; | |
| 13 | + | |
| 12 | 14 | use std::{ |
| 13 | 15 | path::PathBuf, |
| 14 | 16 | time::{Duration, SystemTime}, |
| @@ -374,6 +376,39 @@ impl ChatView { | ||
| 374 | 376 | self.composer.update(cx, |composer, cx| composer.set_submit_on_enter(enter_sends, cx)); |
| 375 | 377 | } |
| 376 | 378 | |
| 379 | + /// Shows a tool call in the conversation, unfolded: opens the finished | |
| 380 | + /// turn and the run of calls it's folded into, and scrolls to it. The | |
| 381 | + /// subagents panel's rows lead here. | |
| 382 | + pub fn reveal_tool(&mut self, ix: usize, cx: &mut Context<Self>) { | |
| 383 | + let session = self.session.read(cx); | |
| 384 | + if !matches!(session.items.get(ix), Some(ChatItem::Tool(_))) { | |
| 385 | + return; | |
| 386 | + } | |
| 387 | + let fold = settings::get(|settings| settings.fold_turns); | |
| 388 | + let hidden = session | |
| 389 | + .finished_turn(ix) | |
| 390 | + .filter(|turn| turn.answer != Some(ix) && session.turn_expanded(turn.end) != fold) | |
| 391 | + .map(|turn| turn.end); | |
| 392 | + let group = timeline::group_of(&session.items, ix); | |
| 393 | + if group.len() > 1 { | |
| 394 | + self.expanded_groups.insert(group[0]); | |
| 395 | + } | |
| 396 | + self.session.update(cx, |session, cx| { | |
| 397 | + if let Some(end) = hidden { | |
| 398 | + session.toggle_turn(end, cx); | |
| 399 | + } | |
| 400 | + if let Some(ChatItem::Tool(tool)) = session.items.get_mut(ix) { | |
| 401 | + tool.expanded = true; | |
| 402 | + } | |
| 403 | + cx.notify(); | |
| 404 | + }); | |
| 405 | + self.scroller.update(cx, |scroller, cx| { | |
| 406 | + scroller.remeasure(cx); | |
| 407 | + scroller.scroll_to_item(ix, cx); | |
| 408 | + }); | |
| 409 | + cx.notify(); | |
| 410 | + } | |
| 411 | + | |
| 377 | 412 | pub fn focus_composer(&self, window: &mut Window, cx: &mut App) { |
| 378 | 413 | self.composer.update(cx, |composer, cx| composer.focus(window, cx)); |
| 379 | 414 | } |
| @@ -9,6 +9,8 @@ use std::collections::HashSet; | ||
| 9 | 9 | use std::path::Path; |
| 10 | 10 | use std::time::SystemTime; |
| 11 | 11 | |
| 12 | + use serde_json::Value; | |
| 13 | + | |
| 12 | 14 | use gpui_kit::component::{ |
| 13 | 15 | Icon, IconName, h_flex, |
| 14 | 16 | menu::{DropdownMenu as _, PopupMenuItem}, |
| @@ -99,9 +101,19 @@ fn relative_path(path: &str, cwd: &Path) -> String { | ||
| 99 | 101 | } |
| 100 | 102 | |
| 101 | 103 | fn present(tool: &ToolCall, cwd: &Path) -> Presentation { |
| 102 | - let field = |keys: &[&str]| tool_field(&tool.input, keys); | |
| 104 | + present_call(&tool.name, &tool.input, cwd) | |
| 105 | + } | |
| 106 | + | |
| 107 | + /// A tool call's icon and label as the timeline shows them, for a call | |
| 108 | + /// known only by its name and input (a subagent's). | |
| 109 | + pub(crate) fn describe_call(name: &str, input: &Value, cwd: &Path) -> (Icon, String) { | |
| 110 | + let presentation = present_call(name, input, cwd); | |
| 111 | + (presentation.icon, presentation.label) | |
| 112 | + } | |
| 113 | + | |
| 114 | + fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation { | |
| 115 | + let field = |keys: &[&str]| tool_field(input, keys); | |
| 103 | 116 | let first_line = |text: String| text.lines().next().unwrap_or_default().chars().take(200).collect::<String>(); |
| 104 | - let name = tool.name.as_str(); | |
| 105 | 117 | let (action, label) = match name.to_ascii_lowercase().as_str() { |
| 106 | 118 | "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())), |
| 107 | 119 | "bashoutput" | "killshell" => (Action::Command, name.to_string()), |
| @@ -129,7 +141,7 @@ fn present(tool: &ToolCall, cwd: &Path) -> Presentation { | ||
| 129 | 141 | (Action::Other, format!("{server}: {tool_name}")) |
| 130 | 142 | } |
| 131 | 143 | _ => { |
| 132 | - let summary = crate::session::tool_summary(name, &tool.input, cwd); | |
| 144 | + let summary = crate::session::tool_summary(name, input, cwd); | |
| 133 | 145 | (Action::Other, if summary.is_empty() { name.to_string() } else { format!("{name} {summary}") }) |
| 134 | 146 | } |
| 135 | 147 | }; |
| @@ -188,7 +200,7 @@ fn group_summary(actions: &[Action]) -> String { | ||
| 188 | 200 | } |
| 189 | 201 | |
| 190 | 202 | /// `4.2s`, `42s`, `2m 14s`, `1h 3m 20s`, as T3 Code writes durations. |
| 191 | - pub(super) fn format_duration(ms: u64) -> String { | |
| 203 | + pub(crate) fn format_duration(ms: u64) -> String { | |
| 192 | 204 | if ms < 1_000 { |
| 193 | 205 | return format!("{}ms", ms.max(1)); |
| 194 | 206 | } |
| @@ -220,7 +232,7 @@ fn is_hidden(item: &ChatItem) -> bool { | ||
| 220 | 232 | } |
| 221 | 233 | |
| 222 | 234 | /// The run of consecutive tool calls `ix` belongs to: its work rows' indices. |
| 223 | - fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> { | |
| 235 | + pub(super) fn group_of(items: &[ChatItem], ix: usize) -> Vec<usize> { | |
| 224 | 236 | let mut start = ix; |
| 225 | 237 | while start > 0 && (is_work(&items[start - 1]) || is_hidden(&items[start - 1])) { |
| 226 | 238 | start -= 1; |
| @@ -27,6 +27,7 @@ const COMMANDS: &[Command] = &[ | ||
| 27 | 27 | Command { id: "sidebar.toggle", label: "Sidebar: Toggle", actions: &["vectrum_code::ToggleSidebar"] }, |
| 28 | 28 | Command { id: "rightPanel.toggle", label: "Right Panel: Toggle", actions: &["vectrum_code::ToggleRightPanel"] }, |
| 29 | 29 | Command { id: "threadPanel.toggle", label: "Thread Panel: Toggle", actions: &["vectrum_code::ToggleProjectPanel"] }, |
| 30 | + Command { id: "subagentsPanel.toggle", label: "Subagents Panel: Toggle", actions: &["vectrum_code::ToggleSubagents"] }, | |
| 30 | 31 | Command { |
| 31 | 32 | id: "commandPalette.toggle", |
| 32 | 33 | label: "Command Palette: Toggle", |
| @@ -99,7 +100,7 @@ const COMPOSER_COMMANDS: &[(&str, &str)] = &[ | ||
| 99 | 100 | /// T3 Code's groups, in page order; a command joins the first group |
| 100 | 101 | /// listing its id's prefix. |
| 101 | 102 | const GROUPS: &[(&str, &[&str])] = &[ |
| 102 | - ("Navigation", &["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"]), | |
| 103 | + ("Navigation", &["sidebar", "rightPanel", "threadPanel", "subagentsPanel", "commandPalette", "filePicker", "projectSearch", "editor"]), | |
| 103 | 104 | ("Threads", &["thread", "chat", "pullRequest"]), |
| 104 | 105 | ("Composer", &["composer", "modelPicker"]), |
| 105 | 106 | ("Terminal", &["terminal"]), |
| @@ -42,7 +42,7 @@ use crate::{ | ||
| 42 | 42 | CopyThreadReference, CycleAppearance, FocusSearch, NewThread, NewThreadLocal, NewThreadWithoutProject, NextThread, |
| 43 | 43 | CloseTerminal, NewTerminal, OpenInEditor, OpenProject, OpenSettings, OpenUsage, PinThread, PreviousThread, SelectTheme, |
| 44 | 44 | SettleThread, SplitTerminal, SplitTerminalVertical, ToggleBrowser, ToggleCommandPalette, ToggleProjectPanel, |
| 45 | - ToggleRightPanel, ToggleSidebar, ToggleTerminal, UndoThreadAction, | |
| 45 | + ToggleRightPanel, ToggleSidebar, ToggleSubagents, ToggleTerminal, UndoThreadAction, | |
| 46 | 46 | }; |
| 47 | 47 | |
| 48 | 48 | mod actions; |
| @@ -51,6 +51,7 @@ mod header; | ||
| 51 | 51 | mod palette; |
| 52 | 52 | mod remote; |
| 53 | 53 | mod sidebar; |
| 54 | + mod subagents; | |
| 54 | 55 | mod terminal_drawer; |
| 55 | 56 | |
| 56 | 57 | use actions::UndoEntry; |
| @@ -219,6 +220,8 @@ pub struct Workspace { | ||
| 219 | 220 | panel_open: bool, |
| 220 | 221 | /// The browser takes the project panel's place while open. |
| 221 | 222 | browser_open: bool, |
| 223 | + /// The subagents panel, in the project panel's place. | |
| 224 | + subagents_open: bool, | |
| 222 | 225 | settled_open: bool, |
| 223 | 226 | snoozed_open: bool, |
| 224 | 227 | working_open: bool, |
| @@ -371,6 +374,7 @@ impl Workspace { | ||
| 371 | 374 | sidebar_open: true, |
| 372 | 375 | panel_open: true, |
| 373 | 376 | browser_open: false, |
| 377 | + subagents_open: false, | |
| 374 | 378 | settled_open: false, |
| 375 | 379 | snoozed_open: false, |
| 376 | 380 | working_open: false, |
| @@ -595,7 +599,7 @@ impl Workspace { | ||
| 595 | 599 | } |
| 596 | 600 | // Working threads show how long they've worked. |
| 597 | 601 | let working = self.projects.iter().flat_map(|project| &project.sessions).any(|session| session.read(cx).is_working()); |
| 598 | - if working && self.sidebar_open { | |
| 602 | + if working && (self.sidebar_open || self.subagents_open) { | |
| 599 | 603 | cx.notify(); |
| 600 | 604 | } |
| 601 | 605 | // Repaint about once a minute so thread ages stay current. |
| @@ -1750,8 +1754,9 @@ impl Workspace { | ||
| 1750 | 1754 | |
| 1751 | 1755 | fn toggle_project_panel(&mut self, cx: &mut Context<Self>) { |
| 1752 | 1756 | // With the browser in its place, the project panel comes back. |
| 1753 | - if self.browser_open { | |
| 1757 | + if self.browser_open || self.subagents_open { | |
| 1754 | 1758 | self.browser_open = false; |
| 1759 | + self.subagents_open = false; | |
| 1755 | 1760 | self.panel_open = true; |
| 1756 | 1761 | } else { |
| 1757 | 1762 | self.panel_open = !self.panel_open; |
| @@ -2069,7 +2074,9 @@ impl Render for Workspace { | ||
| 2069 | 2074 | cx.set_global(directory); |
| 2070 | 2075 | let browser = self.shown_browser(window, cx); |
| 2071 | 2076 | let p = metal::palette(cx); |
| 2077 | + let subagents_visible = self.subagents_open && browser.is_none(); | |
| 2072 | 2078 | let panel_visible = self.panel_open |
| 2079 | + && !subagents_visible | |
| 2073 | 2080 | && browser.is_none() |
| 2074 | 2081 | && self.active_project().is_some() |
| 2075 | 2082 | && window.viewport_size().width >= px(PANEL_MIN_WINDOW); |
| @@ -2167,6 +2174,7 @@ impl Render for Workspace { | ||
| 2167 | 2174 | cx.notify(); |
| 2168 | 2175 | })) |
| 2169 | 2176 | .on_action(cx.listener(|this, _: &ToggleProjectPanel, _, cx| this.toggle_project_panel(cx))) |
| 2177 | + .on_action(cx.listener(|this, _: &ToggleSubagents, _, cx| this.toggle_subagents(cx))) | |
| 2170 | 2178 | .on_action(cx.listener(|this, _: &ToggleBrowser, _, cx| { |
| 2171 | 2179 | this.browser_open = !this.browser_open; |
| 2172 | 2180 | cx.notify(); |
| @@ -2217,6 +2225,7 @@ impl Render for Workspace { | ||
| 2217 | 2225 | .children(self.render_terminal_drawer(window, cx)), |
| 2218 | 2226 | ) |
| 2219 | 2227 | .when(panel_visible, |this| this.child(self.render_panel(cx))) |
| 2228 | + .when(subagents_visible, |this| this.child(self.render_subagents_panel(cx))) | |
| 2220 | 2229 | .when_some(browser, |this, browser| this.child(browser)), |
| 2221 | 2230 | ), |
| 2222 | 2231 | ), |
| @@ -210,9 +210,11 @@ impl Workspace { | ||
| 210 | 210 | } |
| 211 | 211 | |
| 212 | 212 | /// T3's panel toggles: thread details (our project panel), the terminal |
| 213 | - /// drawer, and the right panel (the browser). | |
| 213 | + /// drawer, and the right panel (the browser), plus the subagents panel. | |
| 214 | 214 | fn render_layout_controls(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement { |
| 215 | 215 | let p = metal::palette(cx); |
| 216 | + let subagents_shown = self.subagents_open && !self.browser_open; | |
| 217 | + let subagents_working = !subagents_shown && self.working_subagents(cx) > 0; | |
| 216 | 218 | let live = !self.browser_open |
| 217 | 219 | && self.active.as_ref().is_some_and(|session| BrowserWatch::is_live(&session.read(cx).id, cx)); |
| 218 | 220 | h_flex() |
| @@ -227,6 +229,22 @@ impl Workspace { | ||
| 227 | 229 | .tooltip("Toggle thread details (Ctrl+Shift+B)") |
| 228 | 230 | .on_click(cx.listener(|this, _, _, cx| this.toggle_project_panel(cx))), |
| 229 | 231 | ) |
| 232 | + .child( | |
| 233 | + div() | |
| 234 | + .relative() | |
| 235 | + .child( | |
| 236 | + MetalButton::new("toggle-subagents") | |
| 237 | + .ghost() | |
| 238 | + .mini() | |
| 239 | + .icon(Lucide::Bot) | |
| 240 | + .selected(subagents_shown) | |
| 241 | + .tooltip("Toggle subagents (Ctrl+Alt+S)") | |
| 242 | + .on_click(cx.listener(|this, _, _, cx| this.toggle_subagents(cx))), | |
| 243 | + ) | |
| 244 | + .when(subagents_working, |this| { | |
| 245 | + this.child(div().absolute().top(px(1.)).right(px(1.)).child(metal::led("subagents-live", Led::Live, true, p))) | |
| 246 | + }), | |
| 247 | + ) | |
| 230 | 248 | .child(if self.terminal_available(cx) { |
| 231 | 249 | MetalButton::new("toggle-terminal-drawer") |
| 232 | 250 | .ghost() |
| @@ -0,0 +1,323 @@ | ||
| 1 | + //! The subagents panel: every subagent the open thread's agent started, the | |
| 2 | + //! working ones first with what each is doing right now (its latest tool | |
| 3 | + //! call, its progress summary, how long it's run, its tool uses and | |
| 4 | + //! tokens), then the finished ones. Subagents working in other threads | |
| 5 | + //! list under them. A click shows the subagent's call in its conversation. | |
| 6 | + | |
| 7 | + use std::time::Duration; | |
| 8 | + | |
| 9 | + use gpui_kit::component::{Icon, IconName, h_flex, v_flex}; | |
| 10 | + use gpui_kit::prelude::FluentBuilder as _; | |
| 11 | + use gpui_kit::*; | |
| 12 | + | |
| 13 | + use super::{PANEL_WIDTH, Workspace}; | |
| 14 | + use crate::session::{ChatItem, Session, TaskStatus, tool_field}; | |
| 15 | + use crate::ui::chat::{describe_call, format_duration}; | |
| 16 | + use crate::ui::icons::Lucide; | |
| 17 | + use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; | |
| 18 | + | |
| 19 | + /// A subagent as the panel lists it. | |
| 20 | + struct Subagent { | |
| 21 | + session: Entity<Session>, | |
| 22 | + /// Its tool call's place in the thread's transcript. | |
| 23 | + ix: usize, | |
| 24 | + description: SharedString, | |
| 25 | + /// The kind of agent, e.g. `Explore`. | |
| 26 | + kind: Option<SharedString>, | |
| 27 | + status: TaskStatus, | |
| 28 | + background: bool, | |
| 29 | + tool_uses: usize, | |
| 30 | + tokens: Option<u64>, | |
| 31 | + elapsed: Option<Duration>, | |
| 32 | + /// What it's doing: its latest tool call while it works. | |
| 33 | + activity: Option<(Icon, String)>, | |
| 34 | + summary: Option<SharedString>, | |
| 35 | + } | |
| 36 | + | |
| 37 | + /// A thread's subagents, in the order they started. | |
| 38 | + fn subagents_of(session: &Entity<Session>, cx: &App) -> Vec<Subagent> { | |
| 39 | + let state = session.read(cx); | |
| 40 | + state | |
| 41 | + .items | |
| 42 | + .iter() | |
| 43 | + .enumerate() | |
| 44 | + .filter_map(|(ix, item)| { | |
| 45 | + let ChatItem::Tool(tool) = item else { return None }; | |
| 46 | + let progress = tool.subagent.as_ref()?; | |
| 47 | + let status = tool.subagent_status()?; | |
| 48 | + let running = status == TaskStatus::Running; | |
| 49 | + let description = tool_field(&tool.input, &["description"]) | |
| 50 | + .filter(|description| !description.trim().is_empty()) | |
| 51 | + .unwrap_or_else(|| "Subagent".into()); | |
| 52 | + Some(Subagent { | |
| 53 | + session: session.clone(), | |
| 54 | + ix, | |
| 55 | + description: description.into(), | |
| 56 | + // The default kind goes without saying. | |
| 57 | + kind: tool_field(&tool.input, &["subagent_type"]) | |
| 58 | + .filter(|kind| !kind.is_empty() && kind != "general-purpose") | |
| 59 | + .map(Into::into), | |
| 60 | + status, | |
| 61 | + background: tool.background.is_some(), | |
| 62 | + tool_uses: tool.subagent_tool_count, | |
| 63 | + tokens: progress.tokens, | |
| 64 | + elapsed: progress.elapsed(running), | |
| 65 | + activity: progress | |
| 66 | + .tool | |
| 67 | + .as_ref() | |
| 68 | + .filter(|_| running) | |
| 69 | + .map(|(name, input)| describe_call(name, input, &state.cwd)), | |
| 70 | + summary: progress.summary.clone().map(Into::into), | |
| 71 | + }) | |
| 72 | + }) | |
| 73 | + .collect() | |
| 74 | + } | |
| 75 | + | |
| 76 | + /// `812 tokens`, `23.4k tokens`, `1.2M tokens`. | |
| 77 | + fn format_tokens(tokens: u64) -> String { | |
| 78 | + match tokens { | |
| 79 | + 0..1_000 => format!("{tokens} tokens"), | |
| 80 | + 1_000..1_000_000 => format!("{:.1}k tokens", tokens as f64 / 1_000.).replace(".0k", "k"), | |
| 81 | + _ => format!("{:.1}M tokens", tokens as f64 / 1_000_000.).replace(".0M", "M"), | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + impl Workspace { | |
| 86 | + /// How many subagents the open thread has working, for the header's light. | |
| 87 | + pub(super) fn working_subagents(&self, cx: &App) -> usize { | |
| 88 | + self.active.as_ref().map_or(0, |session| { | |
| 89 | + session | |
| 90 | + .read(cx) | |
| 91 | + .items | |
| 92 | + .iter() | |
| 93 | + .filter(|item| matches!(item, ChatItem::Tool(tool) if tool.subagent_status() == Some(TaskStatus::Running))) | |
| 94 | + .count() | |
| 95 | + }) | |
| 96 | + } | |
| 97 | + | |
| 98 | + pub(super) fn toggle_subagents(&mut self, cx: &mut Context<Self>) { | |
| 99 | + // With the browser in its place, the subagents come back. | |
| 100 | + if self.browser_open { | |
| 101 | + self.browser_open = false; | |
| 102 | + self.subagents_open = true; | |
| 103 | + } else { | |
| 104 | + self.subagents_open = !self.subagents_open; | |
| 105 | + } | |
| 106 | + cx.notify(); | |
| 107 | + } | |
| 108 | + | |
| 109 | + /// Opens a subagent's thread, if it isn't open, and shows its call. | |
| 110 | + fn reveal_subagent(&mut self, session: Entity<Session>, ix: usize, window: &mut Window, cx: &mut Context<Self>) { | |
| 111 | + if self.active.as_ref() != Some(&session) { | |
| 112 | + let Some(project) = self.project_of(&session) else { return }; | |
| 113 | + self.select(project, session.clone(), window, cx); | |
| 114 | + } | |
| 115 | + if let Some(chat) = self.chats.get(&session.entity_id()).cloned() { | |
| 116 | + chat.update(cx, |chat, cx| chat.reveal_tool(ix, cx)); | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + pub(super) fn render_subagents_panel(&self, cx: &mut Context<Self>) -> impl IntoElement { | |
| 121 | + let p = metal::palette(cx); | |
| 122 | + let mine = self.active.as_ref().map(|session| subagents_of(session, cx)).unwrap_or_default(); | |
| 123 | + let (working, mut finished): (Vec<Subagent>, Vec<Subagent>) = | |
| 124 | + mine.into_iter().partition(|subagent| subagent.status == TaskStatus::Running); | |
| 125 | + finished.reverse(); | |
| 126 | + // Subagents working in the other threads. | |
| 127 | + let elsewhere: Vec<Subagent> = self | |
| 128 | + .projects | |
| 129 | + .iter() | |
| 130 | + .flat_map(|project| &project.sessions) | |
| 131 | + .filter(|session| Some(*session) != self.active.as_ref()) | |
| 132 | + .flat_map(|session| subagents_of(session, cx)) | |
| 133 | + .filter(|subagent| subagent.status == TaskStatus::Running) | |
| 134 | + .collect(); | |
| 135 | + let empty = working.is_empty() && finished.is_empty(); | |
| 136 | + | |
| 137 | + let header = h_flex() | |
| 138 | + .h(px(36.)) | |
| 139 | + .pl(px(10.)) | |
| 140 | + .gap(px(10.)) | |
| 141 | + .child(Icon::new(Lucide::Bot).size(px(16.)).text_color(p.icon)) | |
| 142 | + .child(div().flex_1().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child("Subagents")) | |
| 143 | + .when(!working.is_empty(), |this| this.child(metal::tag(format!("{} working", working.len()), p))) | |
| 144 | + .child( | |
| 145 | + MetalButton::new("close-subagents") | |
| 146 | + .ghost() | |
| 147 | + .mini() | |
| 148 | + .icon(IconName::Close) | |
| 149 | + .tooltip("Close subagents") | |
| 150 | + .on_click(cx.listener(|this, _, _, cx| { | |
| 151 | + this.subagents_open = false; | |
| 152 | + cx.notify(); | |
| 153 | + })), | |
| 154 | + ); | |
| 155 | + | |
| 156 | + let section = |title: String, rows: Vec<AnyElement>| { | |
| 157 | + v_flex() | |
| 158 | + .gap(px(2.)) | |
| 159 | + .child(div().px(px(10.)).pt(px(8.)).pb(px(4.)).child(metal::engraved(title, p))) | |
| 160 | + .children(rows) | |
| 161 | + }; | |
| 162 | + let rows = |subagents: Vec<Subagent>, other: bool, cx: &mut Context<Self>| -> Vec<AnyElement> { | |
| 163 | + subagents.into_iter().map(|subagent| self.render_subagent(subagent, other, p, cx)).collect() | |
| 164 | + }; | |
| 165 | + let working_count = working.len(); | |
| 166 | + let finished_count = finished.len(); | |
| 167 | + let elsewhere_count = elsewhere.len(); | |
| 168 | + let working_rows = rows(working, false, cx); | |
| 169 | + let finished_rows = rows(finished, false, cx); | |
| 170 | + let elsewhere_rows = rows(elsewhere, true, cx); | |
| 171 | + | |
| 172 | + v_flex() | |
| 173 | + .w(px(PANEL_WIDTH)) | |
| 174 | + .flex_none() | |
| 175 | + .h_full() | |
| 176 | + .p(px(12.)) | |
| 177 | + .pl(px(0.)) | |
| 178 | + .child( | |
| 179 | + v_flex() | |
| 180 | + .max_h_full() | |
| 181 | + .p(px(6.)) | |
| 182 | + .rounded(px(18.)) | |
| 183 | + .raised(p) | |
| 184 | + .child(header) | |
| 185 | + .child(div().px(px(8.)).py(px(4.)).child(metal::rule(p))) | |
| 186 | + .child( | |
| 187 | + v_flex() | |
| 188 | + .id("subagents-list") | |
| 189 | + .flex_shrink(1.) | |
| 190 | + .min_h_0() | |
| 191 | + .overflow_y_scroll() | |
| 192 | + .when(empty, |this| { | |
| 193 | + this.child( | |
| 194 | + v_flex() | |
| 195 | + .px(px(10.)) | |
| 196 | + .py(px(10.)) | |
| 197 | + .gap(px(4.)) | |
| 198 | + .child(div().text_size(px(13.)).text_color(p.ink2).child("No subagents in this thread")) | |
| 199 | + .child(div().text_size(px(12.)).text_color(p.ink3).child( | |
| 200 | + "When the agent hands work to a subagent, it shows up here with what it's doing.", | |
| 201 | + )), | |
| 202 | + ) | |
| 203 | + }) | |
| 204 | + .when(working_count > 0, |this| this.child(section("Working".into(), working_rows))) | |
| 205 | + .when(finished_count > 0, |this| this.child(section(format!("Finished · {finished_count}"), finished_rows))) | |
| 206 | + .when(elsewhere_count > 0, |this| this.child(section("In other threads".into(), elsewhere_rows))), | |
| 207 | + ), | |
| 208 | + ) | |
| 209 | + } | |
| 210 | + | |
| 211 | + /// One subagent: its light, what it was asked to do and for how long, | |
| 212 | + /// its kind and totals, and while it works, what it's doing now. | |
| 213 | + fn render_subagent(&self, subagent: Subagent, other: bool, p: &Palette, cx: &mut Context<Self>) -> AnyElement { | |
| 214 | + let running = subagent.status == TaskStatus::Running; | |
| 215 | + let (led, status) = match subagent.status { | |
| 216 | + TaskStatus::Running => (Led::Live, None), | |
| 217 | + TaskStatus::Completed => (Led::Off, None), | |
| 218 | + TaskStatus::Failed => (Led::Failed, Some("Failed")), | |
| 219 | + TaskStatus::Stopped => (Led::Off, Some("Stopped")), | |
| 220 | + }; | |
| 221 | + let id = (subagent.session.entity_id().as_u64() as usize) << 20 | subagent.ix; | |
| 222 | + let thread = other.then(|| subagent.session.read(cx).title.clone()); | |
| 223 | + let meta: Vec<String> = [ | |
| 224 | + subagent.kind.as_ref().map(ToString::to_string), | |
| 225 | + (subagent.tool_uses > 0) | |
| 226 | + .then(|| format!("{} {}", subagent.tool_uses, if subagent.tool_uses == 1 { "tool" } else { "tools" })), | |
| 227 | + subagent.tokens.filter(|tokens| *tokens > 0).map(format_tokens), | |
| 228 | + subagent.background.then(|| "background".to_string()), | |
| 229 | + ] | |
| 230 | + .into_iter() | |
| 231 | + .flatten() | |
| 232 | + .collect(); | |
| 233 | + let (session, ix) = (subagent.session.clone(), subagent.ix); | |
| 234 | + h_flex() | |
| 235 | + .id(("subagent", id)) | |
| 236 | + .items_start() | |
| 237 | + .gap(px(10.)) | |
| 238 | + .px(px(10.)) | |
| 239 | + .py(px(7.)) | |
| 240 | + .rounded(px(12.)) | |
| 241 | + .cursor_pointer() | |
| 242 | + .hover(|style| style.bg(p.row_hover)) | |
| 243 | + .on_click(cx.listener(move |this, _, window, cx| this.reveal_subagent(session.clone(), ix, window, cx))) | |
| 244 | + .child(div().flex_none().h(px(18.)).flex().items_center().child(metal::led(("subagent-led", id), led, running, p))) | |
| 245 | + .child( | |
| 246 | + v_flex() | |
| 247 | + .flex_1() | |
| 248 | + .min_w_0() | |
| 249 | + .gap(px(2.)) | |
| 250 | + .child( | |
| 251 | + h_flex() | |
| 252 | + .gap(px(8.)) | |
| 253 | + .child( | |
| 254 | + div() | |
| 255 | + .flex_1() | |
| 256 | + .min_w_0() | |
| 257 | + .truncate() | |
| 258 | + .text_size(px(13.)) | |
| 259 | + .text_color(if running { p.ink } else { p.ink2 }) | |
| 260 | + .child(subagent.description.clone()), | |
| 261 | + ) | |
| 262 | + .when_some(status, |this, status| { | |
| 263 | + this.child( | |
| 264 | + div() | |
| 265 | + .flex_none() | |
| 266 | + .text_size(px(11.5)) | |
| 267 | + .text_color(if subagent.status == TaskStatus::Failed { p.red } else { p.ink3 }) | |
| 268 | + .child(status), | |
| 269 | + ) | |
| 270 | + }) | |
| 271 | + .when_some(subagent.elapsed, |this, elapsed| { | |
| 272 | + this.child( | |
| 273 | + div() | |
| 274 | + .flex_none() | |
| 275 | + .font_family(metal::MONO) | |
| 276 | + .text_size(px(11.)) | |
| 277 | + .text_color(p.ink3) | |
| 278 | + .child(format_duration(elapsed.as_millis() as u64)), | |
| 279 | + ) | |
| 280 | + }), | |
| 281 | + ) | |
| 282 | + .when_some(thread, |this, thread| { | |
| 283 | + this.child(div().truncate().text_size(px(11.5)).text_color(p.ink3).child(thread)) | |
| 284 | + }) | |
| 285 | + .when_some(subagent.activity, |this, (icon, label)| { | |
| 286 | + this.child( | |
| 287 | + h_flex() | |
| 288 | + .gap(px(6.)) | |
| 289 | + .text_size(px(12.)) | |
| 290 | + .text_color(p.ink2) | |
| 291 | + .child(icon.size(px(13.)).text_color(p.ink3)) | |
| 292 | + .child(div().flex_1().min_w_0().truncate().child(label)), | |
| 293 | + ) | |
| 294 | + }) | |
| 295 | + .when_some(subagent.summary.filter(|_| running), |this, summary| { | |
| 296 | + this.child(div().text_size(px(12.)).text_color(p.ink3).line_clamp(2).child(summary)) | |
| 297 | + }) | |
| 298 | + .when(!meta.is_empty(), |this| { | |
| 299 | + this.child( | |
| 300 | + div() | |
| 301 | + .font_family(metal::MONO) | |
| 302 | + .text_size(px(10.5)) | |
| 303 | + .text_color(p.ink3) | |
| 304 | + .child(meta.join(" · ")), | |
| 305 | + ) | |
| 306 | + }), | |
| 307 | + ) | |
| 308 | + .into_any_element() | |
| 309 | + } | |
| 310 | + } | |
| 311 | + | |
| 312 | + #[cfg(test)] | |
| 313 | + mod tests { | |
| 314 | + use super::format_tokens; | |
| 315 | + | |
| 316 | + #[::core::prelude::v1::test] | |
| 317 | + fn token_counts() { | |
| 318 | + assert_eq!(format_tokens(812), "812 tokens"); | |
| 319 | + assert_eq!(format_tokens(23_412), "23.4k tokens"); | |
| 320 | + assert_eq!(format_tokens(40_000), "40k tokens"); | |
| 321 | + assert_eq!(format_tokens(1_340_000), "1.3M tokens"); | |
| 322 | + } | |
| 323 | + } | |