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    match config.output_format() {
26        OutputFormat::Json => CliService::json(result.artifact()),
27        OutputFormat::Yaml => CliService::yaml(result.artifact()),
28        OutputFormat::Table if result.should_skip_render() => {},
29        OutputFormat::Table => render_terminal(result),
30    }
31}
32
33fn render_terminal(result: &CommandOutput) {
34    if let Some(title) = result.title() {
35        CliService::section(title);
36    }
37
38    match result.artifact() {
39        CliArtifact::Text { artifact } => {
40            if result.title().is_none()
41                && let Some(title) = &artifact.title
42            {
43                CliService::section(title);
44            }
45            CliService::output(&artifact.content);
46        },
47        CliArtifact::CopyPasteText { artifact } => {
48            if result.title().is_none()
49                && let Some(title) = &artifact.title
50            {
51                CliService::section(title);
52            }
53            CliService::output(&artifact.content);
54        },
55        CliArtifact::Table { artifact } => render_table(artifact),
56        CliArtifact::List { artifact } => render_list(artifact),
57        CliArtifact::PresentationCard { artifact } => render_card(artifact),
58        CliArtifact::Dashboard { artifact } => {
59            if result.title().is_none() {
60                CliService::section(&artifact.title);
61            }
62            if let Some(description) = &artifact.description {
63                CliService::output(description);
64            }
65        },
66        CliArtifact::Chart { artifact } => render_chart(artifact),
67        CliArtifact::Audio { artifact } => CliService::output(&artifact.src),
68        CliArtifact::Image { artifact } => CliService::output(&artifact.src),
69        CliArtifact::Video { artifact } => CliService::output(&artifact.src),
70        CliArtifact::Message { artifact } => {
71            for line in &artifact.messages {
72                match line.level.as_str() {
73                    "success" => CliService::success(&line.text),
74                    "warning" => CliService::warning(&line.text),
75                    "error" => CliService::error(&line.text),
76                    _ => CliService::info(&line.text),
77                }
78            }
79        },
80    }
81}
82
83fn render_table(artifact: &TableArtifact) {
84    let headers: Vec<&str> = artifact
85        .columns
86        .iter()
87        .map(|c| c.label.as_deref().unwrap_or(&c.name))
88        .collect();
89
90    let rows: Vec<Vec<String>> = artifact
91        .items
92        .iter()
93        .map(|item| {
94            artifact
95                .columns
96                .iter()
97                .map(|col| {
98                    item.get(&col.name)
99                        .map_or_else(String::new, |v| cell_display_within(v, col.width))
100                })
101                .collect()
102        })
103        .collect();
104
105    CliService::table(&headers, &rows);
106}
107
108fn render_list(artifact: &ListArtifact) {
109    for item in &artifact.items {
110        CliService::subsection(&item.title);
111        if !item.summary.is_empty() {
112            CliService::output(&item.summary);
113        }
114        if !item.link.is_empty() {
115            CliService::output(&item.link);
116        }
117    }
118}
119
120fn render_card(artifact: &PresentationCardArtifact) {
121    CliService::section(&artifact.title);
122    if let Some(subtitle) = &artifact.subtitle {
123        CliService::output(subtitle);
124    }
125    for section in &artifact.sections {
126        CliService::subsection(&section.heading);
127        CliService::output(&section.content_display());
128    }
129}
130
131fn render_chart(artifact: &ChartArtifact) {
132    let mut headers: Vec<&str> = vec!["label"];
133    for dataset in &artifact.datasets {
134        headers.push(&dataset.label);
135    }
136
137    let rows: Vec<Vec<String>> = artifact
138        .labels
139        .iter()
140        .enumerate()
141        .map(|(row, label)| {
142            let mut cells = vec![label.clone()];
143            for dataset in &artifact.datasets {
144                cells.push(
145                    dataset
146                        .data
147                        .get(row)
148                        .map_or_else(String::new, ToString::to_string),
149                );
150            }
151            cells
152        })
153        .collect();
154
155    CliService::table(&headers, &rows);
156}
157
158fn cell_display(value: &serde_json::Value) -> String {
159    match value {
160        serde_json::Value::String(s) => s.clone(),
161        serde_json::Value::Null => String::new(),
162        other => other.to_string(),
163    }
164}
165
166fn cell_display_within(value: &serde_json::Value, width: Option<usize>) -> String {
167    let rendered = cell_display(value);
168    match width {
169        Some(width) => truncate_with_ellipsis(&rendered, width),
170        None => rendered,
171    }
172}