fix(tui/history): collapse wait tool output into exec cell

This commit is contained in:
James Peter
2025-09-21 17:29:26 +10:00
parent fbf6a9d6a0
commit 3aa2e17a00
3 changed files with 309 additions and 21 deletions
+129
View File
@@ -204,6 +204,9 @@ struct RunningCommand {
explore_entry: Option<(usize, usize)>,
stdout: String,
stderr: String,
wait_total: Option<Duration>,
wait_active: bool,
wait_note: Option<(String, bool)>,
}
pub(crate) struct ChatWidget<'a> {
@@ -488,6 +491,12 @@ fn wait_target_from_params(params: Option<&String>, call_id: &str) -> String {
format!("call {}", call_id)
}
fn wait_exec_call_id_from_params(params: Option<&String>) -> Option<ExecCallId> {
params
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
.and_then(|json| json.get("call_id").and_then(|v| v.as_str()).map(|s| ExecCallId(s.to_string())))
}
fn line_text(line: &Line<'static>) -> String {
line.spans
.iter()
@@ -2027,6 +2036,7 @@ impl ChatWidget<'_> {
tools_state: ToolState {
running_custom_tools: HashMap::new(),
running_web_search: HashMap::new(),
running_wait_tools: HashMap::new(),
},
// Use max width to disable wrapping during streaming
// Text will be properly wrapped when displayed based on terminal width
@@ -2217,6 +2227,7 @@ impl ChatWidget<'_> {
tools_state: ToolState {
running_custom_tools: HashMap::new(),
running_web_search: HashMap::new(),
running_wait_tools: HashMap::new(),
},
live_builder: RowBuilder::new(usize::MAX),
pending_images: HashMap::new(),
@@ -4423,6 +4434,37 @@ impl ChatWidget<'_> {
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());
if tool_name == "wait" {
if let Some(exec_call_id) =
wait_exec_call_id_from_params(params_string.as_ref())
{
self.tools_state
.running_wait_tools
.insert(ToolCallId(call_id.clone()), exec_call_id.clone());
if let Some(running) = self.exec.running_commands.get_mut(&exec_call_id) {
running.wait_active = true;
running.wait_note = None;
let history_index = running.history_index;
if let Some(idx) = history_index {
if idx < self.history_cells.len() {
if let Some(exec_cell) = self.history_cells[idx]
.as_any_mut()
.downcast_mut::<history_cell::ExecCell>()
{
exec_cell.set_waiting(true);
exec_cell.set_wait_note(None);
}
}
}
}
self.bottom_pane
.update_status_text("waiting for command".to_string());
self.invalidate_height_cache();
self.request_redraw();
return;
}
}
// Animated running cell with live timer and formatted args
let cell = if tool_name.starts_with("browser_") {
history_cell::new_running_browser_tool_call(
@@ -4494,6 +4536,92 @@ impl ChatWidget<'_> {
Ok(content) => (true, content),
Err(error) => (false, error),
};
if tool_name == "wait" {
if let Some(exec_call_id) = self
.tools_state
.running_wait_tools
.remove(&ToolCallId(call_id.clone()))
{
let trimmed = content.trim();
let wait_still_pending = !success && trimmed != "Cancelled by user.";
let wait_note = if trimmed == "Cancelled by user." {
Some(("Wait cancelled".to_string(), true))
} else {
None
};
let mut history_index: Option<usize> = None;
if let Some(running) = self.exec.running_commands.get_mut(&exec_call_id) {
let base = running.wait_total.unwrap_or_default();
let total = base.saturating_add(duration);
running.wait_total = Some(total);
history_index = running.history_index;
running.wait_active = wait_still_pending;
running.wait_note = wait_note.clone();
}
let mut updated = false;
if let Some(idx) = history_index {
if idx < self.history_cells.len() {
if let Some(exec_cell) = self.history_cells[idx]
.as_any_mut()
.downcast_mut::<history_cell::ExecCell>()
{
let total = exec_cell
.wait_total()
.unwrap_or_default()
.saturating_add(duration);
exec_cell.set_wait_total(Some(total));
if wait_still_pending {
exec_cell.set_waiting(true);
} else {
exec_cell.set_waiting(false);
}
exec_cell.set_wait_note(wait_note.clone());
updated = true;
}
}
}
if !updated {
if let Some(exec_cell) = self
.history_cells
.iter_mut()
.rev()
.find_map(|cell| {
cell.as_any_mut()
.downcast_mut::<history_cell::ExecCell>()
})
{
let total = exec_cell
.wait_total()
.unwrap_or_default()
.saturating_add(duration);
exec_cell.set_wait_total(Some(total));
if wait_still_pending {
exec_cell.set_waiting(true);
} else {
exec_cell.set_waiting(false);
}
exec_cell.set_wait_note(wait_note.clone());
}
}
if success {
self.remove_background_completion_message(&call_id);
self.bottom_pane
.update_status_text("responding".to_string());
self.maybe_hide_spinner();
} else if trimmed == "Cancelled by user." {
self.bottom_pane
.update_status_text("wait cancelled".to_string());
} else {
self.bottom_pane
.update_status_text("waiting for command".to_string());
}
self.invalidate_height_cache();
self.request_redraw();
return;
}
}
let running_entry = self
.tools_state
.running_custom_tools
@@ -14187,6 +14315,7 @@ impl RunningToolEntry {
struct ToolState {
running_custom_tools: HashMap<ToolCallId, RunningToolEntry>,
running_web_search: HashMap<ToolCallId, (usize, Option<String>)>,
running_wait_tools: HashMap<ToolCallId, ExecCallId>,
}
#[derive(Default)]
struct StreamState {
+16 -3
View File
@@ -508,6 +508,9 @@ pub(super) fn handle_exec_begin_now(
explore_entry: Some((idx, entry_idx)),
stdout: String::new(),
stderr: String::new(),
wait_total: None,
wait_active: false,
wait_note: None,
},
);
chat.invalidate_height_cache();
@@ -539,6 +542,9 @@ pub(super) fn handle_exec_begin_now(
explore_entry: None,
stdout: String::new(),
stderr: String::new(),
wait_total: None,
wait_active: false,
wait_note: None,
},
);
if !chat.tools_state.running_web_search.is_empty() {
@@ -598,15 +604,17 @@ pub(super) fn handle_exec_end_now(
chat.height_manager
.borrow_mut()
.record_event(HeightEvent::RunEnd);
let (command, parsed, history_index, explore_entry) = match cmd {
let (command, parsed, history_index, explore_entry, wait_total, wait_note) = match cmd {
Some(super::RunningCommand {
command,
parsed,
history_index,
explore_entry,
wait_total,
wait_note,
..
}) => (command, parsed, history_index, explore_entry),
None => (vec![call_id.clone()], vec![], None, None),
}) => (command, parsed, history_index, explore_entry, wait_total, wait_note),
None => (vec![call_id.clone()], vec![], None, None, None, None),
};
if let Some((agg_idx, entry_idx)) = explore_entry {
@@ -674,6 +682,11 @@ pub(super) fn handle_exec_end_now(
stderr,
},
));
if let Some(ref cell) = completed_opt {
cell.set_wait_total(wait_total);
cell.set_wait_note(wait_note.clone());
cell.set_waiting(false);
}
let mut replaced = false;
if let Some(idx) = history_index {
+164 -18
View File
@@ -1322,6 +1322,19 @@ impl HistoryCell for PlanUpdateCell {
// ==================== ExecCell ====================
#[derive(Clone, PartialEq, Eq)]
struct ExecWaitNote {
text: String,
is_error: bool,
}
#[derive(Clone, Default)]
struct ExecWaitState {
total_wait: Option<Duration>,
waiting: bool,
note: Option<ExecWaitNote>,
}
pub(crate) struct ExecCell {
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
@@ -1336,6 +1349,7 @@ pub(crate) struct ExecCell {
// Cached per-width layout (wrapped rows + totals) while content is stable
cached_layout: std::cell::RefCell<Option<Rc<ExecLayoutCache>>>,
has_bold_command: bool,
wait_state: std::cell::RefCell<ExecWaitState>,
}
#[derive(Clone)]
@@ -1814,6 +1828,9 @@ impl HistoryCell for ExecCell {
fn has_custom_render(&self) -> bool {
true
}
fn is_animating(&self) -> bool {
self.output.is_none() && self.start_time.is_some()
}
fn desired_height(&self, width: u16) -> u16 {
let (pre_total, _out_block_total, out_total_with_status) = self.ensure_wrap_totals(width);
pre_total.saturating_add(out_total_with_status)
@@ -1942,6 +1959,82 @@ impl HistoryCell for ExecCell {
}
impl ExecCell {
fn invalidate_render_caches(&self) {
self.cached_display_lines.borrow_mut().take();
self.cached_pre_lines.borrow_mut().take();
self.cached_out_lines.borrow_mut().take();
self.cached_layout.borrow_mut().take();
self.stream_status_line.borrow_mut().take();
}
pub(crate) fn set_waiting(&self, waiting: bool) {
let mut state = self.wait_state.borrow_mut();
if state.waiting != waiting {
state.waiting = waiting;
drop(state);
self.invalidate_render_caches();
}
}
pub(crate) fn set_wait_total(&self, total: Option<Duration>) {
let mut state = self.wait_state.borrow_mut();
if state.total_wait != total {
state.total_wait = total;
drop(state);
self.invalidate_render_caches();
}
}
pub(crate) fn wait_total(&self) -> Option<Duration> {
self.wait_state.borrow().total_wait
}
pub(crate) fn set_wait_note(&self, note: Option<(String, bool)>) {
let new_note = note.map(|(text, is_error)| ExecWaitNote { text, is_error });
let mut state = self.wait_state.borrow_mut();
if state.note != new_note {
state.note = new_note;
drop(state);
self.invalidate_render_caches();
}
}
fn wait_note_line(&self, state: &ExecWaitState) -> Option<Line<'static>> {
let note = state.note.as_ref()?;
let mut line = Line::from(note.text.clone());
let mut style = Style::default().fg(if note.is_error {
crate::colors::error()
} else {
crate::colors::text_dim()
});
if note.is_error {
style = style.add_modifier(Modifier::BOLD);
}
for span in line.spans.iter_mut() {
span.style = style;
}
Some(line)
}
fn wait_state_snapshot(&self) -> ExecWaitState {
self.wait_state.borrow().clone()
}
fn wait_summary_line(&self, state: &ExecWaitState) -> Option<Line<'static>> {
if state.waiting {
return None;
}
let total = state.total_wait?;
if total.is_zero() {
return None;
}
let text = format!("Waited {}", format_duration(total));
Some(Line::styled(
text,
Style::default().fg(crate::colors::text_dim()),
))
}
#[cfg(test)]
fn has_bold_command(&self) -> bool {
self.has_bold_command
@@ -1955,11 +2048,7 @@ impl ExecCell {
self.command = command;
self.parsed = parsed;
self.has_bold_command = command_has_bold_token(&self.command);
self.cached_display_lines.borrow_mut().take();
self.cached_pre_lines.borrow_mut().take();
self.cached_out_lines.borrow_mut().take();
self.cached_layout.borrow_mut().take();
self.stream_status_line.borrow_mut().take();
self.invalidate_render_caches();
}
/// Compute wrapped row totals for the preamble and the output at the given width.
/// Delegates to the per-width layout cache to avoid redundant reflow work.
@@ -1973,10 +2062,14 @@ impl ExecCell {
}
fn ensure_layout(&self, width: u16) -> Rc<ExecLayoutCache> {
if let Some(layout) = self.cached_layout.borrow().as_ref() {
if layout.width == width {
return layout.clone();
if self.output.is_some() {
if let Some(layout) = self.cached_layout.borrow().as_ref() {
if layout.width == width {
return layout.clone();
}
}
} else {
self.cached_layout.borrow_mut().take();
}
let (pre_lines_raw, out_lines_raw, status_line_opt) = self.exec_render_parts();
@@ -2020,7 +2113,9 @@ impl ExecCell {
out_block_total,
out_total_with_status,
});
*self.cached_layout.borrow_mut() = Some(layout.clone());
if self.output.is_some() {
*self.cached_layout.borrow_mut() = Some(layout.clone());
}
layout
}
// Build separate segments: (preamble lines, output lines)
@@ -2040,12 +2135,15 @@ impl ExecCell {
return (pre.clone(), out.clone(), None);
}
let (pre, out, status) = if self.parsed.is_empty() {
let wait_state = self.wait_state_snapshot();
let status_label = if wait_state.waiting { "Waiting" } else { "Running" };
let (pre, mut out, status) = if self.parsed.is_empty() {
exec_render_parts_generic(
&self.command,
self.output.as_ref(),
self.stream_preview.as_ref(),
self.start_time,
status_label,
)
} else {
exec_render_parts_parsed(
@@ -2053,10 +2151,59 @@ impl ExecCell {
self.output.as_ref(),
self.stream_preview.as_ref(),
self.start_time,
status_label,
)
};
if self.output.is_some() {
let mut extra_lines: Vec<Line<'static>> = Vec::new();
if let Some(summary_line) = self.wait_summary_line(&wait_state) {
extra_lines.push(summary_line);
}
if let Some(note_line) = self.wait_note_line(&wait_state) {
extra_lines.push(note_line);
}
if !extra_lines.is_empty() {
let is_blank_line = |line: &Line<'static>| {
line.spans
.iter()
.all(|span| span.content.as_ref().trim().is_empty())
};
let is_error_line = |line: &Line<'static>| {
line.spans
.first()
.map(|span| span.content.as_ref().starts_with("Error (exit code"))
.unwrap_or(false)
};
if let Some(pos) = out.iter().position(is_error_line) {
let mut insert_at = pos;
if insert_at > 0 && !is_blank_line(&out[insert_at - 1]) {
out.insert(insert_at, Line::from(""));
insert_at += 1;
}
for line in extra_lines.iter() {
out.insert(insert_at, line.clone());
insert_at += 1;
out.insert(insert_at, Line::from(""));
insert_at += 1;
}
} else {
let mut insert_at = out.len();
if insert_at > 0 && is_blank_line(&out[insert_at.saturating_sub(1)]) {
insert_at = insert_at.saturating_sub(1);
}
if insert_at > 0 && !is_blank_line(&out[insert_at - 1]) {
out.insert(insert_at, Line::from(""));
insert_at += 1;
}
for line in extra_lines.iter() {
out.insert(insert_at, line.clone());
insert_at += 1;
out.insert(insert_at, Line::from(""));
insert_at += 1;
}
}
}
*self.cached_pre_lines.borrow_mut() = Some(pre.clone());
*self.cached_out_lines.borrow_mut() = Some(out.clone());
self.stream_status_line.borrow_mut().take();
@@ -2079,11 +2226,7 @@ impl ExecCell {
stderr: stderr.to_string(),
});
}
self.cached_display_lines.borrow_mut().take();
self.cached_pre_lines.borrow_mut().take();
self.cached_out_lines.borrow_mut().take();
self.cached_layout.borrow_mut().take();
self.stream_status_line.borrow_mut().take();
self.invalidate_render_caches();
}
}
@@ -3072,6 +3215,7 @@ fn exec_render_parts_generic(
output: Option<&CommandOutput>,
stream_preview: Option<&CommandOutput>,
start_time: Option<Instant>,
status_label: &str,
) -> (
Vec<Line<'static>>,
Vec<Line<'static>>,
@@ -3106,7 +3250,7 @@ fn exec_render_parts_generic(
let has_output = !trim_empty_lines(out.clone()).is_empty();
if render_running_header {
let mut message = "Running...".to_string();
let mut message = format!("{}...", status_label);
if let Some(start) = start_time {
let elapsed = start.elapsed();
message = format!("{message} ({})", format_duration(elapsed));
@@ -3143,6 +3287,7 @@ fn exec_render_parts_parsed(
output: Option<&CommandOutput>,
stream_preview: Option<&CommandOutput>,
start_time: Option<Instant>,
status_label: &str,
) -> (
Vec<Line<'static>>,
Vec<Line<'static>>,
@@ -3170,8 +3315,8 @@ fn exec_render_parts_parsed(
)),
ExecAction::Run => {
let mut message = match &ctx_path {
Some(p) => format!("Running... in {p}"),
None => "Running...".to_string(),
Some(p) => format!("{}... in {p}", status_label),
None => format!("{}...", status_label),
};
if let Some(start) = start_time {
let elapsed = start.elapsed();
@@ -6065,6 +6210,7 @@ fn new_exec_cell(
cached_out_lines: std::cell::RefCell::new(None),
cached_layout: std::cell::RefCell::new(None),
has_bold_command,
wait_state: std::cell::RefCell::new(ExecWaitState::default()),
}
}