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 flattened = s.replace('\n', " ").replace('\r', "");
24 truncate_with_ellipsis(&flattened, max_len)
25}
26
27fn dash() -> String {
28 "-".to_owned()
29}
30
31fn millis(value: Option<impl std::fmt::Display>) -> String {
32 value.map_or_else(dash, |ms| format!("{ms}ms"))
33}
34
35#[derive(Tabled)]
36struct ArtifactListRow {
37 #[tabled(rename = "ID")]
38 id: String,
39 #[tabled(rename = "Name")]
40 name: String,
41 #[tabled(rename = "Type")]
42 artifact_type: String,
43 #[tabled(rename = "Tool")]
44 tool_name: String,
45 #[tabled(rename = "Created")]
46 created_at: String,
47}
48
49#[must_use]
50pub fn artifact_list_table(artifacts: &[ArtifactSummary]) -> String {
51 let rows: Vec<ArtifactListRow> = artifacts
52 .iter()
53 .map(|a| ArtifactListRow {
54 id: truncate_with_ellipsis(a.artifact_id.as_str(), 12),
55 name: a.name.clone().unwrap_or_else(dash),
56 artifact_type: a.artifact_type.clone(),
57 tool_name: a.tool_name.clone().unwrap_or_else(dash),
58 created_at: a.created_at.format("%Y-%m-%d %H:%M").to_string(),
59 })
60 .collect();
61 Table::new(rows).to_string()
62}
63
64#[derive(Tabled)]
65struct ContextListRow {
66 #[tabled(rename = "ID")]
67 id: String,
68 #[tabled(rename = "Name")]
69 name: String,
70 #[tabled(rename = "Tasks")]
71 task_count: i64,
72 #[tabled(rename = "Messages")]
73 message_count: i64,
74 #[tabled(rename = "Updated")]
75 updated_at: String,
76 #[tabled(rename = "Active")]
77 active: String,
78}
79
80#[must_use]
81pub fn context_list_table(contexts: &[ContextSummary]) -> String {
82 let rows: Vec<ContextListRow> = contexts
83 .iter()
84 .map(|c| ContextListRow {
85 id: c.id.as_str().chars().take(8).collect(),
86 name: truncate_with_ellipsis(&c.name, 40),
87 task_count: c.task_count,
88 message_count: c.message_count,
89 updated_at: c.updated_at.format("%Y-%m-%d %H:%M").to_string(),
90 active: if c.is_active {
91 "*".to_owned()
92 } else {
93 String::new()
94 },
95 })
96 .collect();
97 Table::new(rows).to_string()
98}
99
100#[derive(Tabled)]
101struct DbTableRow {
102 #[tabled(rename = "Table")]
103 name: String,
104 #[tabled(rename = "Rows")]
105 row_count: i64,
106 #[tabled(rename = "Size")]
107 size: String,
108}
109
110#[must_use]
111pub fn db_tables_table(tables: &[TableInfo]) -> String {
112 let rows: Vec<DbTableRow> = tables
113 .iter()
114 .map(|t| DbTableRow {
115 name: t.name.clone(),
116 row_count: t.row_count,
117 size: crate::commands::infrastructure::db::format_bytes(t.size_bytes),
118 })
119 .collect();
120 Table::new(rows).to_string()
121}
122
123#[derive(Tabled)]
124struct TaskInfoRow {
125 #[tabled(rename = "Task ID")]
126 task: String,
127 #[tabled(rename = "Agent")]
128 agent_name: String,
129 #[tabled(rename = "Status")]
130 status: String,
131 #[tabled(rename = "Started")]
132 started_at: String,
133 #[tabled(rename = "Duration")]
134 duration: String,
135}
136
137#[must_use]
138pub fn task_info_table(task_info: &TaskInfo) -> String {
139 let rows = vec![TaskInfoRow {
140 task: task_info.task_id.as_str().chars().take(8).collect(),
141 agent_name: task_info.agent_name.clone().unwrap_or_else(dash),
142 status: task_info.status.clone(),
143 started_at: task_info
144 .started_at
145 .map_or_else(dash, |t| t.format("%H:%M:%S").to_string()),
146 duration: millis(task_info.execution_time_ms),
147 }];
148 Table::new(rows).with(Style::rounded()).to_string()
149}
150
151#[derive(Tabled)]
152struct StepRow {
153 #[tabled(rename = "#")]
154 step_number: usize,
155 #[tabled(rename = "Type")]
156 step_type: String,
157 #[tabled(rename = "Title")]
158 title: String,
159 #[tabled(rename = "Status")]
160 status: String,
161 #[tabled(rename = "Duration")]
162 duration: String,
163}
164
165#[must_use]
166pub fn execution_steps_table(steps: &[ExecutionStep]) -> String {
167 let rows: Vec<StepRow> = steps
168 .iter()
169 .enumerate()
170 .map(|(i, s)| StepRow {
171 step_number: i + 1,
172 step_type: s.step_type.clone().unwrap_or_else(|| "unknown".to_owned()),
173 title: truncate_cell(s.title.as_deref().unwrap_or_default(), 40),
174 status: s.status.clone(),
175 duration: millis(s.duration_ms),
176 })
177 .collect();
178 Table::new(rows).with(Style::rounded()).to_string()
179}
180
181#[derive(Tabled)]
182struct AiRequestRow {
183 #[tabled(rename = "Model")]
184 model: String,
185 #[tabled(rename = "Max")]
186 max_tokens: String,
187 #[tabled(rename = "Tokens")]
188 tokens: String,
189 #[tabled(rename = "Cost")]
190 cost: String,
191 #[tabled(rename = "Latency")]
192 latency: String,
193}
194
195#[must_use]
196pub fn ai_requests_table(requests: &[AiRequestInfo]) -> String {
197 let rows: Vec<AiRequestRow> = requests
198 .iter()
199 .map(|r| AiRequestRow {
200 model: format!(
201 "{}/{}",
202 r.provider.as_deref().unwrap_or("-"),
203 r.model.as_deref().unwrap_or("-")
204 ),
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}