systemprompt_cli/presentation/
tables.rs1use systemprompt_logging::{
11 AiRequestInfo, ExecutionStep, McpToolExecution, TaskArtifact, TaskInfo, TraceEvent,
12};
13use tabled::settings::Style;
14use tabled::{Table, Tabled};
15
16use crate::commands::core::artifacts::ArtifactSummary;
17use crate::commands::core::contexts::ContextSummary;
18use crate::commands::infrastructure::db::TableInfo;
19use crate::shared::truncate_with_ellipsis;
20
21#[must_use]
22pub fn truncate_cell(s: &str, max_len: usize) -> String {
23 let s = s.replace('\n', " ").replace('\r', "");
24 if s.len() > max_len {
25 format!("{}...", &s[..max_len.saturating_sub(3)])
26 } else {
27 s
28 }
29}
30
31fn dash() -> String {
32 "-".to_owned()
33}
34
35fn millis(value: Option<impl std::fmt::Display>) -> String {
36 value.map_or_else(dash, |ms| format!("{ms}ms"))
37}
38
39#[derive(Tabled)]
40struct ArtifactListRow {
41 #[tabled(rename = "ID")]
42 id: String,
43 #[tabled(rename = "Name")]
44 name: String,
45 #[tabled(rename = "Type")]
46 artifact_type: String,
47 #[tabled(rename = "Tool")]
48 tool_name: String,
49 #[tabled(rename = "Created")]
50 created_at: String,
51}
52
53#[must_use]
54pub fn artifact_list_table(artifacts: &[ArtifactSummary]) -> String {
55 let rows: Vec<ArtifactListRow> = artifacts
56 .iter()
57 .map(|a| ArtifactListRow {
58 id: truncate_with_ellipsis(a.artifact_id.as_str(), 12),
59 name: a.name.clone().unwrap_or_else(dash),
60 artifact_type: a.artifact_type.clone(),
61 tool_name: a.tool_name.clone().unwrap_or_else(dash),
62 created_at: a.created_at.format("%Y-%m-%d %H:%M").to_string(),
63 })
64 .collect();
65 Table::new(rows).to_string()
66}
67
68#[derive(Tabled)]
69struct ContextListRow {
70 #[tabled(rename = "ID")]
71 id: String,
72 #[tabled(rename = "Name")]
73 name: String,
74 #[tabled(rename = "Tasks")]
75 task_count: i64,
76 #[tabled(rename = "Messages")]
77 message_count: i64,
78 #[tabled(rename = "Updated")]
79 updated_at: String,
80 #[tabled(rename = "Active")]
81 active: String,
82}
83
84#[must_use]
85pub fn context_list_table(contexts: &[ContextSummary]) -> String {
86 let rows: Vec<ContextListRow> = contexts
87 .iter()
88 .map(|c| ContextListRow {
89 id: c.id.as_str().chars().take(8).collect(),
90 name: truncate_with_ellipsis(&c.name, 40),
91 task_count: c.task_count,
92 message_count: c.message_count,
93 updated_at: c.updated_at.format("%Y-%m-%d %H:%M").to_string(),
94 active: if c.is_active {
95 "*".to_owned()
96 } else {
97 String::new()
98 },
99 })
100 .collect();
101 Table::new(rows).to_string()
102}
103
104#[derive(Tabled)]
105struct DbTableRow {
106 #[tabled(rename = "Table")]
107 name: String,
108 #[tabled(rename = "Rows")]
109 row_count: i64,
110 #[tabled(rename = "Size")]
111 size: String,
112}
113
114#[must_use]
115pub fn db_tables_table(tables: &[TableInfo]) -> String {
116 let rows: Vec<DbTableRow> = tables
117 .iter()
118 .map(|t| DbTableRow {
119 name: t.name.clone(),
120 row_count: t.row_count,
121 size: crate::commands::infrastructure::db::format_bytes(t.size_bytes),
122 })
123 .collect();
124 Table::new(rows).to_string()
125}
126
127#[derive(Tabled)]
128struct TaskInfoRow {
129 #[tabled(rename = "Task ID")]
130 task: String,
131 #[tabled(rename = "Agent")]
132 agent_name: String,
133 #[tabled(rename = "Status")]
134 status: String,
135 #[tabled(rename = "Started")]
136 started_at: String,
137 #[tabled(rename = "Duration")]
138 duration: String,
139}
140
141#[must_use]
142pub fn task_info_table(task_info: &TaskInfo) -> String {
143 let rows = vec![TaskInfoRow {
144 task: task_info.task_id.as_str().chars().take(8).collect(),
145 agent_name: task_info.agent_name.clone().unwrap_or_else(dash),
146 status: task_info.status.clone(),
147 started_at: task_info
148 .started_at
149 .map_or_else(dash, |t| t.format("%H:%M:%S").to_string()),
150 duration: millis(task_info.execution_time_ms),
151 }];
152 Table::new(rows).with(Style::rounded()).to_string()
153}
154
155#[derive(Tabled)]
156struct StepRow {
157 #[tabled(rename = "#")]
158 step_number: usize,
159 #[tabled(rename = "Type")]
160 step_type: String,
161 #[tabled(rename = "Title")]
162 title: String,
163 #[tabled(rename = "Status")]
164 status: String,
165 #[tabled(rename = "Duration")]
166 duration: String,
167}
168
169#[must_use]
170pub fn execution_steps_table(steps: &[ExecutionStep]) -> String {
171 let rows: Vec<StepRow> = steps
172 .iter()
173 .enumerate()
174 .map(|(i, s)| StepRow {
175 step_number: i + 1,
176 step_type: s.step_type.clone().unwrap_or_else(|| "unknown".to_owned()),
177 title: truncate_cell(s.title.as_deref().unwrap_or_default(), 40),
178 status: s.status.clone(),
179 duration: millis(s.duration_ms),
180 })
181 .collect();
182 Table::new(rows).with(Style::rounded()).to_string()
183}
184
185#[derive(Tabled)]
186struct AiRequestRow {
187 #[tabled(rename = "Model")]
188 model: String,
189 #[tabled(rename = "Max")]
190 max_tokens: String,
191 #[tabled(rename = "Tokens")]
192 tokens: String,
193 #[tabled(rename = "Cost")]
194 cost: String,
195 #[tabled(rename = "Latency")]
196 latency: String,
197}
198
199#[must_use]
200pub fn ai_requests_table(requests: &[AiRequestInfo]) -> String {
201 let rows: Vec<AiRequestRow> = requests
202 .iter()
203 .map(|r| AiRequestRow {
204 model: format!("{}/{}", r.provider, r.model),
205 max_tokens: r.max_tokens.map_or_else(dash, |t| t.to_string()),
206 tokens: format!(
207 "{} (in:{}, out:{})",
208 r.input_tokens.unwrap_or(0) + r.output_tokens.unwrap_or(0),
209 r.input_tokens.unwrap_or(0),
210 r.output_tokens.unwrap_or(0)
211 ),
212 #[expect(
213 clippy::cast_precision_loss,
214 reason = "display-only dollar conversion of microdollar totals"
215 )]
216 cost: format!("${:.4}", r.cost_microdollars as f64 / 1_000_000.0),
217 latency: millis(r.latency_ms),
218 })
219 .collect();
220 Table::new(rows).with(Style::rounded()).to_string()
221}
222
223#[derive(Tabled)]
224struct ToolCallRow {
225 #[tabled(rename = "Tool")]
226 tool_name: String,
227 #[tabled(rename = "Server")]
228 server: String,
229 #[tabled(rename = "Status")]
230 status: String,
231 #[tabled(rename = "Duration")]
232 duration: String,
233}
234
235#[must_use]
236pub fn mcp_tool_calls_table(executions: &[McpToolExecution]) -> String {
237 let rows: Vec<ToolCallRow> = executions
238 .iter()
239 .map(|e| ToolCallRow {
240 tool_name: e.tool_name.clone(),
241 server: e.server_name.clone(),
242 status: e.status.clone(),
243 duration: millis(e.execution_time_ms),
244 })
245 .collect();
246 Table::new(rows).with(Style::rounded()).to_string()
247}
248
249#[derive(Tabled)]
250struct TaskArtifactRow {
251 #[tabled(rename = "ID")]
252 artifact: String,
253 #[tabled(rename = "Type")]
254 artifact_type: String,
255 #[tabled(rename = "Name")]
256 name: String,
257 #[tabled(rename = "Source")]
258 source: String,
259 #[tabled(rename = "Tool")]
260 tool_name: String,
261}
262
263#[must_use]
264pub fn task_artifacts_table(artifacts: &[TaskArtifact]) -> String {
265 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
266 let rows: Vec<TaskArtifactRow> = artifacts
267 .iter()
268 .filter(|a| seen.insert(a.artifact_id.to_string()))
269 .map(|a| TaskArtifactRow {
270 artifact: truncate_cell(a.artifact_id.as_str(), 12),
271 artifact_type: a.artifact_type.clone(),
272 name: a.name.as_ref().map_or_else(dash, |s| truncate_cell(s, 30)),
273 source: a.source.clone().unwrap_or_else(dash),
274 tool_name: a.tool_name.clone().unwrap_or_else(dash),
275 })
276 .collect();
277 Table::new(&rows).with(Style::rounded()).to_string()
278}
279
280#[derive(Tabled)]
281struct TraceRow {
282 #[tabled(rename = "Time")]
283 time: String,
284 #[tabled(rename = "Delta")]
285 delta: String,
286 #[tabled(rename = "Type")]
287 event_type: String,
288 #[tabled(rename = "Details")]
289 details: String,
290 #[tabled(rename = "Latency")]
291 latency: String,
292}
293
294#[must_use]
295pub fn format_metadata_value(key: &str, value: &serde_json::Value) -> String {
296 let raw = || format!("{value}").trim_matches('"').to_owned();
297 match key {
298 "cost_microdollars" => value.as_i64().map_or_else(raw, |microdollars| {
299 #[expect(
300 clippy::cast_precision_loss,
301 reason = "display-only dollar conversion of microdollar totals"
302 )]
303 let dollars = microdollars as f64 / 1_000_000.0;
304 format!("${dollars:.6}")
305 }),
306 "latency_ms" | "execution_time_ms" => {
307 value.as_i64().map_or_else(raw, |ms| format!("{ms}ms"))
308 },
309 "tokens_used" => value.as_i64().map_or_else(raw, |tokens| tokens.to_string()),
310 _ => raw(),
311 }
312}
313
314#[must_use]
315pub fn extract_latency_from_metadata(metadata: Option<&str>, event_type: &str) -> String {
316 if let Some(meta) = metadata
317 && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(meta)
318 {
319 let key = match event_type {
320 "AI" => Some("latency_ms"),
321 "MCP" => Some("execution_time_ms"),
322 _ => None,
323 };
324 if let Some(key) = key
325 && let Some(ms) = parsed.get(key).and_then(serde_json::Value::as_i64)
326 {
327 return format!("{ms}ms");
328 }
329 }
330 dash()
331}
332
333#[must_use]
334pub fn trace_events_table(events: &[TraceEvent]) -> String {
335 let mut prev_timestamp: Option<chrono::DateTime<chrono::Utc>> = None;
336 let rows: Vec<TraceRow> = events
337 .iter()
338 .map(|e| {
339 let delta = prev_timestamp.map_or_else(
340 || "+0ms".to_owned(),
341 |prev| {
342 let delta_ms = e.timestamp.signed_duration_since(prev).num_milliseconds();
343 format!("+{delta_ms}ms")
344 },
345 );
346 prev_timestamp = Some(e.timestamp);
347 TraceRow {
348 time: e.timestamp.format("%H:%M:%S%.3f").to_string(),
349 delta,
350 event_type: e.event_type.clone(),
351 details: truncate_cell(&e.details, 100),
352 latency: extract_latency_from_metadata(e.metadata.as_deref(), &e.event_type),
353 }
354 })
355 .collect();
356
357 if rows.is_empty() {
358 String::new()
359 } else {
360 Table::new(rows).with(Style::modern()).to_string()
361 }
362}