1use std::collections::HashMap;
2
3use ratatui::{
4 Frame,
5 layout::{Constraint, Direction, Layout, Rect},
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::{Block, Borders, Paragraph, Wrap},
9};
10
11use crate::telemetry::PROVIDER_METRICS;
12use crate::tui::app::state::App;
13use crate::tui::chat::message::MessageType;
14use crate::tui::token_display::TokenDisplay;
15
16#[derive(Debug, Default)]
17struct ToolLatencySummary {
18 count: usize,
19 failures: usize,
20 total_ms: u64,
21 max_ms: u64,
22 last_ms: u64,
23}
24
25pub fn render_latency(f: &mut Frame, area: Rect, app: &App) {
26 let chunks = Layout::default()
27 .direction(Direction::Vertical)
28 .constraints([Constraint::Min(8), Constraint::Length(3)])
29 .split(area);
30
31 let mut lines = vec![
32 Line::from("Latency Inspector"),
33 Line::from(""),
34 Line::from("Process-local timing from the current TUI runtime."),
35 Line::from("TTFT/TTL measure Enter to first/last assistant text observed in the TUI."),
36 Line::from(format!("Workspace: {}", app.state.cwd_display)),
37 ];
38
39 if app.state.processing {
40 let elapsed = app
41 .state
42 .current_request_elapsed_ms()
43 .map(format_duration)
44 .unwrap_or_else(|| "running".to_string());
45 let ttft = app
46 .state
47 .current_request_first_token_ms
48 .map(format_duration)
49 .unwrap_or_else(|| "waiting".to_string());
50 lines.push(Line::from(format!(
51 "Current request: in flight for {elapsed} | TTFT {ttft}"
52 )));
53 } else {
54 lines.push(Line::from("Current request: idle"));
55 }
56
57 match (
58 app.state.last_request_first_token_ms,
59 app.state.last_request_last_token_ms,
60 ) {
61 (Some(ttft_ms), Some(ttl_ms)) => lines.push(Line::from(format!(
62 "Last request: TTFT {} | TTL {}",
63 format_duration(ttft_ms),
64 format_duration(ttl_ms)
65 ))),
66 (Some(ttft_ms), None) => lines.push(Line::from(format!(
67 "Last request: TTFT {} | TTL unavailable",
68 format_duration(ttft_ms)
69 ))),
70 (None, Some(ttl_ms)) => lines.push(Line::from(format!(
71 "Last request: TTFT unavailable | TTL {}",
72 format_duration(ttl_ms)
73 ))),
74 (None, None) => lines.push(Line::from("Last request: no token timing yet")),
75 }
76
77 if let Some(latency_ms) = app.state.last_completion_latency_ms {
78 let model = app
79 .state
80 .last_completion_model
81 .clone()
82 .unwrap_or_else(|| "unknown".to_string());
83 let prompt = app.state.last_completion_prompt_tokens.unwrap_or_default();
84 let output = app.state.last_completion_output_tokens.unwrap_or_default();
85 lines.push(Line::from(format!(
86 "Last model round-trip: {model} in {} ({} in / {} out)",
87 format_duration(latency_ms),
88 prompt,
89 output
90 )));
91 } else {
92 lines.push(Line::from("Last model round-trip: no request timing yet"));
93 }
94
95 if let Some(latency_ms) = app.state.last_tool_latency_ms {
96 let tool = app
97 .state
98 .last_tool_name
99 .clone()
100 .unwrap_or_else(|| "unknown".to_string());
101 let outcome = if app.state.last_tool_success.unwrap_or(false) {
102 "ok"
103 } else {
104 "fail"
105 };
106 lines.push(Line::from(format!(
107 "Last tool: {tool} in {} [{outcome}]",
108 format_duration(latency_ms)
109 )));
110 } else {
111 lines.push(Line::from("Last tool: no timed tool executions yet"));
112 }
113
114 lines.push(Line::from(""));
115 lines.push(section_heading("Token Usage Snapshot"));
116 let token_display = TokenDisplay::new();
117 for line in token_display.create_detailed_display().into_iter().take(10) {
118 lines.push(Line::from(line));
119 }
120 lines.push(Line::from(""));
121 lines.push(section_heading("Provider Round-Trip Latency"));
122
123 let mut provider_snapshots = PROVIDER_METRICS.all_snapshots();
124 provider_snapshots.sort_by(|a, b| {
125 b.request_count
126 .cmp(&a.request_count)
127 .then_with(|| b.avg_latency_ms.total_cmp(&a.avg_latency_ms))
128 .then_with(|| a.provider.cmp(&b.provider))
129 });
130 if provider_snapshots.is_empty() {
131 lines.push(Line::from(" No provider requests recorded yet."));
132 } else {
133 for snapshot in provider_snapshots.iter().take(6) {
134 lines.push(Line::from(format!(
135 " {}: avg {} | p50 {} | p95 {} | {} reqs | {} avg TPS",
136 snapshot.provider,
137 format_duration(snapshot.avg_latency_ms.round() as u64),
138 format_duration(snapshot.p50_latency_ms.round() as u64),
139 format_duration(snapshot.p95_latency_ms.round() as u64),
140 snapshot.request_count,
141 format_tps(snapshot.avg_tps)
142 )));
143 }
144 }
145
146 lines.push(Line::from(""));
147 lines.push(section_heading("Tool Latency In Chat"));
148
149 let tool_summaries = summarize_tool_latencies(app);
150 if tool_summaries.is_empty() {
151 lines.push(Line::from(
152 " No timed tool executions recorded in the current chat buffer.",
153 ));
154 } else {
155 for (tool_name, stats) in tool_summaries.iter().take(6) {
156 let avg_ms = stats.total_ms / stats.count.max(1) as u64;
157 lines.push(Line::from(format!(
158 " {}: avg {} | max {} | {} runs | {} failures",
159 tool_name,
160 format_duration(avg_ms),
161 format_duration(stats.max_ms),
162 stats.count,
163 stats.failures
164 )));
165 }
166 }
167
168 lines.push(Line::from(""));
169 lines.push(section_heading("Recent Timed Tools"));
170 let recent = recent_tool_latencies(app, 8);
171 if recent.is_empty() {
172 lines.push(Line::from(" No recent timed tools yet."));
173 } else {
174 for entry in recent {
175 lines.push(Line::from(format!(" {entry}")));
176 }
177 }
178
179 let widget = Paragraph::new(lines)
180 .block(Block::default().borders(Borders::ALL).title("Latency"))
181 .wrap(Wrap { trim: false });
182 f.render_widget(widget, chunks[0]);
183
184 let footer = Paragraph::new(Line::from(vec![
185 Span::styled(
186 " LATENCY ",
187 Style::default().fg(Color::Black).bg(Color::Cyan),
188 ),
189 Span::raw(" | "),
190 Span::styled("Esc", Style::default().fg(Color::Yellow)),
191 Span::raw(": Back | "),
192 Span::styled("/chat", Style::default().fg(Color::Yellow)),
193 Span::raw(": Return | "),
194 Span::styled(
195 app.state.status.clone(),
196 Style::default().fg(if app.state.processing {
197 Color::Yellow
198 } else {
199 Color::Green
200 }),
201 ),
202 ]));
203 f.render_widget(footer, chunks[1]);
204}
205
206fn section_heading(title: &str) -> Line<'static> {
207 Line::from(vec![Span::styled(
208 format!(" {title}"),
209 Style::default()
210 .fg(Color::Cyan)
211 .add_modifier(Modifier::BOLD),
212 )])
213}
214
215fn summarize_tool_latencies(app: &App) -> Vec<(String, ToolLatencySummary)> {
216 let mut by_tool: HashMap<String, ToolLatencySummary> = HashMap::new();
217
218 for message in &app.state.messages {
219 if let MessageType::ToolResult {
220 name,
221 success,
222 duration_ms: Some(duration_ms),
223 ..
224 } = &message.message_type
225 {
226 let entry = by_tool.entry(name.clone()).or_default();
227 entry.count += 1;
228 entry.total_ms += *duration_ms;
229 entry.max_ms = entry.max_ms.max(*duration_ms);
230 entry.last_ms = *duration_ms;
231 if !*success {
232 entry.failures += 1;
233 }
234 }
235 }
236
237 let mut summaries: Vec<_> = by_tool.into_iter().collect();
238 summaries.sort_by(|a, b| {
239 let a_avg = a.1.total_ms / a.1.count.max(1) as u64;
240 let b_avg = b.1.total_ms / b.1.count.max(1) as u64;
241 b_avg
242 .cmp(&a_avg)
243 .then_with(|| b.1.last_ms.cmp(&a.1.last_ms))
244 .then_with(|| a.0.cmp(&b.0))
245 });
246 summaries
247}
248
249fn recent_tool_latencies(app: &App, limit: usize) -> Vec<String> {
250 app.state
251 .messages
252 .iter()
253 .rev()
254 .filter_map(|message| {
255 if let MessageType::ToolResult {
256 name,
257 success,
258 duration_ms: Some(duration_ms),
259 ..
260 } = &message.message_type
261 {
262 let icon = if *success { "OK" } else { "ER" };
263 Some(format!("[{icon}] {name} {}", format_duration(*duration_ms)))
264 } else {
265 None
266 }
267 })
268 .take(limit)
269 .collect()
270}
271
272fn format_duration(ms: u64) -> String {
273 match ms {
274 0..=999 => format!("{ms}ms"),
275 1_000..=9_999 => format!("{:.2}s", ms as f64 / 1_000.0),
276 10_000..=59_999 => format!("{:.1}s", ms as f64 / 1_000.0),
277 _ => format!("{:.1}m", ms as f64 / 60_000.0),
278 }
279}
280
281fn format_tps(tps: f64) -> String {
282 if tps >= 100.0 {
283 format!("{tps:.0}")
284 } else if tps >= 10.0 {
285 format!("{tps:.1}")
286 } else {
287 format!("{tps:.2}")
288 }
289}