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
7use 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/// Flattens newlines and truncates to `max_len`, appending `...` when cut.
19#[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/// Renders one row per distinct artifact id, keeping first-seen order.
262#[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/// Formats a trace metadata value for display, giving cost/latency/token keys
294/// their unit-aware form.
295#[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/// Pulls the event-type-appropriate latency field out of raw JSON metadata.
316#[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/// Renders trace events with inter-event deltas; empty input yields an empty
336/// string.
337#[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}