Skip to main content

codetether_agent/tui/
latency.rs

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}