Skip to main content

saya_cli/render/
mod.rs

1use saya_agent::{
2    KnowledgeOutcome, LearningSkipReason, OverrideFindingDto, ProposedClaimDto, SuppliedContractDto,
3};
4use saya_config::OutputFormat;
5use saya_types::{QueryResult, SchemaTree};
6use serde::Serialize;
7mod contract_view;
8mod io_view;
9mod render_contract;
10mod render_delta;
11mod render_io;
12mod render_json;
13mod render_learned;
14mod render_memory;
15pub use contract_view::{
16    ContractClaimView, ContractConflictView, ContractQueueItemView, ContractView,
17};
18pub use io_view::{ContractExportView, ContractImportClaimView, ContractImportView};
19/// Re-exported for the TUI, which renders [`AgentEvent::KnowledgeProposed`] in
20/// `apply_event` and shares this shaper so the wording lives in one place.
21pub(crate) use render_learned::knowledge_learned_text;
22/// Re-exported for the TUI, which renders [`AgentEvent::KnowledgeOverridden`] in
23/// `apply_event` and shares this shaper so the wording lives in one place (A1).
24pub(crate) use render_memory::knowledge_overridden_text;
25/// Re-exported for the TUI, which renders [`AgentEvent::KnowledgeSupplied`] in
26/// `apply_event` and shares this shaper so the wording lives in one place.
27pub(crate) use render_memory::knowledge_supplied_text;
28/// Re-exported for the TUI, which renders [`AgentEvent::KnowledgeLearningSkipped`]
29/// in `apply_event` and shares this shaper so the wording lives in one place
30/// (packet-54).
31pub(crate) use render_memory::learning_skipped_text;
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum RenderFormat {
34    Text,
35    Json,
36    Ndjson,
37}
38impl From<crate::cli::FormatArg> for RenderFormat {
39    fn from(value: crate::cli::FormatArg) -> Self {
40        match value {
41            crate::cli::FormatArg::Text => Self::Text,
42            crate::cli::FormatArg::Json => Self::Json,
43            crate::cli::FormatArg::Ndjson => Self::Ndjson,
44        }
45    }
46}
47impl From<OutputFormat> for RenderFormat {
48    fn from(value: OutputFormat) -> Self {
49        match value {
50            OutputFormat::Text => Self::Text,
51            OutputFormat::Json => Self::Json,
52            OutputFormat::Ndjson => Self::Ndjson,
53        }
54    }
55}
56#[derive(Debug, Clone, Serialize, PartialEq)]
57#[serde(tag = "event", rename_all = "snake_case")]
58pub enum TerminalEvent {
59    AssistantText {
60        text: String,
61    },
62    ToolRequested {
63        name: String,
64        #[serde(default, skip_serializing_if = "Option::is_none")]
65        detail: Option<String>,
66    },
67    ToolCompleted {
68        name: String,
69        summary: String,
70    },
71    ToolDenied {
72        name: String,
73        reason: String,
74    },
75    /// What memory **supplied** to the turn, emitted once before the provider
76    /// call (spec P1c). Carries the outcome, the supplied contracts (claim DTOs,
77    /// no opaque identity), and the count the bounds dropped. Text is shaped in
78    /// [`render_memory`]; JSON/NDJSON fall out of the serde derive.
79    KnowledgeSupplied {
80        outcome: KnowledgeOutcome,
81        contracts: Vec<SuppliedContractDto>,
82        dropped_by_bounds: usize,
83    },
84    /// One fact SAYA came away from the turn knowing (`AgentEvent::KnowledgeProposed`).
85    /// Emitted once per learned claim, after the answer. Text is shaped in
86    /// [`render_learned`] and carries no claim id — learning is not something the
87    /// user asked for, so it must not hand them a hash to manage. JSON/NDJSON keep
88    /// the DTO whole, id included, for machine consumers.
89    KnowledgeLearned {
90        claim: ProposedClaimDto,
91    },
92    /// A confirmed claim the turn's SQL **contradicted** (spec A1). Emitted at
93    /// most once per turn, after the loop, carrying every finding the detector
94    /// raised. The finding says the SQL **referenced** columns, never that it
95    /// **used** them — the extractor cannot prove role. Text is shaped in
96    /// [`render_memory`]; JSON/NDJSON fall out of the serde derive.
97    KnowledgeOverridden {
98        findings: Vec<OverrideFindingDto>,
99    },
100    /// Post-turn extraction was skipped after the turn succeeded — no memory
101    /// was recorded, and the line says so (spec packet-54). Trails the answer.
102    /// Text is shaped in [`render_memory`]; JSON/NDJSON fall out of the serde
103    /// derive.
104    KnowledgeLearningSkipped {
105        reason: LearningSkipReason,
106    },
107    Complete,
108    Result {
109        message: String,
110    },
111    QueryResult {
112        result: QueryResult,
113    },
114    Schema {
115        schema: SchemaTree,
116    },
117    NotImplemented {
118        feature: String,
119    },
120    Diagnostic {
121        message: String,
122    },
123    Error {
124        message: String,
125    },
126    ContractList {
127        contracts: Vec<ContractView>,
128    },
129    ContractShow {
130        contract: ContractView,
131    },
132    ContractChanged {
133        claim_id: String,
134        action: String,
135        status: String,
136    },
137    ContractRemembered {
138        /// Carried for machine consumers only. The text renderer never prints
139        /// it: a 64-character hash is the system's business, and a script that
140        /// remembers then forgets still needs a handle without a second call.
141        claim_id: String,
142        object: String,
143        kind: String,
144        value: String,
145        #[serde(default, skip_serializing_if = "Option::is_none")]
146        column: Option<String>,
147        #[serde(default, skip_serializing_if = "Option::is_none")]
148        previous: Option<String>,
149        action: String,
150        status: String,
151    },
152    ContractQueue {
153        items: Vec<ContractQueueItemView>,
154    },
155    ContractImport {
156        report: ContractImportView,
157    },
158    ContractExport {
159        report: ContractExportView,
160    },
161}
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct Rendered {
164    pub stdout: String,
165    pub stderr: String,
166}
167
168pub fn render_event(event: &TerminalEvent, format: RenderFormat) -> Rendered {
169    match format {
170        RenderFormat::Text => text_event(event),
171        RenderFormat::Json | RenderFormat::Ndjson => render_json::render(event),
172    }
173}
174
175pub(super) fn sanitize_terminal(s: &str) -> String {
176    let mut out = String::with_capacity(s.len());
177    for c in s.chars() {
178        match c {
179            '\n' | '\t' => out.push(c),
180            '\x00'..='\x1F' | '\x7F' | '\u{0080}'..='\u{009F}' => {}
181            _ => out.push(c),
182        }
183    }
184    out
185}
186
187fn text_event(event: &TerminalEvent) -> Rendered {
188    let rendered = match event {
189        TerminalEvent::Diagnostic { message } | TerminalEvent::Error { message } => Rendered {
190            stdout: String::new(),
191            stderr: format!("{message}\n"),
192        },
193        TerminalEvent::AssistantText { text } => render_delta::text(text),
194        TerminalEvent::ToolRequested { name, detail } => Rendered {
195            stdout: match detail {
196                Some(detail) => format!("Using read-only tool: {name}\n  {detail}\n"),
197                None => format!("Using read-only tool: {name}\n"),
198            },
199            stderr: String::new(),
200        },
201        TerminalEvent::ToolCompleted { name, summary } => Rendered {
202            stdout: format!("{name}: {summary}\n"),
203            stderr: String::new(),
204        },
205        TerminalEvent::ToolDenied { name, reason } => Rendered {
206            stdout: format!("Approval denied for {name}: {reason}\n"),
207            stderr: String::new(),
208        },
209        TerminalEvent::KnowledgeSupplied {
210            outcome,
211            contracts,
212            dropped_by_bounds,
213        } => Rendered {
214            stdout: render_memory::knowledge_supplied_text(*outcome, contracts, *dropped_by_bounds),
215            stderr: String::new(),
216        },
217        TerminalEvent::KnowledgeLearned { claim } => Rendered {
218            stdout: render_learned::knowledge_learned_text(claim),
219            stderr: String::new(),
220        },
221        TerminalEvent::KnowledgeOverridden { findings } => Rendered {
222            stdout: render_memory::knowledge_overridden_text(findings),
223            stderr: String::new(),
224        },
225        TerminalEvent::KnowledgeLearningSkipped { reason } => Rendered {
226            stdout: render_memory::learning_skipped_text(*reason),
227            stderr: String::new(),
228        },
229        TerminalEvent::Complete => Rendered {
230            stdout: "\n".into(),
231            stderr: String::new(),
232        },
233        TerminalEvent::Result { message } => Rendered {
234            stdout: format!("{message}\n"),
235            stderr: String::new(),
236        },
237        TerminalEvent::QueryResult { result } => Rendered {
238            stdout: query_text(result),
239            stderr: String::new(),
240        },
241        TerminalEvent::Schema { schema } => Rendered {
242            stdout: format!("{}\n", schema_text(schema)),
243            stderr: String::new(),
244        },
245        TerminalEvent::NotImplemented { feature } => Rendered {
246            stdout: format!("Not implemented: {feature}\n"),
247            stderr: String::new(),
248        },
249        TerminalEvent::ContractList { contracts } => render_contract::list(contracts),
250        TerminalEvent::ContractShow { contract } => render_contract::show(contract),
251        TerminalEvent::ContractChanged {
252            claim_id,
253            action,
254            status,
255        } => render_contract::changed(claim_id, action, status),
256        TerminalEvent::ContractRemembered {
257            claim_id: _,
258            object,
259            kind,
260            value,
261            column,
262            previous,
263            action,
264            status,
265        } => render_contract::remembered(
266            object,
267            kind,
268            value,
269            column.as_deref(),
270            previous.as_deref(),
271            action,
272            status,
273        ),
274        TerminalEvent::ContractQueue { items } => render_contract::queue(items),
275        TerminalEvent::ContractImport { report } => render_io::import(report),
276        TerminalEvent::ContractExport { report } => render_io::export(report),
277    };
278    Rendered {
279        stdout: sanitize_terminal(&rendered.stdout),
280        stderr: sanitize_terminal(&rendered.stderr),
281    }
282}
283
284fn query_text(result: &QueryResult) -> String {
285    let mut output = result.columns.join("\t");
286    if !output.is_empty() {
287        output.push('\n');
288    }
289    for row in &result.rows {
290        let value = match row {
291            serde_json::Value::Array(values) => values
292                .iter()
293                .map(display_value)
294                .collect::<Vec<_>>()
295                .join("\t"),
296            value => display_value(value),
297        };
298        output.push_str(&value);
299        output.push('\n');
300    }
301    if result.truncated {
302        output.push_str("[truncated]\n");
303    }
304    output
305}
306
307fn display_value(value: &serde_json::Value) -> String {
308    match value {
309        serde_json::Value::String(value) => value.clone(),
310        value => value.to_string(),
311    }
312}
313
314fn schema_text(schema: &SchemaTree) -> String {
315    schema
316        .databases
317        .iter()
318        .flat_map(|database| {
319            database.schemas.iter().flat_map(move |schema| {
320                schema
321                    .tables
322                    .iter()
323                    .map(move |table| format!("{}.{}.{}", database.name, schema.name, table.name))
324            })
325        })
326        .collect::<Vec<_>>()
327        .join("\n")
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_sanitize_terminal_strips_control_bytes_and_preserves_tabs_and_newlines() {
336        let input = "hello\x1b[31mRED\x1b[0m\tworld\n\x1b]0;pwned\x07\r\x7f\u{0080}\u{009f}";
337        let sanitized = sanitize_terminal(input);
338        assert_eq!(sanitized, "hello[31mRED[0m\tworld\n]0;pwned");
339        assert!(!sanitized.contains('\x1b'));
340        assert!(!sanitized.contains('\x07'));
341        assert!(!sanitized.contains('\r'));
342        assert!(!sanitized.contains('\x7f'));
343        assert!(!sanitized.contains('\u{0080}'));
344        assert!(!sanitized.contains('\u{009f}'));
345    }
346
347    #[test]
348    fn test_text_render_sanitizes_terminal_control_sequences() {
349        let raw_text = "col1\x1b[31mRED\x1b[0m\tcol2\x1b]0;pwned\x07";
350        let event = TerminalEvent::QueryResult {
351            result: QueryResult {
352                columns: vec!["col1".into(), "col2".into()],
353                rows: vec![serde_json::json!([raw_text, "ok"])],
354                row_count: 1,
355                truncated: false,
356                executed_sql: "SELECT 1".into(),
357            },
358        };
359
360        let rendered_text = render_event(&event, RenderFormat::Text);
361        assert!(!rendered_text.stdout.contains('\x1b'));
362        assert!(!rendered_text.stdout.contains('\x07'));
363        assert!(rendered_text.stdout.contains("col1[31mRED[0m"));
364        assert!(rendered_text.stdout.contains("pwned"));
365        assert!(rendered_text.stdout.contains('\t'));
366        assert!(rendered_text.stdout.contains('\n'));
367
368        let rendered_json = render_event(&event, RenderFormat::Json);
369        assert!(rendered_json.stdout.contains("\\u001b[31mRED\\u001b[0m"));
370        assert!(rendered_json.stdout.contains("\\u001b]0;pwned\\u0007"));
371    }
372
373    #[test]
374    fn test_assistant_text_and_delta_sanitizes_control_sequences() {
375        let raw = "\x1b[31mRED\x1b[0m\x1b]0;pwned\x07";
376        let delta_rendered = render_delta::text(raw);
377        assert!(!delta_rendered.stdout.contains('\x1b'));
378        assert!(!delta_rendered.stdout.contains('\x07'));
379        assert_eq!(delta_rendered.stdout, "[31mRED[0m]0;pwned");
380
381        let event = TerminalEvent::AssistantText {
382            text: raw.to_string(),
383        };
384        let text_rendered = render_event(&event, RenderFormat::Text);
385        assert!(!text_rendered.stdout.contains('\x1b'));
386        assert!(!text_rendered.stdout.contains('\x07'));
387
388        let json_rendered = render_event(&event, RenderFormat::Json);
389        assert!(json_rendered.stdout.contains("\\u001b"));
390    }
391
392    #[test]
393    fn test_contract_remembered_replaced_render() {
394        let event = TerminalEvent::ContractRemembered {
395            claim_id: "ki-123".to_string(),
396            object: "pagila.public.rental".to_string(),
397            kind: "grain".to_string(),
398            value: "one row per rental per day".to_string(),
399            column: None,
400            previous: Some("one row per rental".to_string()),
401            action: "replaced".to_string(),
402            status: "confirmed".to_string(),
403        };
404        let text_rendered = render_event(&event, RenderFormat::Text);
405        assert_eq!(
406            text_rendered.stdout,
407            "replaced grain for pagila.public.rental: \"one row per rental\" -> \"one row per rental per day\"\n"
408        );
409
410        let event_col = TerminalEvent::ContractRemembered {
411            claim_id: "ki-456".to_string(),
412            object: "pagila.public.rental".to_string(),
413            kind: "column-role".to_string(),
414            value: "event_time".to_string(),
415            column: Some("rental_date".to_string()),
416            previous: Some("timestamp".to_string()),
417            action: "replaced".to_string(),
418            status: "confirmed".to_string(),
419        };
420        let text_rendered_col = render_event(&event_col, RenderFormat::Text);
421        assert_eq!(
422            text_rendered_col.stdout,
423            "replaced column-role for pagila.public.rental (col: rental_date): \"timestamp\" -> \"event_time\"\n"
424        );
425    }
426}