Skip to main content

systemprompt_cli/shared/command_result/
render.rs

1//! Terminal rendering for [`CommandOutput`].
2//!
3//! `json`/`yaml` formats emit the [`CliArtifact`] verbatim; `table` renders per
4//! artifact variant for an interactive terminal.
5//!
6//! A [`Column`](systemprompt_models::artifacts::Column) carrying a `width`
7//! elides over-long cells for the terminal only. Shortening a value while
8//! building the row would corrupt `json`/`yaml` too, which is how
9//! `infra logs request list` came to emit ids no other command would accept.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use systemprompt_logging::CliService;
15use systemprompt_models::artifacts::{
16    ChartArtifact, CliArtifact, ListArtifact, PresentationCardArtifact, TableArtifact,
17};
18
19use systemprompt_models::text::truncate_with_ellipsis;
20
21use super::CommandOutput;
22use crate::cli_settings::{CliConfig, OutputFormat};
23
24pub fn render_result(result: &CommandOutput, config: &CliConfig) {
25    if result.should_skip_render() {
26        return;
27    }
28
29    match config.output_format() {
30        OutputFormat::Json => CliService::json(result.artifact()),
31        OutputFormat::Yaml => CliService::yaml(result.artifact()),
32        OutputFormat::Table => render_terminal(result),
33    }
34}
35
36fn render_terminal(result: &CommandOutput) {
37    if let Some(title) = result.title() {
38        CliService::section(title);
39    }
40
41    match result.artifact() {
42        CliArtifact::Text { artifact } => {
43            if result.title().is_none()
44                && let Some(title) = &artifact.title
45            {
46                CliService::section(title);
47            }
48            CliService::output(&artifact.content);
49        },
50        CliArtifact::CopyPasteText { artifact } => {
51            if result.title().is_none()
52                && let Some(title) = &artifact.title
53            {
54                CliService::section(title);
55            }
56            CliService::output(&artifact.content);
57        },
58        CliArtifact::Table { artifact } => render_table(artifact),
59        CliArtifact::List { artifact } => render_list(artifact),
60        CliArtifact::PresentationCard { artifact } => render_card(artifact),
61        CliArtifact::Dashboard { artifact } => {
62            if result.title().is_none() {
63                CliService::section(&artifact.title);
64            }
65            if let Some(description) = &artifact.description {
66                CliService::output(description);
67            }
68        },
69        CliArtifact::Chart { artifact } => render_chart(artifact),
70        CliArtifact::Audio { artifact } => CliService::output(&artifact.src),
71        CliArtifact::Image { artifact } => CliService::output(&artifact.src),
72        CliArtifact::Video { artifact } => CliService::output(&artifact.src),
73        CliArtifact::Message { artifact } => {
74            for line in &artifact.messages {
75                match line.level.as_str() {
76                    "success" => CliService::success(&line.text),
77                    "warning" => CliService::warning(&line.text),
78                    "error" => CliService::error(&line.text),
79                    _ => CliService::info(&line.text),
80                }
81            }
82        },
83    }
84}
85
86fn render_table(artifact: &TableArtifact) {
87    let headers: Vec<&str> = artifact
88        .columns
89        .iter()
90        .map(|c| c.label.as_deref().unwrap_or(&c.name))
91        .collect();
92
93    let rows: Vec<Vec<String>> = artifact
94        .items
95        .iter()
96        .map(|item| {
97            artifact
98                .columns
99                .iter()
100                .map(|col| {
101                    item.get(&col.name)
102                        .map_or_else(String::new, |v| cell_display_within(v, col.width))
103                })
104                .collect()
105        })
106        .collect();
107
108    CliService::table(&headers, &rows);
109}
110
111fn render_list(artifact: &ListArtifact) {
112    for item in &artifact.items {
113        CliService::subsection(&item.title);
114        if !item.summary.is_empty() {
115            CliService::output(&item.summary);
116        }
117        if !item.link.is_empty() {
118            CliService::output(&item.link);
119        }
120    }
121}
122
123fn render_card(artifact: &PresentationCardArtifact) {
124    CliService::section(&artifact.title);
125    if let Some(subtitle) = &artifact.subtitle {
126        CliService::output(subtitle);
127    }
128    for section in &artifact.sections {
129        CliService::subsection(&section.heading);
130        CliService::output(&section.content_display());
131    }
132}
133
134fn render_chart(artifact: &ChartArtifact) {
135    let mut headers: Vec<&str> = vec!["label"];
136    for dataset in &artifact.datasets {
137        headers.push(&dataset.label);
138    }
139
140    let rows: Vec<Vec<String>> = artifact
141        .labels
142        .iter()
143        .enumerate()
144        .map(|(row, label)| {
145            let mut cells = vec![label.clone()];
146            for dataset in &artifact.datasets {
147                cells.push(
148                    dataset
149                        .data
150                        .get(row)
151                        .map_or_else(String::new, ToString::to_string),
152                );
153            }
154            cells
155        })
156        .collect();
157
158    CliService::table(&headers, &rows);
159}
160
161fn cell_display(value: &serde_json::Value) -> String {
162    match value {
163        serde_json::Value::String(s) => s.clone(),
164        serde_json::Value::Null => String::new(),
165        other => other.to_string(),
166    }
167}
168
169fn cell_display_within(value: &serde_json::Value, width: Option<usize>) -> String {
170    let rendered = cell_display(value);
171    match width {
172        Some(width) => truncate_with_ellipsis(&rendered, width),
173        None => rendered,
174    }
175}