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};
19pub(crate) use render_learned::knowledge_learned_text;
22pub(crate) use render_memory::knowledge_overridden_text;
25pub(crate) use render_memory::knowledge_supplied_text;
28pub(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 KnowledgeSupplied {
80 outcome: KnowledgeOutcome,
81 contracts: Vec<SuppliedContractDto>,
82 dropped_by_bounds: usize,
83 },
84 KnowledgeLearned {
90 claim: ProposedClaimDto,
91 },
92 KnowledgeOverridden {
98 findings: Vec<OverrideFindingDto>,
99 },
100 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 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}