fix(tui/tools): group agent and browser tool cells

This commit is contained in:
James Peter
2026-07-31 20:35:35 -07:00
committed by zeekay
parent 20bf31cfd0
commit f9b95602b9
14 changed files with 2344 additions and 8 deletions
+117 -7
View File
@@ -73,6 +73,8 @@ mod streaming;
mod terminal_handlers;
mod terminal;
mod tools;
mod browser_sessions;
mod agent_runs;
mod running_tools;
#[cfg(any(test, feature = "test-helpers"))]
pub mod smoke_helpers;
@@ -3764,6 +3766,16 @@ impl ChatWidget<'_> {
running_web_search: HashMap::new(),
running_wait_tools: HashMap::new(),
running_kill_tools: HashMap::new(),
browser_sessions: HashMap::new(),
browser_session_by_call: HashMap::new(),
browser_session_by_order: HashMap::new(),
browser_last_key: None,
agent_runs: HashMap::new(),
agent_run_by_call: HashMap::new(),
agent_run_by_order: HashMap::new(),
agent_run_by_batch: HashMap::new(),
agent_run_by_agent: HashMap::new(),
agent_last_key: None,
},
live_builder: RowBuilder::new(usize::MAX),
header_wave: {
@@ -9465,7 +9477,34 @@ impl ChatWidget<'_> {
// Flush any queued interrupts when streaming ends
self.flush_interrupt_queue();
// Show an active entry immediately for all custom tools so the user sees progress
let params_string = parameters.map(|p| p.to_string());
let params_json = parameters.clone();
let params_string = params_json.clone().map(|p| p.to_string());
if agent_runs::is_agent_tool(&tool_name) {
if agent_runs::handle_custom_tool_begin(
self,
event.order.as_ref(),
&call_id,
&tool_name,
params_json.clone(),
) {
self.bottom_pane
.update_status_text("agents coordinating".to_string());
return;
}
}
if tool_name.starts_with("browser_") {
if browser_sessions::handle_custom_tool_begin(
self,
event.order.as_ref(),
&call_id,
&tool_name,
params_json.clone(),
) {
self.bottom_pane
.update_status_text("using browser".to_string());
return;
}
}
if tool_name == "wait" {
if let Some(exec_call_id) =
wait_exec_call_id_from_params(params_string.as_ref())
@@ -9524,6 +9563,11 @@ impl ChatWidget<'_> {
tool_name.clone(),
params_string.clone(),
)
} else if tool_name.starts_with("agent_") {
history_cell::new_running_custom_tool_call(
tool_name.clone(),
params_string.clone(),
)
} else {
history_cell::new_running_custom_tool_call(
tool_name.clone(),
@@ -9560,7 +9604,7 @@ impl ChatWidget<'_> {
if tool_name.starts_with("browser_") {
self.bottom_pane
.update_status_text("using browser".to_string());
} else if tool_name.starts_with("agent_") {
} else if agent_runs::is_agent_tool(&tool_name) {
self.bottom_pane
.update_status_text("agents coordinating".to_string());
} else {
@@ -9575,6 +9619,42 @@ impl ChatWidget<'_> {
duration,
result,
}) => {
let params_json = parameters.clone();
if agent_runs::is_agent_tool(&tool_name) {
if agent_runs::handle_custom_tool_end(
self,
event.order.as_ref(),
&call_id,
&tool_name,
params_json.clone(),
duration,
&result,
) {
self.bottom_pane
.update_status_text("responding".to_string());
return;
}
}
if tool_name.starts_with("browser_") {
if browser_sessions::handle_custom_tool_end(
self,
event.order.as_ref(),
&call_id,
&tool_name,
params_json.clone(),
duration,
&result,
) {
if tool_name == "browser_close" {
self.bottom_pane
.update_status_text("responding".to_string());
} else {
self.bottom_pane
.update_status_text("using browser".to_string());
}
return;
}
}
let ok = match event.order.as_ref() {
Some(om) => self.provider_order_key_from_order_meta(om),
None => {
@@ -9591,7 +9671,7 @@ impl ChatWidget<'_> {
event.event_seq
);
// Convert parameters to String if present
let params_string = parameters.map(|p| p.to_string());
let params_string = params_json.map(|p| p.to_string());
// Determine success and content from Result
let (success, content) = match result {
Ok(content) => (true, content),
@@ -9900,6 +9980,13 @@ impl ChatWidget<'_> {
}
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
info!("BackgroundEvent: {message}");
if browser_sessions::handle_background_event(
self,
event.order.as_ref(),
&message,
) {
return;
}
self.clear_resume_placeholder();
// Route through unified system notice helper. If the core ties the
// event to a turn (order present), prefer placing it before the next
@@ -9938,7 +10025,9 @@ impl ChatWidget<'_> {
self.recent_agent_hint = Some(message);
}
}
EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent { agents, context, task }) => {
EventMsg::AgentStatusUpdate(event) => {
agent_runs::handle_status_update(self, &event);
let AgentStatusUpdateEvent { agents, context, task } = event;
// Update the active agents list from the event and track timing
self.active_agents.clear();
let now = Instant::now();
@@ -10041,11 +10130,17 @@ impl ChatWidget<'_> {
self.maybe_hide_spinner();
self.request_redraw();
}
EventMsg::BrowserScreenshotUpdate(event) => {
EventMsg::BrowserScreenshotUpdate(payload) => {
#[cfg(feature = "code-fork")]
handle_browser_screenshot(&event, &self.app_event_tx);
handle_browser_screenshot(&payload, &self.app_event_tx);
let BrowserScreenshotUpdateEvent { screenshot_path, url } = event;
let BrowserScreenshotUpdateEvent { screenshot_path, url } = payload;
let grouped = browser_sessions::handle_screenshot_update(
self,
event.order.as_ref(),
&screenshot_path,
&url,
);
tracing::info!(
"Received browser screenshot update: {} at URL: {}",
screenshot_path.display(),
@@ -10070,6 +10165,11 @@ impl ChatWidget<'_> {
// Request a redraw to update the display immediately
self.app_event_tx.send(AppEvent::RequestRedraw);
if grouped {
self.bottom_pane
.update_status_text("using browser".to_string());
}
}
// Newer protocol variants we currently ignore in the TUI
EventMsg::UserMessage(_) => {}
@@ -25756,6 +25856,16 @@ struct ToolState {
running_web_search: HashMap<ToolCallId, (usize, Option<String>)>,
running_wait_tools: HashMap<ToolCallId, ExecCallId>,
running_kill_tools: HashMap<ToolCallId, ExecCallId>,
browser_sessions: HashMap<String, browser_sessions::BrowserSessionTracker>,
browser_session_by_call: HashMap<String, String>,
browser_session_by_order: HashMap<u64, String>,
browser_last_key: Option<String>,
agent_runs: HashMap<String, agent_runs::AgentRunTracker>,
agent_run_by_call: HashMap<String, String>,
agent_run_by_order: HashMap<u64, String>,
agent_run_by_batch: HashMap<String, String>,
agent_run_by_agent: HashMap<String, String>,
agent_last_key: Option<String>,
}
#[derive(Default)]
struct StreamState {
+709
View File
@@ -0,0 +1,709 @@
use super::{ChatWidget, OrderKey};
use crate::history::state::HistoryId;
use crate::history_cell::AgentRunCell;
use code_core::protocol::{AgentStatusUpdateEvent, OrderMeta};
use serde_json::Value;
use std::collections::HashSet;
use std::time::Duration;
const AGENT_TOOL_NAMES: &[&str] = &[
"agent",
"agent_run",
"agent_result",
"agent_wait",
"agent_cancel",
"agent_check",
"agent_list",
];
pub(super) fn is_agent_tool(tool_name: &str) -> bool {
AGENT_TOOL_NAMES
.iter()
.copied()
.any(|name| name.eq_ignore_ascii_case(tool_name))
}
fn is_primary_run_tool(tool_name: &str) -> bool {
matches!(tool_name, "agent" | "agent_run")
}
fn format_elapsed_short(duration: Duration) -> String {
let secs = duration.as_secs();
let minutes = secs / 60;
let seconds = secs % 60;
if minutes > 0 {
format!("{}m{:02}s", minutes, seconds)
} else {
format!("{:02}s", seconds)
}
}
fn begin_action_for(tool_name: &str, metadata: &InvocationMetadata) -> Option<String> {
let label = metadata
.label
.clone()
.or_else(|| metadata.agent_ids.clone().into_iter().next())
.unwrap_or_else(|| "agent".to_string());
match tool_name {
"agent" | "agent_run" => Some(format!("Started agent run for {}", label)),
"agent_wait" => metadata
.batch_id
.as_ref()
.map(|batch| format!("Waiting for agents in batch {}", batch)),
"agent_result" => Some(format!("Requested results for {}", label)),
"agent_cancel" => Some(format!("Cancelling agent batch for {}", label)),
"agent_check" => Some(format!("Checking agent status for {}", label)),
"agent_list" => Some("Listing available agents".to_string()),
_ => None,
}
}
fn end_action_for(
tool_name: &str,
duration: Duration,
success: bool,
message: Option<&str>,
) -> Option<String> {
let elapsed = format_elapsed_short(duration);
match tool_name {
"agent" | "agent_run" => {
if success {
Some(format!("Agent run completed in {}", elapsed))
} else {
let detail = message.unwrap_or("unknown error");
Some(format!("Agent run failed in {} — {}", elapsed, detail))
}
}
"agent_wait" => {
if success {
Some(format!("Finished waiting in {}", elapsed))
} else {
let detail = message.unwrap_or("wait failed");
Some(format!("Wait failed in {} — {}", elapsed, detail))
}
}
"agent_result" => {
if success {
Some(format!("Fetched agent results in {}", elapsed))
} else {
let detail = message.unwrap_or("result error");
Some(format!("Result fetch failed in {} — {}", elapsed, detail))
}
}
"agent_cancel" => Some(format!("Cancel request completed in {}", elapsed)),
"agent_check" => Some(format!("Status check finished in {}", elapsed)),
"agent_list" => Some("Listed agents".to_string()),
_ => None,
}
}
pub(super) struct AgentRunTracker {
pub order_key: OrderKey,
pub cell_index: Option<usize>,
pub history_id: Option<HistoryId>,
pub cell: AgentRunCell,
pub batch_id: Option<String>,
agent_ids: HashSet<String>,
task: Option<String>,
has_custom_name: bool,
call_ids: HashSet<String>,
}
impl AgentRunTracker {
pub fn new(order_key: OrderKey) -> Self {
Self {
order_key,
cell_index: None,
history_id: None,
cell: AgentRunCell::new("(pending)".to_string()),
batch_id: None,
agent_ids: HashSet::new(),
task: None,
has_custom_name: false,
call_ids: HashSet::new(),
}
}
fn merge_agent_ids<I>(&mut self, ids: I)
where
I: IntoIterator<Item = String>,
{
for id in ids {
self.agent_ids.insert(id);
}
}
fn set_task(&mut self, task: Option<String>) {
if let Some(value) = task {
self.task = Some(value);
}
self.cell.set_task(self.task.clone());
}
fn set_agent_name(&mut self, name: Option<String>, override_existing: bool) {
if let Some(name) = name {
if override_existing || !self.has_custom_name {
self.cell.set_agent_name(name);
self.has_custom_name = true;
}
}
}
}
#[derive(Default)]
struct InvocationMetadata {
batch_id: Option<String>,
agent_ids: Vec<String>,
task: Option<String>,
plan: Vec<String>,
label: Option<String>,
}
impl InvocationMetadata {
fn from(tool_name: &str, params: Option<&Value>) -> Self {
let mut meta = InvocationMetadata::default();
if let Some(Value::Object(map)) = params {
if let Some(batch) = map.get("batch_id").and_then(|v| v.as_str()) {
meta.batch_id = Some(batch.to_string());
}
if let Some(agent_id) = map.get("agent_id").and_then(|v| v.as_str()) {
meta.agent_ids.push(agent_id.to_string());
}
if let Some(agent_name) = map.get("agent_name").and_then(|v| v.as_str()) {
meta.label = Some(agent_name.to_string());
}
if let Some(task) = map.get("task").and_then(|v| v.as_str()) {
meta.task = Some(task.to_string());
}
if let Some(plan) = map.get("plan").and_then(|v| v.as_array()) {
meta.plan = plan
.iter()
.filter_map(|step| step.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(models) = map.get("models").and_then(|v| v.as_array()) {
for model in models {
if let Some(name) = model.as_str() {
meta.agent_ids.push(name.to_string());
}
}
}
if let Some(agents) = map.get("agents").and_then(|v| v.as_array()) {
for entry in agents {
if let Some(obj) = entry.as_object() {
if let Some(id) = obj.get("id").and_then(|v| v.as_str()) {
meta.agent_ids.push(id.to_string());
}
if meta.label.is_none() {
if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
meta.label = Some(name.to_string());
}
}
}
}
}
}
if meta.label.is_none() {
if let Some(first) = meta.agent_ids.first() {
meta.label = Some(first.clone());
}
}
meta.agent_ids = dedup(meta.agent_ids);
if meta.plan.is_empty() && is_primary_run_tool(tool_name) {
// Leave plan empty; the UI will render a placeholder.
}
meta
}
}
pub(super) fn handle_custom_tool_begin(
chat: &mut ChatWidget<'_>,
order: Option<&OrderMeta>,
call_id: &str,
tool_name: &str,
params: Option<Value>,
) -> bool {
if !is_agent_tool(tool_name) {
return false;
}
let metadata = InvocationMetadata::from(tool_name, params.as_ref());
let (order_key, ordinal) = order_key_and_ordinal(chat, order);
let mut key = agent_key(order, call_id, tool_name, &metadata);
let mut tracker = chat
.tools_state
.agent_runs
.remove(&key)
.unwrap_or_else(|| AgentRunTracker::new(order_key));
tracker.order_key = order_key;
ensure_agent_cell(chat, &mut tracker);
if let Some(batch) = metadata.batch_id.clone() {
tracker.batch_id.get_or_insert(batch);
}
tracker.merge_agent_ids(metadata.agent_ids.clone());
tracker.set_agent_name(metadata.label.clone(), true);
if !metadata.plan.is_empty() {
tracker.cell.set_plan(metadata.plan.clone());
}
tracker.set_task(metadata.task.clone());
if let Some(action) = begin_action_for(tool_name, &metadata) {
tracker.cell.record_action(action);
}
replace_agent_cell(chat, &mut tracker);
key = update_mappings(
chat,
key,
order,
Some(call_id),
ordinal,
tool_name,
&mut tracker,
);
chat.tools_state.agent_last_key = Some(key.clone());
chat.tools_state.agent_runs.insert(key, tracker);
true
}
pub(super) fn handle_custom_tool_end(
chat: &mut ChatWidget<'_>,
order: Option<&OrderMeta>,
call_id: &str,
tool_name: &str,
params: Option<Value>,
duration: Duration,
result: &Result<String, String>,
) -> bool {
if !is_agent_tool(tool_name) {
return false;
}
let metadata = InvocationMetadata::from(tool_name, params.as_ref());
let ordinal = order.map(|m| m.request_ordinal);
let mut key = lookup_key(chat, order, call_id)
.unwrap_or_else(|| agent_key(order, call_id, tool_name, &metadata));
let mut tracker = match chat.tools_state.agent_runs.remove(&key) {
Some(existing) => existing,
None => return false,
};
ensure_agent_cell(chat, &mut tracker);
if let Some(batch) = metadata.batch_id.clone() {
tracker.batch_id.get_or_insert(batch);
}
tracker.merge_agent_ids(metadata.agent_ids.clone());
tracker.set_agent_name(metadata.label.clone(), true);
if !metadata.plan.is_empty() {
tracker.cell.set_plan(metadata.plan.clone());
}
tracker.set_task(metadata.task.clone());
tracker.cell.set_duration(Some(duration));
match result {
Ok(text) => {
let lines = lines_from(text);
if !lines.is_empty() {
tracker.cell.set_latest_result(lines);
}
tracker.cell.set_status_label("Completed");
tracker.cell.mark_completed();
if let Some(action) = end_action_for(tool_name, duration, true, Some(text.as_str())) {
tracker.cell.record_action(action);
}
}
Err(err) => {
tracker.cell.set_latest_result(vec![err.clone()]);
tracker.cell.mark_failed();
if let Some(action) = end_action_for(tool_name, duration, false, Some(err.as_str())) {
tracker.cell.record_action(action);
}
}
}
replace_agent_cell(chat, &mut tracker);
key = update_mappings(
chat,
key,
order,
Some(call_id),
ordinal,
tool_name,
&mut tracker,
);
chat.tools_state.agent_last_key = Some(key.clone());
chat.tools_state.agent_runs.insert(key, tracker);
true
}
pub(super) fn handle_status_update(chat: &mut ChatWidget<'_>, event: &AgentStatusUpdateEvent) {
if chat.tools_state.agent_runs.is_empty() {
return;
}
let mut candidate_keys: Vec<String> = Vec::new();
for agent in &event.agents {
if let Some(batch_id) = agent.batch_id.as_ref() {
if let Some(key) = chat.tools_state.agent_run_by_batch.get(batch_id) {
candidate_keys.push(key.clone());
}
}
if let Some(key) = chat.tools_state.agent_run_by_agent.get(&agent.id) {
candidate_keys.push(key.clone());
}
}
if candidate_keys.is_empty() {
if let Some(task) = event.task.as_ref() {
if let Some((key, _)) = chat
.tools_state
.agent_runs
.iter()
.find(|(_, tracker)| tracker.task.as_ref() == Some(task))
{
candidate_keys.push(key.clone());
}
}
}
if candidate_keys.is_empty() {
if let Some(last) = chat.tools_state.agent_last_key.clone() {
candidate_keys.push(last);
}
}
candidate_keys = dedup(candidate_keys);
for key in candidate_keys {
let mut tracker = match chat.tools_state.agent_runs.remove(&key) {
Some(existing) => existing,
None => continue,
};
let mut current_key = key;
ensure_agent_cell(chat, &mut tracker);
if let Some(task) = event.task.clone() {
tracker.set_task(Some(task));
} else {
tracker.set_task(tracker.task.clone());
}
let mut rows: Vec<(String, String)> = Vec::new();
let mut status_collect = StatusSummary::default();
let mut latest_lines: Option<Vec<String>> = None;
for agent in &event.agents {
let mut status_text = agent.status.clone();
if let Some(progress) = agent.last_progress.as_ref() {
if !progress.trim().is_empty() {
status_text = format!("{} — {}", status_text, progress);
}
}
if let Some(result) = agent.result.as_ref() {
if !result.trim().is_empty() {
status_text = format!("{} (result)", status_text);
}
}
if let Some(error) = agent.error.as_ref() {
if !error.trim().is_empty() {
status_text = format!("{} (error)", status_text);
}
}
rows.push((agent.name.clone(), status_text.clone()));
tracker.agent_ids.insert(agent.id.clone());
if let Some(batch_id) = agent.batch_id.as_ref() {
tracker.batch_id.get_or_insert(batch_id.clone());
}
let phase = classify_status(&agent.status, agent.result.is_some(), agent.error.is_some());
status_collect.observe(phase);
if let Some(result) = agent.result.as_ref() {
latest_lines = Some(lines_from(result));
} else if let Some(error) = agent.error.as_ref() {
latest_lines = Some(lines_from(error));
}
tracker.set_agent_name(Some(agent.name.clone()), false);
}
if let Some(lines) = latest_lines {
if !lines.is_empty() {
tracker.cell.set_latest_result(lines);
}
}
tracker.cell.set_status_rows(rows.clone());
status_collect.apply(&mut tracker.cell);
if !rows.is_empty() {
let summary = rows
.iter()
.map(|(name, status)| format!("{}: {}", name, status))
.collect::<Vec<_>>()
.join("; ");
tracker
.cell
.record_action(format!("Status update — {}", summary));
}
replace_agent_cell(chat, &mut tracker);
current_key = update_mappings(
chat,
current_key,
None,
None,
None,
"agent_status",
&mut tracker,
);
chat.tools_state.agent_last_key = Some(current_key.clone());
chat.tools_state.agent_runs.insert(current_key, tracker);
}
}
fn ensure_agent_cell(chat: &mut ChatWidget<'_>, tracker: &mut AgentRunTracker) -> Option<usize> {
if let Some(id) = tracker.history_id {
if let Some(idx) = chat.cell_index_for_history_id(id) {
tracker.cell_index = Some(idx);
return Some(idx);
}
}
if let Some(idx) = tracker
.cell_index
.and_then(|idx| if idx < chat.history_cells.len() { Some(idx) } else { None })
{
return Some(idx);
}
let idx = chat.history_insert_with_key_global(Box::new(tracker.cell.clone()), tracker.order_key);
tracker.cell_index = Some(idx);
tracker.history_id = chat.history_cell_ids.get(idx).and_then(|slot| *slot);
Some(idx)
}
fn replace_agent_cell(chat: &mut ChatWidget<'_>, tracker: &mut AgentRunTracker) {
if let Some(idx) = ensure_agent_cell(chat, tracker) {
chat.history_replace_at(idx, Box::new(tracker.cell.clone()));
if let Some(id) = chat.history_cell_ids.get(idx).and_then(|slot| *slot) {
tracker.history_id = Some(id);
}
}
}
fn order_key_and_ordinal(chat: &mut ChatWidget<'_>, order: Option<&OrderMeta>) -> (OrderKey, Option<u64>) {
match order {
Some(meta) => (chat.provider_order_key_from_order_meta(meta), Some(meta.request_ordinal)),
None => (chat.next_internal_key(), None),
}
}
fn agent_key(
order: Option<&OrderMeta>,
call_id: &str,
tool_name: &str,
metadata: &InvocationMetadata,
) -> String {
if let Some(batch) = metadata.batch_id.as_ref() {
return format!("batch:{}", batch);
}
if is_primary_run_tool(tool_name) {
if let Some(meta) = order {
return format!("req:{}:agent-run", meta.request_ordinal);
}
}
if let Some(first) = metadata.agent_ids.first() {
return format!("agent:{}", first);
}
if let Some(meta) = order {
return format!("req:{}:{}", meta.request_ordinal, call_id);
}
format!("call:{}", call_id)
}
fn lookup_key(chat: &mut ChatWidget<'_>, order: Option<&OrderMeta>, call_id: &str) -> Option<String> {
chat
.tools_state
.agent_run_by_call
.remove(call_id)
.or_else(|| order.and_then(|meta| chat.tools_state.agent_run_by_order.get(&meta.request_ordinal).cloned()))
.or_else(|| chat.tools_state.agent_last_key.clone())
}
fn update_mappings(
chat: &mut ChatWidget<'_>,
mut key: String,
order: Option<&OrderMeta>,
call_id: Option<&str>,
ordinal: Option<u64>,
tool_name: &str,
tracker: &mut AgentRunTracker,
) -> String {
let original_key = key.clone();
if let Some(batch) = tracker.batch_id.as_ref() {
let batch_key = format!("batch:{}", batch);
if batch_key != key {
key = batch_key;
}
}
if is_primary_run_tool(tool_name) {
if let Some(ord) = ordinal {
let ord_key = format!("req:{}:agent-run", ord);
if ord_key != key {
key = ord_key;
}
}
}
if let Some(cid) = call_id {
tracker.call_ids.insert(cid.to_string());
chat
.tools_state
.agent_run_by_call
.insert(cid.to_string(), key.clone());
}
if let Some(meta) = order {
chat
.tools_state
.agent_run_by_order
.insert(meta.request_ordinal, key.clone());
}
if let Some(batch) = tracker.batch_id.as_ref() {
chat
.tools_state
.agent_run_by_batch
.insert(batch.clone(), key.clone());
}
for agent_id in &tracker.agent_ids {
chat
.tools_state
.agent_run_by_agent
.insert(agent_id.clone(), key.clone());
}
if key != original_key {
for cid in &tracker.call_ids {
chat
.tools_state
.agent_run_by_call
.insert(cid.clone(), key.clone());
}
}
key
}
#[derive(Default)]
struct StatusSummary {
any_failed: bool,
any_cancelled: bool,
any_running: bool,
any_pending: bool,
total: usize,
completed: usize,
}
impl StatusSummary {
fn observe(&mut self, phase: AgentPhase) {
self.total += 1;
match phase {
AgentPhase::Failed => {
self.any_failed = true;
}
AgentPhase::Cancelled => {
self.any_cancelled = true;
}
AgentPhase::Running => {
self.any_running = true;
}
AgentPhase::Pending => {
self.any_pending = true;
}
AgentPhase::Completed => {
self.completed += 1;
}
}
}
fn apply(self, cell: &mut AgentRunCell) {
if self.any_failed {
cell.mark_failed();
return;
}
if self.any_cancelled {
cell.set_status_label("Cancelled");
cell.mark_completed();
return;
}
if self.total > 0 && self.completed == self.total {
cell.set_status_label("Completed");
cell.mark_completed();
return;
}
if self.any_running {
cell.set_status_label("Running");
return;
}
if self.any_pending {
cell.set_status_label("Pending");
return;
}
cell.set_status_label("Running");
}
}
#[derive(Clone, Copy)]
enum AgentPhase {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
fn classify_status(status: &str, has_result: bool, has_error: bool) -> AgentPhase {
if has_error {
return AgentPhase::Failed;
}
if has_result {
return AgentPhase::Completed;
}
let token = status
.split_whitespace()
.next()
.unwrap_or(status)
.to_ascii_lowercase();
match token.as_str() {
"failed" | "error" | "errored" => AgentPhase::Failed,
"cancelled" | "canceled" => AgentPhase::Cancelled,
"completed" | "complete" | "done" | "success" | "succeeded" => AgentPhase::Completed,
"pending" | "queued" | "waiting" | "starting" => AgentPhase::Pending,
_ => AgentPhase::Running,
}
}
fn lines_from(input: &str) -> Vec<String> {
input.lines().map(|line| line.to_string()).collect()
}
fn dedup(mut values: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
values.retain(|value| seen.insert(value.clone()));
values
}
@@ -0,0 +1,356 @@
use super::{ChatWidget, OrderKey};
use crate::history_cell::BrowserSessionCell;
use crate::history::state::HistoryId;
use code_core::protocol::OrderMeta;
use serde_json::Value;
use std::path::PathBuf;
use std::time::Duration;
pub(super) struct BrowserSessionTracker {
pub order_key: OrderKey,
pub cell_index: Option<usize>,
pub history_id: Option<HistoryId>,
pub cell: BrowserSessionCell,
pub elapsed: Duration,
}
impl BrowserSessionTracker {
fn new(order_key: OrderKey) -> Self {
Self {
order_key,
cell_index: None,
history_id: None,
cell: BrowserSessionCell::new(),
elapsed: Duration::default(),
}
}
}
pub(super) fn handle_custom_tool_begin(
chat: &mut ChatWidget<'_>,
order: Option<&OrderMeta>,
call_id: &str,
tool_name: &str,
params: Option<Value>,
) -> bool {
if !tool_name.starts_with("browser_") {
return false;
}
let (order_key, ordinal) = order_key_and_ordinal(chat, order);
let key = select_session_key(chat, order, call_id, tool_name);
let mut tracker = chat
.tools_state
.browser_sessions
.remove(&key)
.unwrap_or_else(|| BrowserSessionTracker::new(order_key));
tracker.order_key = order_key;
ensure_session_cell(chat, &mut tracker);
if let Some(Value::Object(json)) = params.as_ref() {
if tool_name == "browser_open" {
if let Some(url) = json.get("url").and_then(|v| v.as_str()) {
tracker.cell.set_url(url.to_string());
}
}
}
chat
.tools_state
.browser_session_by_call
.insert(call_id.to_string(), key.clone());
if let Some(ord) = ordinal {
chat
.tools_state
.browser_session_by_order
.insert(ord, key.clone());
}
chat.tools_state.browser_last_key = Some(key.clone());
chat
.tools_state
.browser_sessions
.insert(key, tracker);
true
}
pub(super) fn handle_custom_tool_end(
chat: &mut ChatWidget<'_>,
order: Option<&OrderMeta>,
call_id: &str,
tool_name: &str,
params: Option<Value>,
duration: Duration,
result: &Result<String, String>,
) -> bool {
if !tool_name.starts_with("browser_") {
return false;
}
let key = chat
.tools_state
.browser_session_by_call
.remove(call_id)
.or_else(|| order.and_then(|meta| chat.tools_state.browser_session_by_order.get(&meta.request_ordinal).cloned()))
.unwrap_or_else(|| browser_key(order, call_id));
let mut tracker = match chat.tools_state.browser_sessions.remove(&key) {
Some(tracker) => tracker,
None => return false,
};
ensure_session_cell(chat, &mut tracker);
let params_to_use = params.as_ref();
if tool_name == "browser_open" {
if let Some(Value::Object(json)) = params_to_use {
if let Some(url) = json.get("url").and_then(|v| v.as_str()) {
tracker.cell.set_url(url.to_string());
}
}
}
let summary = summarize_action(tool_name, params_to_use, result);
let timestamp = tracker.elapsed;
tracker
.cell
.record_action(timestamp, duration, summary);
tracker.elapsed = tracker.elapsed.saturating_add(duration);
replace_session_cell(chat, &mut tracker);
if let Some(ord) = order.map(|m| m.request_ordinal) {
chat
.tools_state
.browser_session_by_order
.insert(ord, key.clone());
}
chat
.tools_state
.browser_sessions
.insert(key, tracker);
true
}
pub(super) fn handle_background_event(
chat: &mut ChatWidget<'_>,
order: Option<&OrderMeta>,
message: &str,
) -> bool {
if chat.tools_state.browser_sessions.is_empty() {
return false;
}
let key = key_from_order_or_last(chat, order);
let Some(key) = key else { return false; };
let mut tracker = match chat.tools_state.browser_sessions.remove(&key) {
Some(tracker) => tracker,
None => return false,
};
ensure_session_cell(chat, &mut tracker);
let console_line = if message.starts_with("⚠️") {
message.to_string()
} else {
format!("⚠️ {}", message)
};
tracker.cell.add_console_message(console_line);
replace_session_cell(chat, &mut tracker);
chat
.tools_state
.browser_sessions
.insert(key, tracker);
true
}
pub(super) fn handle_screenshot_update(
chat: &mut ChatWidget<'_>,
order: Option<&OrderMeta>,
screenshot_path: &PathBuf,
url: &str,
) -> bool {
if chat.tools_state.browser_sessions.is_empty() {
return false;
}
let key = key_from_order_or_last(chat, order);
let Some(key) = key else { return false; };
let mut tracker = match chat.tools_state.browser_sessions.remove(&key) {
Some(tracker) => tracker,
None => return false,
};
ensure_session_cell(chat, &mut tracker);
tracker.cell.set_url(url.to_string());
tracker.cell.set_screenshot(screenshot_path.clone());
replace_session_cell(chat, &mut tracker);
chat
.tools_state
.browser_sessions
.insert(key, tracker);
true
}
fn ensure_session_cell(
chat: &mut ChatWidget<'_>,
tracker: &mut BrowserSessionTracker,
) -> Option<usize> {
if let Some(id) = tracker.history_id {
if let Some(idx) = chat.cell_index_for_history_id(id) {
tracker.cell_index = Some(idx);
return Some(idx);
}
}
if let Some(idx) = tracker
.cell_index
.and_then(|idx| if idx < chat.history_cells.len() { Some(idx) } else { None })
{
return Some(idx);
}
let idx = chat.history_insert_with_key_global(Box::new(tracker.cell.clone()), tracker.order_key);
tracker.cell_index = Some(idx);
tracker.history_id = chat.history_cell_ids.get(idx).and_then(|slot| *slot);
Some(idx)
}
fn replace_session_cell(chat: &mut ChatWidget<'_>, tracker: &mut BrowserSessionTracker) {
if let Some(idx) = ensure_session_cell(chat, tracker) {
chat.history_replace_at(idx, Box::new(tracker.cell.clone()));
if let Some(id) = chat.history_cell_ids.get(idx).and_then(|slot| *slot) {
tracker.history_id = Some(id);
}
}
}
fn order_key_and_ordinal(chat: &mut ChatWidget<'_>, order: Option<&OrderMeta>) -> (OrderKey, Option<u64>) {
match order {
Some(meta) => (chat.provider_order_key_from_order_meta(meta), Some(meta.request_ordinal)),
None => (chat.next_internal_key(), None),
}
}
fn browser_key(order: Option<&OrderMeta>, call_id: &str) -> String {
if let Some(meta) = order {
format!("req:{}:{}", meta.request_ordinal, call_id)
} else {
format!("call:{}", call_id)
}
}
fn select_session_key(
chat: &mut ChatWidget<'_>,
order: Option<&OrderMeta>,
call_id: &str,
tool_name: &str,
) -> String {
let mut key = browser_key(order, call_id);
if order.is_none() && tool_name != "browser_open" {
if let Some(last) = chat.tools_state.browser_last_key.clone() {
if chat.tools_state.browser_sessions.contains_key(&last) {
key = last;
}
}
}
key
}
fn key_from_order_or_last(
chat: &mut ChatWidget<'_>,
order: Option<&OrderMeta>,
) -> Option<String> {
if let Some(meta) = order {
if let Some(key) = chat
.tools_state
.browser_session_by_order
.get(&meta.request_ordinal)
.cloned()
{
return Some(key);
}
}
chat.tools_state.browser_last_key.clone()
}
fn summarize_action(
tool_name: &str,
params: Option<&Value>,
result: &Result<String, String>,
) -> String {
match tool_name {
"browser_open" => params
.and_then(|value| value.get("url"))
.and_then(|value| value.as_str())
.map(|url| format!("Open URL {}", url))
.unwrap_or_else(|| "Open page".to_string()),
"browser_click" => {
let description = params
.and_then(|value| value.get("description"))
.and_then(|value| value.as_str())
.map(|s| s.to_string());
let selector = params
.and_then(|value| value.get("selector"))
.and_then(|value| value.as_str())
.map(|s| s.to_string());
match (description, selector) {
(Some(desc), Some(sel)) => format!("Click {} ({})", desc, sel),
(Some(desc), None) => format!("Click {}", desc),
(None, Some(sel)) => format!("Click {}", sel),
_ => "Click".to_string(),
}
}
"browser_scroll" => {
let dx = params
.and_then(|value| value.get("dx"))
.and_then(|value| value.as_i64())
.unwrap_or(0);
let dy = params
.and_then(|value| value.get("dy"))
.and_then(|value| value.as_i64())
.unwrap_or(0);
if dx == 0 {
format!("Scroll by dy={}", dy)
} else {
format!("Scroll by dx={} dy={}", dx, dy)
}
}
"browser_type" => {
let text = params
.and_then(|value| value.get("text"))
.and_then(|value| value.as_str())
.unwrap_or("");
let open = '\u{201c}';
let close = '\u{201d}';
format!("Type {}{}{}", open, text, close)
}
"browser_close" => match result {
Ok(_) => "Close browser".to_string(),
Err(err) => format!("Close browser (error: {}...)", truncate(err, 24)),
},
other => other.replace('_', " "),
}
}
fn truncate(input: &str, max: usize) -> String {
if input.chars().count() <= max {
input.to_string()
} else {
let truncated: String = input.chars().take(max).collect();
format!("{}…", truncated)
}
}
@@ -83,10 +83,30 @@ pub(super) fn rehydrate(chat: &mut ChatWidget<'_>) {
let super::ToolState {
running_wait_tools,
running_kill_tools,
browser_sessions,
browser_session_by_call,
browser_session_by_order,
browser_last_key,
agent_runs,
agent_run_by_call,
agent_run_by_order,
agent_run_by_batch,
agent_run_by_agent,
agent_last_key,
..
} = old_state;
new_state.running_wait_tools = running_wait_tools;
new_state.running_kill_tools = running_kill_tools;
new_state.browser_sessions = browser_sessions;
new_state.browser_session_by_call = browser_session_by_call;
new_state.browser_session_by_order = browser_session_by_order;
new_state.browser_last_key = browser_last_key;
new_state.agent_runs = agent_runs;
new_state.agent_run_by_call = agent_run_by_call;
new_state.agent_run_by_order = agent_run_by_order;
new_state.agent_run_by_batch = agent_run_by_batch;
new_state.agent_run_by_agent = agent_run_by_agent;
new_state.agent_last_key = agent_last_key;
chat.tools_state = new_state;
chat.history_debug(format!(
@@ -161,6 +161,26 @@ impl ChatWidgetHarness {
}
}
#[cfg(any(test, feature = "test-helpers"))]
pub fn override_running_tool_elapsed(
&mut self,
call_id: &str,
duration: Duration,
) {
self.flush_into_widget();
for cell in &mut self.chat.history_cells {
if let Some(running) = cell
.as_any_mut()
.downcast_mut::<history_cell::RunningToolCallCell>()
{
if running.state().call_id.as_deref() == Some(call_id) {
running.override_elapsed_for_testing(duration);
break;
}
}
}
}
pub fn push_user_prompt(&mut self, message: impl Into<String>) {
let state = history_cell::new_user_prompt(message.into());
self.chat.history_push_plain_state(state);
+176
View File
@@ -0,0 +1,176 @@
use super::{HistoryCell, HistoryCellType, ToolCellStatus};
use ratatui::text::Line;
use std::time::Duration;
const HEADER_WIDTH: usize = 76;
#[derive(Clone, Default)]
pub(crate) struct AgentRunCell {
agent_name: String,
status_label: String,
task: Option<String>,
duration: Option<Duration>,
plan: Vec<String>,
status_rows: Vec<(String, String)>,
latest_result: Vec<String>,
completed: bool,
actions: Vec<String>,
}
impl AgentRunCell {
pub(crate) fn new(agent_name: String) -> Self {
Self {
agent_name,
status_label: "Running".to_string(),
..Default::default()
}
}
pub(crate) fn set_task(&mut self, task: Option<String>) {
self.task = task;
}
pub(crate) fn set_plan(&mut self, plan: Vec<String>) {
self.plan = plan;
}
pub(crate) fn set_status_rows(&mut self, rows: Vec<(String, String)>) {
self.status_rows = rows;
}
pub(crate) fn set_duration(&mut self, duration: Option<Duration>) {
self.duration = duration;
}
pub(crate) fn set_latest_result(&mut self, lines: Vec<String>) {
self.latest_result = lines;
}
pub(crate) fn mark_completed(&mut self) {
self.completed = true;
}
pub(crate) fn mark_failed(&mut self) {
self.completed = true;
self.status_label = "Failed".to_string();
}
pub(crate) fn set_agent_name(&mut self, name: String) {
self.agent_name = name;
}
pub(crate) fn set_status_label<S: Into<String>>(&mut self, label: S) {
self.status_label = label.into();
}
pub(crate) fn record_action<S: Into<String>>(&mut self, text: S) {
const MAX_ACTIONS: usize = 20;
self.actions.push(text.into());
if self.actions.len() > MAX_ACTIONS {
let overflow = self.actions.len() - MAX_ACTIONS;
self.actions.drain(0..overflow);
}
}
}
impl HistoryCell for AgentRunCell {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn kind(&self) -> HistoryCellType {
let status = if self.completed {
if self.status_label == "Failed" {
ToolCellStatus::Failed
} else {
ToolCellStatus::Success
}
} else {
ToolCellStatus::Running
};
HistoryCellType::Tool { status }
}
fn display_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let header = build_header_content(&self.agent_name, &self.status_label);
lines.push(Line::from(format!("┌{}┐", "─".repeat(HEADER_WIDTH))));
lines.push(Line::from(format!("│{}│", header)));
lines.push(Line::from(format!("└{}┘", "─".repeat(HEADER_WIDTH))));
if let Some(task) = &self.task {
lines.push(Line::from(format!(" Task: {}", task)));
}
if let Some(duration) = self.duration {
lines.push(Line::from(format!(" Duration: {}", format_duration(duration))));
}
lines.push(Line::from(""));
lines.push(Line::from(" Plan"));
if self.plan.is_empty() {
lines.push(Line::from(" (no plan provided)"));
} else {
for step in &self.plan {
lines.push(Line::from(format!(" • {}", step)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(" Status"));
if self.status_rows.is_empty() {
lines.push(Line::from(" (no status updates yet)"));
} else {
for (name, status) in &self.status_rows {
lines.push(Line::from(format!(" {:<12} {}", name, status)));
}
}
if !self.latest_result.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(" Latest result"));
for line in &self.latest_result {
lines.push(Line::from(format!(" {}", line)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(" Actions"));
if self.actions.is_empty() {
lines.push(Line::from(" (no recorded actions yet)"));
} else {
for action in &self.actions {
lines.push(Line::from(format!(" {}", action)));
}
}
lines
}
}
fn format_duration(duration: Duration) -> String {
let secs = duration.as_secs();
let minutes = secs / 60;
let seconds = secs % 60;
format!("{}m{:02}s", minutes, seconds)
}
fn build_header_content(agent_name: &str, status_label: &str) -> String {
let mut base = format!(" Agent Run • {}", agent_name);
if base.len() > HEADER_WIDTH {
base.truncate(HEADER_WIDTH);
}
let status = format!(" {} ", status_label);
if base.len() + status.len() > HEADER_WIDTH {
if status.len() >= HEADER_WIDTH {
return format!("{}", &status[status.len() - HEADER_WIDTH..]);
}
base.truncate(HEADER_WIDTH - status.len());
}
let padding = HEADER_WIDTH.saturating_sub(base.len() + status.len());
format!("{}{}{}", base, " ".repeat(padding), status)
}
+210
View File
@@ -0,0 +1,210 @@
use super::{HistoryCell, HistoryCellType, ToolCellStatus};
use ratatui::text::Line;
use std::path::PathBuf;
use std::time::Duration;
const BOX_WIDTH: usize = 76;
const MAX_ACTIONS: usize = 12;
const MAX_CONSOLE: usize = 8;
#[derive(Clone, Default)]
pub(crate) struct BrowserSessionCell {
url: Option<String>,
title: Option<String>,
actions: Vec<BrowserAction>,
console_messages: Vec<String>,
screenshot_path: Option<String>,
total_duration: Duration,
completed: bool,
}
#[derive(Clone)]
struct BrowserAction {
timestamp: Duration,
description: String,
}
impl BrowserSessionCell {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn set_url(&mut self, url: impl Into<String>) {
self.url = Some(url.into());
}
pub(crate) fn record_action(
&mut self,
timestamp: Duration,
duration: Duration,
description: String,
) {
let action = BrowserAction {
timestamp,
description,
};
self.actions.push(action);
if self.actions.len() > MAX_ACTIONS {
let overflow = self.actions.len() - MAX_ACTIONS;
self.actions.drain(0..overflow);
}
let finish = timestamp.saturating_add(duration);
if finish > self.total_duration {
self.total_duration = finish;
}
}
pub(crate) fn add_console_message(&mut self, message: String) {
self.console_messages.push(message);
if self.console_messages.len() > MAX_CONSOLE {
let overflow = self.console_messages.len() - MAX_CONSOLE;
self.console_messages.drain(0..overflow);
}
}
pub(crate) fn set_screenshot(&mut self, path: PathBuf) {
self.screenshot_path = Some(path.display().to_string());
}
}
impl HistoryCell for BrowserSessionCell {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn kind(&self) -> HistoryCellType {
let status = if self.completed {
ToolCellStatus::Success
} else {
ToolCellStatus::Running
};
HistoryCellType::Tool { status }
}
fn display_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let inner_border = "─".repeat(BOX_WIDTH);
lines.push(Line::from(format!("┌{}┐", inner_border)));
lines.push(Line::from(format!(
"│{}│",
build_header_content(
self.url.as_deref().unwrap_or("(unknown)"),
self.total_duration,
self.completed,
)
)));
lines.push(Line::from(format!("└{}┘", inner_border)));
let title = self.title.clone().unwrap_or_else(|| "(pending)".to_string());
lines.push(Line::from(format!(
" Active URL: {}",
self.url.as_deref().unwrap_or("(unknown)")
)));
lines.push(Line::from(format!(" Title: {}", title)));
lines.push(Line::from(""));
lines.push(Line::from(" Actions"));
if self.actions.is_empty() {
lines.push(Line::from(" (no browser actions yet)"));
} else {
for action in &self.actions {
lines.push(Line::from(format!(
" {} {}",
format_timestamp(action.timestamp),
action.description
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(" Console"));
if self.console_messages.is_empty() {
lines.push(Line::from(" (no console messages)"));
} else {
for message in &self.console_messages {
lines.push(Line::from(format!(" {}", message)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(" Screenshot"));
if let Some(path) = &self.screenshot_path {
lines.push(Line::from(format!(" {}", path)));
} else {
lines.push(Line::from(" (no screenshot yet)"));
}
lines
}
}
fn build_header_content(url: &str, total_duration: Duration, completed: bool) -> String {
let inner_width = BOX_WIDTH;
let mut base = format!(" Browser Session • {}", url);
if base.len() > inner_width {
base.truncate(inner_width);
}
let duration_label = if total_duration.is_zero() {
if completed {
"0s".to_string()
} else {
"Running".to_string()
}
} else {
format_elapsed_compact(total_duration)
};
let required = duration_label.len();
let mut max_head = inner_width.saturating_sub(required + 1);
if max_head == 0 {
max_head = inner_width;
}
if base.len() > max_head {
base.truncate(max_head);
if !base.ends_with(' ') {
base.push(' ');
}
}
let padding = inner_width
.saturating_sub(base.len())
.saturating_sub(required);
if padding > 0 {
base.push_str(&" ".repeat(padding));
}
if required > 0 {
if !base.ends_with(' ') {
base.push(' ');
}
base.push_str(&duration_label);
}
if base.len() < inner_width {
base.push_str(&" ".repeat(inner_width - base.len()));
}
base
}
fn format_elapsed_compact(duration: Duration) -> String {
let secs = duration.as_secs();
let minutes = secs / 60;
let seconds = secs % 60;
if minutes > 0 {
format!("{}m{:02}s", minutes, seconds)
} else {
format!("{:02}s", seconds)
}
}
fn format_timestamp(duration: Duration) -> String {
let secs = duration.as_secs();
let minutes = secs / 60;
let seconds = secs % 60;
format!("{:02}:{:02}", minutes, seconds)
}
+4
View File
@@ -80,6 +80,8 @@ mod image;
mod loading;
mod reasoning;
mod tool;
mod browser;
mod agent;
mod wait_status;
mod plan_update;
mod rate_limits;
@@ -109,6 +111,8 @@ pub(crate) use diff::{
new_diff_cell_from_string,
DiffCell,
};
pub(crate) use browser::BrowserSessionCell;
pub(crate) use agent::AgentRunCell;
pub(crate) use explore::{
explore_lines_from_record,
explore_lines_from_record_with_force,
+10
View File
@@ -141,6 +141,16 @@ impl RunningToolCallCell {
&mut self.state
}
#[cfg(any(test, feature = "test-helpers"))]
pub(crate) fn override_elapsed_for_testing(&mut self, duration: Duration) {
if let Some(adjusted) = SystemTime::now().checked_sub(duration) {
self.state.started_at = adjusted;
} else {
self.state.started_at = SystemTime::UNIX_EPOCH;
}
self.start_clock = Instant::now();
}
fn strip_zero_seconds_suffix(mut duration: String) -> String {
if duration.ends_with(" 00s") {
duration.truncate(duration.len().saturating_sub(4));
@@ -0,0 +1,36 @@
---
source: tui/tests/vt100_chatwidget_snapshot.rs
assertion_line: 663
expression: output
---
┌────────────────────────────────────────────────────────────────────────────┐
│ Code │
└────────────────────────────────────────────────────────────────────────────┘
┌ Agents ────────────────────────────────────────────────────────────────────┐
│ • QA Bot (running 0s) — Executing smoke tests Ctrl+A open terminal │
└────────────────────────────────────────────────────────────────────────────┘
Task: Run targeted regression suite
Duration: 1m44s
Plan
• Init workspace
• Nextest smoke
• Summarize
Status
QA Bot running — Executing smoke tests
Latest result
Regression sweep complete
- 58 tests passed
- 0 failures
Actions
Started agent run for qa-bot
Status update — QA Bot: running — Executing smoke tests
Agent run completed in 1m44s
┌───────────────────────────── ✶ Responding... ──────────────────────────────┐
│ What can I code for you today? │
└────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+H help
@@ -0,0 +1,35 @@
---
source: tui/tests/vt100_chatwidget_snapshot.rs
expression: output
---
┌────────────────────────────────────────────────────────────────────────────┐
│ Code │
└────────────────────────────────────────────────────────────────────────────┘
┌ Browser ───────────────────────────────────────────────────────────────────┐
│ • https://example.com/docs Ctrl+B expand │
└────────────────────────────────────────────────────────────────────────────┘
/cloud - browse, apply, and create cloud tasks NEW
› Open docs and find login button
⚙ ┌───────────────────────────────────────────────────────────────────────────
│ Browser Session • https://example.com/docs 1m05s│
└───────────────────────────────────────────────────────────────────────────
Active URL: https://example.com/docs
Title: (pending)
Actions
00:00 Open URL https://example.com/docs
00:05 Click Sign in button (#sign-in)
00:18 Scroll by dy=640
00:32 Type “docs search”
Console
⚠️ cdp warning: Refused to load script from cdn.example.com
Screenshot
┌────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you today? │
└────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+H help
@@ -0,0 +1,36 @@
---
source: tui/tests/vt100_chatwidget_snapshot.rs
assertion_line: 597
expression: output
---
┌────────────────────────────────────────────────────────────────────────────┐
│ Code │
└────────────────────────────────────────────────────────────────────────────┘
/limits - visualize weekly and hourly rate limits
/review - review your changes for potential issues
/auto - work autonomously on long tasks with Auto Drive Experimental
/cloud - browse, apply, and create cloud tasks NEW
› Handle captcha gracefully
⚙ ┌───────────────────────────────────────────────────────────────────────────
│ Browser Session • https://example.com 06s│
└───────────────────────────────────────────────────────────────────────────
Active URL: https://example.com
Title: (pending)
Actions
00:00 Open URL https://example.com
00:03 Type “pizza”
00:05 browser key
Console
⚠️ Encountering captcha block
Screenshot
(no screenshot yet)
┌────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you today? │
└────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+H help
@@ -0,0 +1,35 @@
---
source: tui/tests/vt100_chatwidget_snapshot.rs
expression: output
---
┌────────────────────────────────────────────────────────────────────────────┐
│ Code │
└────────────────────────────────────────────────────────────────────────────┘
Plan
• Init workspace
• Nextest smoke
• Summarize
Status
(no status updates yet)
Latest result
Regression sweep complete
- 58 tests passed
- 0 failures
⚙ ┌───────────────────────────────────────────────────────────────────────────
│ Agent Run • agent-pending Running │
└───────────────────────────────────────────────────────────────────────────
Plan
(no plan provided)
Status
(no status updates yet)
┌────────────────────────────────────────────────────────────────────────────┐
│ What can I code for you today? │
└────────────────────────────────────────────────────────────────────────────┘
Read Only (Shift+Tab change) Ctrl+H help
+580 -1
View File
@@ -8,9 +8,71 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use code_core::protocol::{
AgentMessageDeltaEvent, AgentMessageEvent, Event, EventMsg, OrderMeta,
AgentInfo,
AgentMessageDeltaEvent,
AgentMessageEvent,
AgentStatusUpdateEvent,
BackgroundEventEvent,
BrowserScreenshotUpdateEvent,
CustomToolCallBeginEvent,
CustomToolCallEndEvent,
Event,
EventMsg,
OrderMeta,
WebSearchBeginEvent,
WebSearchCompleteEvent,
};
use code_tui::test_helpers::{render_chat_widget_to_vt100, ChatWidgetHarness};
use serde_json::json;
use std::path::PathBuf;
use std::time::Duration;
fn normalize_output(text: String) -> String {
text
.replace('✧', "✶")
.replace('◇', "✶")
.replace('✦', "✶")
.replace('◆', "✶")
.replace('✨', "✶")
}
fn push_ordered_event(
harness: &mut ChatWidgetHarness,
event_seq: &mut u64,
order_seq: &mut u64,
msg: EventMsg,
) {
let seq = *event_seq;
let ord = *order_seq;
let event = Event {
id: "turn-1".into(),
event_seq: seq,
msg,
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(0),
sequence_number: Some(ord),
}),
};
harness.handle_event(event);
*event_seq = seq.saturating_add(1);
*order_seq = ord.saturating_add(1);
}
fn push_unordered_event(
harness: &mut ChatWidgetHarness,
event_seq: &mut u64,
msg: EventMsg,
) {
let seq = *event_seq;
harness.handle_event(Event {
id: format!("unordered-{seq}"),
event_seq: seq,
msg,
order: None,
});
*event_seq = seq.saturating_add(1);
}
#[test]
fn baseline_empty_chat() {
@@ -187,3 +249,520 @@ fn baseline_multiline_formatting() {
let output = render_chat_widget_to_vt100(&mut harness, 80, 24);
insta::assert_snapshot!("multiline_formatting", output);
}
#[test]
fn tool_activity_showcase() {
let mut harness = ChatWidgetHarness::new();
harness.push_user_prompt("Can you gather details from the latest docs update?");
let mut event_seq = 0u64;
let mut order_seq = 0u64;
// Completed web search call
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::WebSearchBegin(WebSearchBeginEvent {
call_id: "search-complete".into(),
query: Some("ratatui widget patterns".into()),
}),
);
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::WebSearchComplete(WebSearchCompleteEvent {
call_id: "search-complete".into(),
query: Some("ratatui widget patterns".into()),
}),
);
// Running web search call
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::WebSearchBegin(WebSearchBeginEvent {
call_id: "search-running".into(),
query: Some("async rust tui example".into()),
}),
);
harness.override_running_tool_elapsed("search-running", Duration::from_secs(75));
// Browser tool: completed click
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "browser-finished".into(),
tool_name: "browser_click".into(),
parameters: Some(json!({
"type": "double",
"x": 512,
"y": 284,
"selector": "#login-button"
})),
}),
);
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: "browser-finished".into(),
tool_name: "browser_click".into(),
parameters: Some(json!({
"type": "double",
"x": 512,
"y": 284,
"selector": "#login-button"
})),
duration: Duration::from_secs(8),
result: Ok("{\n \"status\": \"ok\",\n \"notes\": \"Login button clicked\"\n}".into()),
}),
);
// Browser tool: active scroll
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "browser-running".into(),
tool_name: "browser_scroll".into(),
parameters: Some(json!({
"dx": 0,
"dy": 640,
"speed": "smooth"
})),
}),
);
harness.override_running_tool_elapsed("browser-running", Duration::from_secs(95));
// Agent tool: completed run
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "agent-done".into(),
tool_name: "agent_run".into(),
parameters: Some(json!({
"agent_id": "qa-bot",
"task": "Run targeted regression suite",
"plan": ["Init workspace", "Nextest smoke", "Summarize"]
})),
}),
);
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: "agent-done".into(),
tool_name: "agent_run".into(),
parameters: Some(json!({
"agent_id": "qa-bot",
"task": "Run targeted regression suite",
"plan": ["Init workspace", "Nextest smoke", "Summarize"]
})),
duration: Duration::from_secs(94),
result: Ok("Regression sweep complete\n- 58 tests passed\n- 0 failures".into()),
}),
);
// Agent tool: active wait
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "agent-pending".into(),
tool_name: "agent_wait".into(),
parameters: Some(json!({
"agent_id": "deploy-helper",
"status_url": "https://status.example.com/run/42",
"timeout_seconds": 600
})),
}),
);
harness.override_running_tool_elapsed("agent-pending", Duration::from_secs(185));
let output = render_chat_widget_to_vt100(&mut harness, 80, 32);
insta::assert_snapshot!("tool_activity_showcase", output);
}
#[test]
fn browser_session_grouped_desired_layout() {
let mut harness = ChatWidgetHarness::new();
harness.push_user_prompt("Open docs and find login button");
let mut event_seq = 0u64;
let mut order_seq = 0u64;
// Browser open -> sets initial URL
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "browser-session".into(),
tool_name: "browser_open".into(),
parameters: Some(json!({
"url": "https://example.com/docs"
})),
}),
);
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: "browser-session".into(),
tool_name: "browser_open".into(),
parameters: Some(json!({
"url": "https://example.com/docs"
})),
duration: Duration::from_secs(5),
result: Ok("{\n \"status\": \"ok\"\n}".into()),
}),
);
// Additional browser interactions
let actions = [
(
"browser_click",
json!({
"selector": "#sign-in",
"description": "Sign in button"
}),
Duration::from_secs(13),
),
(
"browser_scroll",
json!({
"dx": 0,
"dy": 640
}),
Duration::from_secs(14),
),
(
"browser_type",
json!({
"text": "docs search"
}),
Duration::from_secs(33),
),
];
for (tool, params, dur) in actions {
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: format!("browser-session-{tool}"),
tool_name: tool.into(),
parameters: Some(params.clone()),
}),
);
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: format!("browser-session-{tool}"),
tool_name: tool.into(),
parameters: Some(params),
duration: dur,
result: Ok("{\n \"status\": \"ok\"\n}".into()),
}),
);
}
// Capture console warning (represented as background event)
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::BackgroundEvent(BackgroundEventEvent {
message: "cdp warning: Refused to load script from cdn.example.com".into(),
}),
);
// Screenshot update for active tab
harness.handle_event(Event {
id: "browser-shot".into(),
event_seq,
msg: EventMsg::BrowserScreenshotUpdate(BrowserScreenshotUpdateEvent {
screenshot_path: PathBuf::from("/tmp/browser_session.png"),
url: "https://example.com/docs".into(),
}),
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(1),
sequence_number: Some(order_seq),
}),
});
let output = render_chat_widget_to_vt100(&mut harness, 80, 32);
insta::assert_snapshot!("browser_session_grouped_desired_layout", output);
}
#[test]
fn browser_session_grouped_with_unordered_actions() {
let mut harness = ChatWidgetHarness::new();
harness.push_user_prompt("Handle captcha gracefully");
let mut event_seq = 0u64;
let mut order_seq = 0u64;
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "browser-session-open".into(),
tool_name: "browser_open".into(),
parameters: Some(json!({ "url": "https://example.com" })),
}),
);
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: "browser-session-open".into(),
tool_name: "browser_open".into(),
parameters: Some(json!({ "url": "https://example.com" })),
duration: Duration::from_secs(3),
result: Ok("{ \"status\": \"ok\" }".into()),
}),
);
push_unordered_event(
&mut harness,
&mut event_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "browser-session-type".into(),
tool_name: "browser_type".into(),
parameters: Some(json!({ "text": "pizza" })),
}),
);
push_unordered_event(
&mut harness,
&mut event_seq,
EventMsg::BackgroundEvent(BackgroundEventEvent {
message: "Encountering captcha block".into(),
}),
);
push_unordered_event(
&mut harness,
&mut event_seq,
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: "browser-session-type".into(),
tool_name: "browser_type".into(),
parameters: Some(json!({ "text": "pizza" })),
duration: Duration::from_secs(2),
result: Ok("{ \"status\": \"typed\" }".into()),
}),
);
push_unordered_event(
&mut harness,
&mut event_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "browser-session-key".into(),
tool_name: "browser_key".into(),
parameters: Some(json!({ "key": "Enter" })),
}),
);
push_unordered_event(
&mut harness,
&mut event_seq,
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: "browser-session-key".into(),
tool_name: "browser_key".into(),
parameters: Some(json!({ "key": "Enter" })),
duration: Duration::from_secs(1),
result: Ok("{ \"status\": \"ok\" }".into()),
}),
);
let output = render_chat_widget_to_vt100(&mut harness, 80, 32);
let output = normalize_output(output);
insta::assert_snapshot!(
"browser_session_grouped_with_unordered_actions",
output
);
}
#[test]
fn agent_run_grouped_desired_layout() {
let mut harness = ChatWidgetHarness::new();
harness.push_user_prompt("Kick off QA bot regression run");
let mut event_seq = 0u64;
let mut order_seq = 0u64;
// Agent run begins
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "agent-run".into(),
tool_name: "agent_run".into(),
parameters: Some(json!({
"agent_id": "qa-bot",
"task": "Run targeted regression suite",
"plan": ["Init workspace", "Nextest smoke", "Summarize"]
})),
}),
);
// Status update with multiple agents
harness.handle_event(Event {
id: "agent-status".into(),
event_seq,
msg: EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
agents: vec![
AgentInfo {
id: "qa-bot".into(),
name: "QA Bot".into(),
status: "running tests".into(),
batch_id: None,
model: None,
last_progress: None,
result: None,
error: None,
},
AgentInfo {
id: "doc-writer".into(),
name: "Doc Writer".into(),
status: "planning".into(),
batch_id: None,
model: None,
last_progress: None,
result: None,
error: None,
},
],
context: Some("regression sweep".into()),
task: Some("Ship bugfix patch".into()),
}),
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(1),
sequence_number: Some(order_seq),
}),
});
event_seq += 1;
order_seq += 1;
// Agent result
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: "agent-run".into(),
tool_name: "agent_run".into(),
parameters: Some(json!({
"agent_id": "qa-bot",
"task": "Run targeted regression suite",
"plan": ["Init workspace", "Nextest smoke", "Summarize"]
})),
duration: Duration::from_secs(94),
result: Ok("Regression sweep complete\n- 58 tests passed\n- 0 failures".into()),
}),
);
let output = render_chat_widget_to_vt100(&mut harness, 80, 32);
let output = normalize_output(output);
insta::assert_snapshot!(
"agent_run_grouped_desired_layout",
&output,
@"agent_run_grouped_desired_layout"
);
}
#[test]
fn agent_run_grouped_plain_tool_name() {
let mut harness = ChatWidgetHarness::new();
harness.push_user_prompt("Kick off QA bot regression run");
let mut event_seq = 0u64;
let mut order_seq = 0u64;
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
call_id: "agent-run-plain".into(),
tool_name: "agent".into(),
parameters: Some(json!({
"action": "run",
"batch_id": "batch-001",
"agent_id": "qa-bot",
"task": "Run targeted regression suite",
"plan": ["Init workspace", "Nextest smoke", "Summarize"]
})),
}),
);
harness.handle_event(Event {
id: "agent-status".into(),
event_seq,
msg: EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
agents: vec![
AgentInfo {
id: "qa-bot".into(),
name: "QA Bot".into(),
status: "running".into(),
batch_id: Some("batch-001".into()),
model: Some("claude".into()),
last_progress: Some("Executing smoke tests".into()),
result: None,
error: None,
},
],
context: Some("regression sweep".into()),
task: Some("Ship bugfix patch".into()),
}),
order: Some(OrderMeta {
request_ordinal: 1,
output_index: Some(1),
sequence_number: Some(order_seq),
}),
});
event_seq += 1;
order_seq += 1;
push_ordered_event(
&mut harness,
&mut event_seq,
&mut order_seq,
EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
call_id: "agent-run-plain".into(),
tool_name: "agent".into(),
parameters: Some(json!({
"action": "run",
"batch_id": "batch-001",
"agent_id": "qa-bot",
"task": "Run targeted regression suite",
"plan": ["Init workspace", "Nextest smoke", "Summarize"]
})),
duration: Duration::from_secs(104),
result: Ok("Regression sweep complete\n- 58 tests passed\n- 0 failures".into()),
}),
);
let output = render_chat_widget_to_vt100(&mut harness, 80, 32);
let output = normalize_output(output);
insta::assert_snapshot!("agent_run_grouped_plain_tool_name", output);
}