tui/src/history_cell/separators.rs
171 lines
//! Turn separators and runtime-metrics labels for transcript history.use super::*;#[derive(Debug)]/// A visual divider between turns, optionally showing how long the assistant "worked for".////// This separator is only emitted for turns that performed concrete work (e.g., running commands,/// applying patches, making MCP tool calls), so purely conversational turns do not show an empty/// divider.pub struct FinalMessageSeparator { elapsed_seconds: Option<u64>, runtime_metrics: Option<RuntimeMetricsSummary>,}impl FinalMessageSeparator { /// Creates a separator; completed turns should pass protocol turn duration when available. pub(crate) fn new( elapsed_seconds: Option<u64>, runtime_metrics: Option<RuntimeMetricsSummary>, ) -> Self { Self { elapsed_seconds, runtime_metrics, } }}impl HistoryCell for FinalMessageSeparator { fn display_lines(&self, width: u16) -> Vec<Line<'static>> { let mut label_parts = Vec::new(); if let Some(elapsed_seconds) = self .elapsed_seconds .filter(|seconds| *seconds > 60) .map(crate::status_indicator_widget::fmt_elapsed_compact) { label_parts.push(format!("Worked for {elapsed_seconds}")); } if let Some(metrics_label) = self.runtime_metrics.and_then(runtime_metrics_label) { label_parts.push(metrics_label); } if label_parts.is_empty() { return vec![Line::from_iter(["─".repeat(width as usize).dim()])]; } let label = format!("─ {} ─", label_parts.join(" • ")); let (label, _suffix, label_width) = take_prefix_by_width(&label, width as usize); vec![ Line::from_iter([ label, "─".repeat((width as usize).saturating_sub(label_width)), ]) .dim(), ] } fn raw_lines(&self) -> Vec<Line<'static>> { let mut label_parts = Vec::new(); if let Some(elapsed_seconds) = self .elapsed_seconds .filter(|seconds| *seconds > 60) .map(crate::status_indicator_widget::fmt_elapsed_compact) { label_parts.push(format!("Worked for {elapsed_seconds}")); } if let Some(metrics_label) = self.runtime_metrics.and_then(runtime_metrics_label) { label_parts.push(metrics_label); } if label_parts.is_empty() { Vec::new() } else { vec![Line::from(label_parts.join(" • "))] } }}pub(crate) fn runtime_metrics_label(summary: RuntimeMetricsSummary) -> Option<String> { let mut parts = Vec::new(); if summary.tool_calls.count > 0 { let duration = format_duration_ms(summary.tool_calls.duration_ms); let calls = pluralize(summary.tool_calls.count, "call", "calls"); parts.push(format!( "Local tools: {} {calls} ({duration})", summary.tool_calls.count )); } if summary.api_calls.count > 0 { let duration = format_duration_ms(summary.api_calls.duration_ms); let calls = pluralize(summary.api_calls.count, "call", "calls"); parts.push(format!( "Inference: {} {calls} ({duration})", summary.api_calls.count )); } if summary.websocket_calls.count > 0 { let duration = format_duration_ms(summary.websocket_calls.duration_ms); parts.push(format!( "WebSocket: {} events send ({duration})", summary.websocket_calls.count )); } if summary.streaming_events.count > 0 { let duration = format_duration_ms(summary.streaming_events.duration_ms); let stream_label = pluralize(summary.streaming_events.count, "Stream", "Streams"); let events = pluralize(summary.streaming_events.count, "event", "events"); parts.push(format!( "{stream_label}: {} {events} ({duration})", summary.streaming_events.count )); } if summary.websocket_events.count > 0 { let duration = format_duration_ms(summary.websocket_events.duration_ms); parts.push(format!( "{} events received ({duration})", summary.websocket_events.count )); } if summary.responses_api_overhead_ms > 0 { let duration = format_duration_ms(summary.responses_api_overhead_ms); parts.push(format!("Responses API overhead: {duration}")); } if summary.responses_api_inference_time_ms > 0 { let duration = format_duration_ms(summary.responses_api_inference_time_ms); parts.push(format!("Responses API inference: {duration}")); } if summary.responses_api_engine_iapi_ttft_ms > 0 || summary.responses_api_engine_service_ttft_ms > 0 { let mut ttft_parts = Vec::new(); if summary.responses_api_engine_iapi_ttft_ms > 0 { let duration = format_duration_ms(summary.responses_api_engine_iapi_ttft_ms); ttft_parts.push(format!("{duration} (iapi)")); } if summary.responses_api_engine_service_ttft_ms > 0 { let duration = format_duration_ms(summary.responses_api_engine_service_ttft_ms); ttft_parts.push(format!("{duration} (service)")); } parts.push(format!("TTFT: {}", ttft_parts.join(" "))); } if summary.responses_api_engine_iapi_tbt_ms > 0 || summary.responses_api_engine_service_tbt_ms > 0 { let mut tbt_parts = Vec::new(); if summary.responses_api_engine_iapi_tbt_ms > 0 { let duration = format_duration_ms(summary.responses_api_engine_iapi_tbt_ms); tbt_parts.push(format!("{duration} (iapi)")); } if summary.responses_api_engine_service_tbt_ms > 0 { let duration = format_duration_ms(summary.responses_api_engine_service_tbt_ms); tbt_parts.push(format!("{duration} (service)")); } parts.push(format!("TBT: {}", tbt_parts.join(" "))); } if parts.is_empty() { None } else { Some(parts.join(" • ")) }}fn format_duration_ms(duration_ms: u64) -> String { if duration_ms >= 1_000 { let seconds = duration_ms as f64 / 1_000.0; format!("{seconds:.1}s") } else { format!("{duration_ms}ms") }}fn pluralize(count: u64, singular: &'static str, plural: &'static str) -> &'static str { if count == 1 { singular } else { plural }}