fix(tui/history): show run duration and drop wait json

This commit is contained in:
James Peter
2025-09-21 20:33:35 +10:00
parent 29ee0bb18a
commit 8cdba3a61c
3 changed files with 160 additions and 68 deletions
+41 -11
View File
@@ -206,7 +206,7 @@ struct RunningCommand {
stderr: String,
wait_total: Option<Duration>,
wait_active: bool,
wait_note: Option<(String, bool)>,
wait_notes: Vec<(String, bool)>,
}
pub(crate) struct ChatWidget<'a> {
@@ -4444,7 +4444,7 @@ impl ChatWidget<'_> {
if let Some(running) = self.exec.running_commands.get_mut(&exec_call_id) {
running.wait_active = true;
running.wait_note = None;
running.wait_notes.clear();
let history_index = running.history_index;
if let Some(idx) = history_index {
if idx < self.history_cells.len() {
@@ -4453,7 +4453,7 @@ impl ChatWidget<'_> {
.downcast_mut::<history_cell::ExecCell>()
{
exec_cell.set_waiting(true);
exec_cell.set_wait_note(None);
exec_cell.clear_wait_notes();
}
}
}
@@ -4544,11 +4544,27 @@ impl ChatWidget<'_> {
{
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 note_lines: Vec<(String, bool)> = Vec::new();
let suppress_json_notes = serde_json::from_str::<serde_json::Value>(
trimmed,
)
.ok()
.and_then(|value| {
value.as_object().map(|obj| {
obj.contains_key("output") || obj.contains_key("metadata")
})
})
.unwrap_or(false);
if !suppress_json_notes {
for line in content.lines() {
let note_text = line.trim();
if note_text.is_empty() {
continue;
}
let is_error_note = note_text == "Cancelled by user.";
note_lines.push((note_text.to_string(), is_error_note));
}
}
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();
@@ -4556,7 +4572,17 @@ impl ChatWidget<'_> {
running.wait_total = Some(total);
history_index = running.history_index;
running.wait_active = wait_still_pending;
running.wait_note = wait_note.clone();
for (text, is_error_note) in &note_lines {
if running
.wait_notes
.last()
.map(|(existing, existing_err)| existing == text && existing_err == is_error_note)
.unwrap_or(false)
{
continue;
}
running.wait_notes.push((text.clone(), *is_error_note));
}
}
let mut updated = false;
@@ -4576,7 +4602,9 @@ impl ChatWidget<'_> {
} else {
exec_cell.set_waiting(false);
}
exec_cell.set_wait_note(wait_note.clone());
for (text, is_error_note) in &note_lines {
exec_cell.push_wait_note(text, *is_error_note);
}
updated = true;
}
}
@@ -4601,7 +4629,9 @@ impl ChatWidget<'_> {
} else {
exec_cell.set_waiting(false);
}
exec_cell.set_wait_note(wait_note.clone());
for (text, is_error_note) in &note_lines {
exec_cell.push_wait_note(text, *is_error_note);
}
}
}
+9 -8
View File
@@ -510,7 +510,7 @@ pub(super) fn handle_exec_begin_now(
stderr: String::new(),
wait_total: None,
wait_active: false,
wait_note: None,
wait_notes: Vec::new(),
},
);
chat.invalidate_height_cache();
@@ -544,7 +544,7 @@ pub(super) fn handle_exec_begin_now(
stderr: String::new(),
wait_total: None,
wait_active: false,
wait_note: None,
wait_notes: Vec::new(),
},
);
if !chat.tools_state.running_web_search.is_empty() {
@@ -593,7 +593,7 @@ pub(super) fn handle_exec_end_now(
let ExecCommandEndEvent {
call_id,
exit_code,
duration: _,
duration,
stdout,
stderr,
} = ev;
@@ -604,17 +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, wait_total, wait_note) = match cmd {
let (command, parsed, history_index, explore_entry, wait_total, wait_notes) = match cmd {
Some(super::RunningCommand {
command,
parsed,
history_index,
explore_entry,
wait_total,
wait_note,
wait_notes,
..
}) => (command, parsed, history_index, explore_entry, wait_total, wait_note),
None => (vec![call_id.clone()], vec![], None, None, None, None),
}) => (command, parsed, history_index, explore_entry, wait_total, wait_notes),
None => (vec![call_id.clone()], vec![], None, None, None, Vec::new()),
};
if let Some((agg_idx, entry_idx)) = explore_entry {
@@ -684,8 +684,9 @@ pub(super) fn handle_exec_end_now(
));
if let Some(ref cell) = completed_opt {
cell.set_wait_total(wait_total);
cell.set_wait_note(wait_note.clone());
cell.set_wait_notes(&wait_notes);
cell.set_waiting(false);
cell.set_run_duration(Some(duration));
}
let mut replaced = false;
+110 -49
View File
@@ -1331,8 +1331,9 @@ struct ExecWaitNote {
#[derive(Clone, Default)]
struct ExecWaitState {
total_wait: Option<Duration>,
run_duration: Option<Duration>,
waiting: bool,
note: Option<ExecWaitNote>,
notes: Vec<ExecWaitNote>,
}
pub(crate) struct ExecCell {
@@ -1985,35 +1986,95 @@ impl ExecCell {
}
}
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 });
pub(crate) fn set_run_duration(&self, duration: Option<Duration>) {
let mut state = self.wait_state.borrow_mut();
if state.note != new_note {
state.note = new_note;
if state.run_duration != duration {
state.run_duration = duration;
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()
pub(crate) fn wait_total(&self) -> Option<Duration> {
self.wait_state.borrow().total_wait
}
pub(crate) fn clear_wait_notes(&self) {
let mut state = self.wait_state.borrow_mut();
if state.notes.is_empty() {
return;
}
state.notes.clear();
drop(state);
self.invalidate_render_caches();
}
pub(crate) fn push_wait_note(&self, text: &str, is_error: bool) {
let trimmed = text.trim();
if trimmed.is_empty() {
return;
}
let mut state = self.wait_state.borrow_mut();
if state
.notes
.last()
.map(|note| note.text == trimmed && note.is_error == is_error)
.unwrap_or(false)
{
return;
}
state.notes.push(ExecWaitNote {
text: trimmed.to_string(),
is_error,
});
if note.is_error {
style = style.add_modifier(Modifier::BOLD);
drop(state);
self.invalidate_render_caches();
}
pub(crate) fn set_wait_notes(&self, notes: &[(String, bool)]) {
let mut state = self.wait_state.borrow_mut();
let mut changed = state.notes.len() != notes.len();
if !changed {
for (existing, (text, is_error)) in state.notes.iter().zip(notes.iter()) {
if existing.text != text.trim() || existing.is_error != *is_error {
changed = true;
break;
}
}
}
for span in line.spans.iter_mut() {
span.style = style;
if !changed {
return;
}
Some(line)
state.notes = notes
.iter()
.map(|(text, is_error)| ExecWaitNote {
text: text.trim().to_string(),
is_error: *is_error,
})
.filter(|note| !note.text.is_empty())
.collect();
drop(state);
self.invalidate_render_caches();
}
fn wait_note_lines(&self, state: &ExecWaitState) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for note in &state.notes {
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;
}
lines.push(line);
}
lines
}
fn wait_state_snapshot(&self) -> ExecWaitState {
@@ -2024,6 +2085,16 @@ impl ExecCell {
if state.waiting {
return None;
}
if let Some(run_duration) = state.run_duration {
if run_duration.is_zero() {
return None;
}
let text = format!("Ran for {}", format_duration(run_duration));
return Some(Line::styled(
text,
Style::default().fg(crate::colors::text_dim()),
));
}
let total = state.total_wait?;
if total.is_zero() {
return None;
@@ -2160,9 +2231,7 @@ impl ExecCell {
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);
}
extra_lines.extend(self.wait_note_lines(&wait_state));
if !extra_lines.is_empty() {
let is_blank_line = |line: &Line<'static>| {
line.spans
@@ -2175,34 +2244,26 @@ impl ExecCell {
.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;
let insert_at = if let Some(pos) = out.iter().position(is_error_line) {
pos
} else {
out.len()
};
let mut block: Vec<Line<'static>> = Vec::new();
if insert_at > 0 && !is_blank_line(&out[insert_at - 1]) {
block.push(Line::from(""));
}
block.extend(extra_lines.into_iter());
if insert_at < out.len() {
if !is_blank_line(&out[insert_at]) {
block.push(Line::from(""));
}
} 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;
}
block.push(Line::from(""));
}
out.splice(insert_at..insert_at, block);
}
*self.cached_pre_lines.borrow_mut() = Some(pre.clone());
*self.cached_out_lines.borrow_mut() = Some(out.clone());