mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
fix(tui/tools): group agent and browser tool cells
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
+36
@@ -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
|
||||
+35
@@ -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
|
||||
+36
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user