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