systemprompt_cli/presentation/tables/
trace.rs1use systemprompt_identifiers::{AgentName, McpServerId, McpToolName};
8use systemprompt_runtime::{
9 AiRequestInfo, ExecutionStep, McpToolExecution, TaskArtifact, TaskInfo, TraceEvent,
10};
11use tabled::settings::Style;
12use tabled::{Table, Tabled};
13
14use super::{OrDash, dash, millis, truncate_cell};
15
16#[derive(Tabled)]
17struct TaskInfoRow {
18 #[tabled(rename = "Task ID")]
19 task: String,
20 #[tabled(rename = "Agent")]
21 agent_name: OrDash<AgentName>,
22 #[tabled(rename = "Status")]
23 status: String,
24 #[tabled(rename = "Started")]
25 started_at: String,
26 #[tabled(rename = "Duration")]
27 duration: String,
28}
29
30#[must_use]
31pub fn task_info_table(task_info: &TaskInfo) -> String {
32 let rows = vec![TaskInfoRow {
33 task: task_info.task_id.as_str().chars().take(8).collect(),
34 agent_name: OrDash(task_info.agent_name.clone()),
35 status: task_info.status.clone(),
36 started_at: task_info
37 .started_at
38 .map_or_else(dash, |t| t.format("%H:%M:%S").to_string()),
39 duration: millis(task_info.execution_time_ms),
40 }];
41 Table::new(rows).with(Style::rounded()).to_string()
42}
43
44#[derive(Tabled)]
45struct StepRow {
46 #[tabled(rename = "#")]
47 step_number: usize,
48 #[tabled(rename = "Type")]
49 step_type: String,
50 #[tabled(rename = "Title")]
51 title: String,
52 #[tabled(rename = "Status")]
53 status: String,
54 #[tabled(rename = "Duration")]
55 duration: String,
56}
57
58#[must_use]
59pub fn execution_steps_table(steps: &[ExecutionStep]) -> String {
60 let rows: Vec<StepRow> = steps
61 .iter()
62 .enumerate()
63 .map(|(i, s)| StepRow {
64 step_number: i + 1,
65 step_type: s.step_type.clone().unwrap_or_else(|| "unknown".to_owned()),
66 title: truncate_cell(s.title.as_deref().unwrap_or_default(), 40),
67 status: s.status.clone(),
68 duration: millis(s.duration_ms),
69 })
70 .collect();
71 Table::new(rows).with(Style::rounded()).to_string()
72}
73
74#[derive(Tabled)]
75struct AiRequestRow {
76 #[tabled(rename = "Model")]
77 model: String,
78 #[tabled(rename = "Max")]
79 max_tokens: String,
80 #[tabled(rename = "Tokens")]
81 tokens: String,
82 #[tabled(rename = "Cost")]
83 cost: String,
84 #[tabled(rename = "Latency")]
85 latency: String,
86}
87
88#[must_use]
89pub fn ai_requests_table(requests: &[AiRequestInfo]) -> String {
90 let rows: Vec<AiRequestRow> = requests
91 .iter()
92 .map(|r| AiRequestRow {
93 model: format!(
94 "{}/{}",
95 r.provider.as_deref().unwrap_or("-"),
96 r.model.as_deref().unwrap_or("-")
97 ),
98 max_tokens: r.max_tokens.map_or_else(dash, |t| t.to_string()),
99 tokens: format!(
100 "{} (in:{}, out:{})",
101 r.input_tokens.unwrap_or(0) + r.output_tokens.unwrap_or(0),
102 r.input_tokens.unwrap_or(0),
103 r.output_tokens.unwrap_or(0)
104 ),
105 #[expect(
106 clippy::cast_precision_loss,
107 reason = "display-only dollar conversion of microdollar totals"
108 )]
109 cost: format!("${:.4}", r.cost_microdollars as f64 / 1_000_000.0),
110 latency: millis(r.latency_ms),
111 })
112 .collect();
113 Table::new(rows).with(Style::rounded()).to_string()
114}
115
116#[derive(Tabled)]
117struct ToolCallRow {
118 #[tabled(rename = "Tool")]
119 tool_name: McpToolName,
120 #[tabled(rename = "Server")]
121 server: McpServerId,
122 #[tabled(rename = "Status")]
123 status: String,
124 #[tabled(rename = "Duration")]
125 duration: String,
126}
127
128#[must_use]
129pub fn mcp_tool_calls_table(executions: &[McpToolExecution]) -> String {
130 let rows: Vec<ToolCallRow> = executions
131 .iter()
132 .map(|e| ToolCallRow {
133 tool_name: e.tool_name.clone(),
134 server: e.server_name.clone(),
135 status: e.status.clone(),
136 duration: millis(e.execution_time_ms),
137 })
138 .collect();
139 Table::new(rows).with(Style::rounded()).to_string()
140}
141
142#[derive(Tabled)]
143struct TaskArtifactRow {
144 #[tabled(rename = "ID")]
145 artifact: String,
146 #[tabled(rename = "Type")]
147 artifact_type: String,
148 #[tabled(rename = "Name")]
149 name: String,
150 #[tabled(rename = "Source")]
151 source: String,
152 #[tabled(rename = "Tool")]
153 tool_name: OrDash<McpToolName>,
154}
155
156#[must_use]
157pub fn task_artifacts_table(artifacts: &[TaskArtifact]) -> String {
158 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
159 let rows: Vec<TaskArtifactRow> = artifacts
160 .iter()
161 .filter(|a| seen.insert(a.artifact_id.to_string()))
162 .map(|a| TaskArtifactRow {
163 artifact: truncate_cell(a.artifact_id.as_str(), 12),
164 artifact_type: a.artifact_type.clone(),
165 name: a.name.as_ref().map_or_else(dash, |s| truncate_cell(s, 30)),
166 source: a.source.clone().unwrap_or_else(dash),
167 tool_name: OrDash(a.tool_name.clone()),
168 })
169 .collect();
170 Table::new(&rows).with(Style::rounded()).to_string()
171}
172
173#[derive(Tabled)]
174struct TraceRow {
175 #[tabled(rename = "Time")]
176 time: String,
177 #[tabled(rename = "Delta")]
178 delta: String,
179 #[tabled(rename = "Type")]
180 event_type: String,
181 #[tabled(rename = "Details")]
182 details: String,
183 #[tabled(rename = "Latency")]
184 latency: String,
185}
186
187#[must_use]
188pub fn format_metadata_value(key: &str, value: &serde_json::Value) -> String {
190 let raw = || format!("{value}").trim_matches('"').to_owned();
191 match key {
192 "cost_microdollars" => value.as_i64().map_or_else(raw, |microdollars| {
193 #[expect(
194 clippy::cast_precision_loss,
195 reason = "display-only dollar conversion of microdollar totals"
196 )]
197 let dollars = microdollars as f64 / 1_000_000.0;
198 format!("${dollars:.6}")
199 }),
200 "latency_ms" | "execution_time_ms" => {
201 value.as_i64().map_or_else(raw, |ms| format!("{ms}ms"))
202 },
203 "tokens_used" => value.as_i64().map_or_else(raw, |tokens| tokens.to_string()),
204 _ => raw(),
205 }
206}
207
208#[must_use]
209pub fn extract_latency_from_metadata(metadata: Option<&str>, event_type: &str) -> String {
210 if let Some(meta) = metadata
211 && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(meta)
212 {
213 let key = match event_type {
214 "AI" => Some("latency_ms"),
215 "MCP" => Some("execution_time_ms"),
216 _ => None,
217 };
218 if let Some(key) = key
219 && let Some(ms) = parsed.get(key).and_then(serde_json::Value::as_i64)
220 {
221 return format!("{ms}ms");
222 }
223 }
224 dash()
225}
226
227#[must_use]
228pub fn trace_events_table(events: &[TraceEvent]) -> String {
229 let mut prev_timestamp: Option<chrono::DateTime<chrono::Utc>> = None;
230 let rows: Vec<TraceRow> = events
231 .iter()
232 .map(|e| {
233 let delta = prev_timestamp.map_or_else(
234 || "+0ms".to_owned(),
235 |prev| {
236 let delta_ms = e.timestamp.signed_duration_since(prev).num_milliseconds();
237 format!("+{delta_ms}ms")
238 },
239 );
240 prev_timestamp = Some(e.timestamp);
241 TraceRow {
242 time: e.timestamp.format("%H:%M:%S%.3f").to_string(),
243 delta,
244 event_type: e.event_type.clone(),
245 details: truncate_cell(&e.details, 100),
246 latency: extract_latency_from_metadata(e.metadata.as_deref(), &e.event_type),
247 }
248 })
249 .collect();
250
251 if rows.is_empty() {
252 String::new()
253 } else {
254 Table::new(rows).with(Style::modern()).to_string()
255 }
256}