Skip to main content

systemprompt_cli/presentation/
tables.rs

1//! Reusable `tabled` table widgets for command output.
2//!
3//! Pure shaping and rendering: each function turns domain records into a
4//! rendered table string. Callers decide where the string is printed, so the
5//! row shaping stays testable without a terminal.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use 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}