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 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}