1use sparrow_core::event::{AgentStatus, AutonomyLevel, Decision, Event, RiskLevel};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Lang {
24 Fr,
25 En,
26}
27
28impl Lang {
29 pub fn from_code(code: &str) -> Self {
32 let c = code.trim().to_lowercase();
33 if c.starts_with("en") {
34 Lang::En
35 } else {
36 Lang::Fr
37 }
38 }
39}
40
41fn t(lang: Lang, fr: &str, en: &str) -> String {
43 match lang {
44 Lang::Fr => fr.to_string(),
45 Lang::En => en.to_string(),
46 }
47}
48
49fn tool_phrase(name: &str, args: &serde_json::Value, lang: Lang) -> String {
52 let target = ["path", "file_path", "file", "filename"]
54 .iter()
55 .find_map(|k| args.get(*k).and_then(|v| v.as_str()))
56 .unwrap_or("");
57 let with = |fr: &str, en: &str| {
58 if target.is_empty() {
59 t(lang, fr, en)
60 } else {
61 match lang {
62 Lang::Fr => format!("{fr} {target}…"),
63 Lang::En => format!("{en} {target}…"),
64 }
65 }
66 };
67 match name {
68 "fs_read" | "read" | "read_file" | "file_search" => with("Je lis", "Reading"),
69 "fs_write" | "write" | "write_file" => with("Je crée", "Creating"),
70 "edit" | "multi_edit" | "str_replace" => with("Je modifie", "Editing"),
71 "exec" | "bash" | "shell" | "code_exec" | "run_command" => {
72 t(lang, "Je lance une commande…", "Running a command…")
73 }
74 "search" | "grep" | "ripgrep" => t(lang, "Je cherche dans le code…", "Searching the code…"),
75 "web_search" => t(lang, "Je cherche sur internet…", "Searching the web…"),
76 "web_fetch" | "fetch" => t(lang, "Je consulte une page web…", "Fetching a web page…"),
77 _ => match lang {
78 Lang::Fr => format!("Je m'apprête à utiliser l'outil « {name} »…"),
79 Lang::En => format!("About to use the “{name}” tool…"),
80 },
81 }
82}
83
84pub fn humanize(ev: &Event, lang: Lang) -> Option<String> {
90 match ev {
91 Event::RunStarted { .. } => Some(t(lang, "C'est parti, je m'occupe de ça.", "On it.")),
92
93 Event::RouteSelected { .. } => None,
95 Event::TokenUsage { .. } => None,
96 Event::TokenUsageEstimated { .. } => None,
97 Event::CostUpdate { .. } => None,
98 Event::ThinkingDelta { .. } => None,
101 Event::ReasoningDelta { .. } => None,
102 Event::AgentStatus { .. } => None,
104 Event::SkillLearned { .. } => None,
106
107 Event::ModelSwitched { reason, .. } => {
108 if reason.contains("escalat") || reason.contains("verify") {
110 Some(t(
111 lang,
112 "C'est plus coriace que prévu, je passe la vitesse supérieure.",
113 "Tougher than expected — stepping up to a stronger model.",
114 ))
115 } else {
116 Some(t(
117 lang,
118 "Je change de modèle pour continuer.",
119 "Switching model to keep going.",
120 ))
121 }
122 }
123
124 Event::Message { .. } => None,
128
129 Event::ToolUseProposed { name, args, .. } => Some(tool_phrase(name, args, lang)),
130 Event::ToolUseStarted { .. } => None,
132 Event::ToolOutput { .. } => None,
133
134 Event::ApprovalRequested { tool, .. } => {
135 let what = tool.as_deref().unwrap_or("");
136 if what.is_empty() {
137 Some(t(
138 lang,
139 "J'ai besoin de ton accord pour continuer.",
140 "I need your go-ahead to continue.",
141 ))
142 } else {
143 Some(match lang {
144 Lang::Fr => format!("J'ai besoin de ton accord pour « {what} »."),
145 Lang::En => format!("I need your go-ahead for “{what}”."),
146 })
147 }
148 }
149 Event::ApprovalResolved { decision, .. } => Some(match decision {
150 Decision::Allow
151 | Decision::AllowOnce
152 | Decision::AllowSession
153 | Decision::AllowAlways => t(lang, "D'accord, j'y vais.", "Got it, going ahead."),
154 Decision::Deny => t(
155 lang,
156 "Compris, je n'y touche pas.",
157 "Understood, leaving it alone.",
158 ),
159 Decision::AskUser => t(lang, "J'attends ta réponse.", "Waiting for your answer."),
160 }),
161
162 Event::DiffProposed {
163 file, plus, minus, ..
164 } => Some(match lang {
165 Lang::Fr => format!("J'ai préparé une modification de {file} (+{plus} / −{minus})."),
166 Lang::En => format!("Prepared a change to {file} (+{plus} / −{minus})."),
167 }),
168 Event::DiffApplied { file, .. } => Some(match lang {
169 Lang::Fr => format!("{file} mis à jour."),
170 Lang::En => format!("{file} updated."),
171 }),
172
173 Event::TestResult { passed, failed, .. } => Some(if *failed == 0 {
174 match lang {
175 Lang::Fr => format!("Tests : {passed} réussis. ✅"),
176 Lang::En => format!("Tests: {passed} passing. ✅"),
177 }
178 } else {
179 match lang {
180 Lang::Fr => format!("Tests : {passed} réussis, {failed} en échec."),
181 Lang::En => format!("Tests: {passed} passing, {failed} failing."),
182 }
183 }),
184
185 Event::AgentSpawned { role, .. } => Some(match lang {
186 Lang::Fr => format!("Je fais appel à un assistant ({role})."),
187 Lang::En => format!("Bringing in a helper ({role})."),
188 }),
189
190 Event::CheckpointCreated { .. } => Some(t(
191 lang,
192 "Point de sauvegarde fait — on peut tout annuler.",
193 "Checkpoint saved — everything is undoable.",
194 )),
195
196 Event::AutonomyChanged { level, .. } => Some(match level {
197 AutonomyLevel::Supervised => t(
198 lang,
199 "Je te demande avant chaque action.",
200 "I'll ask before each action.",
201 ),
202 AutonomyLevel::Trusted => t(
203 lang,
204 "J'agis seul, mais je te montre tout.",
205 "I'll act on my own and show you everything.",
206 ),
207 AutonomyLevel::Autonomous => {
208 t(lang, "Je travaille en autonomie.", "Working autonomously.")
209 }
210 }),
211
212 Event::RunFinished { outcome, .. } => {
213 let files = outcome.diffs.len();
214 Some(match outcome.status.as_str() {
215 "completed" => match lang {
216 Lang::Fr if files > 0 => {
217 format!("Terminé ! {files} fichier(s) modifié(s).")
218 }
219 Lang::Fr => "Terminé !".to_string(),
220 Lang::En if files > 0 => format!("Done! {files} file(s) changed."),
221 Lang::En => "Done!".to_string(),
222 },
223 "waiting_for_approval" => t(
224 lang,
225 "En attente de ton accord pour continuer.",
226 "Waiting for your approval to continue.",
227 ),
228 "no actions taken" => t(lang, "Rien n'a été modifié.", "Nothing was changed."),
229 other => match lang {
230 Lang::Fr => format!("Fin : {other}."),
231 Lang::En => format!("Finished: {other}."),
232 },
233 })
234 }
235
236 Event::Error { message, .. } => {
237 let m = message.to_lowercase();
242 let calm = if m.contains("api error") || m.contains("400") || m.contains("{\"") {
243 t(
244 lang,
245 "Un modèle a refusé la requête. Je réessaie autrement — si ça persiste, tape « sparrow doctor ».",
246 "A model refused the request. I'll try another way — if it persists, run “sparrow doctor”.",
247 )
248 } else if m.contains("connect") || m.contains("network") || m.contains("timeout") {
249 t(
250 lang,
251 "Je n'arrive pas à joindre internet. Je peux continuer avec un modèle local si tu veux.",
252 "I can't reach the internet. I can keep going with a local model if you like.",
253 )
254 } else if message.len() <= 120 {
255 match lang {
256 Lang::Fr => {
257 format!("Quelque chose a coincé : {message}. Rien n'a été modifié.")
258 }
259 Lang::En => format!("Something went wrong: {message}. Nothing was changed."),
260 }
261 } else {
262 t(
263 lang,
264 "Quelque chose a coincé, mais rien n'a été modifié. Tape « sparrow doctor » pour un diagnostic.",
265 "Something went wrong, but nothing was changed. Run “sparrow doctor” for a checkup.",
266 )
267 };
268 Some(calm)
269 }
270
271 Event::Compacted { .. } => Some(t(
272 lang,
273 "J'ai fait de la place dans ma mémoire de travail.",
274 "Freed up room in my working memory.",
275 )),
276
277 Event::UpdateAvailable { latest, .. } => Some(match lang {
278 Lang::Fr => format!("Une nouvelle version de Sparrow est disponible ({latest})."),
279 Lang::En => format!("A new version of Sparrow is available ({latest})."),
280 }),
281 }
282}
283
284pub fn risk_phrase(risk: &RiskLevel, lang: Lang) -> String {
286 match risk {
287 RiskLevel::ReadOnly => t(lang, "lecture seule", "read-only"),
288 RiskLevel::Mutating => t(lang, "modifie des fichiers", "changes files"),
289 RiskLevel::Exec => t(lang, "exécute une commande", "runs a command"),
290 RiskLevel::Destructive => t(lang, "action irréversible", "irreversible action"),
291 RiskLevel::Network => t(lang, "accède à internet", "accesses the internet"),
292 }
293}
294
295pub fn status_phrase(status: &AgentStatus, lang: Lang) -> String {
297 match status {
298 AgentStatus::Idle => t(lang, "au repos", "idle"),
299 AgentStatus::Thinking => t(lang, "réfléchit…", "thinking…"),
300 AgentStatus::Working => t(lang, "travaille…", "working…"),
301 AgentStatus::WaitingForApproval => t(lang, "attend ton accord", "awaiting your go-ahead"),
302 AgentStatus::Done => t(lang, "terminé", "done"),
303 AgentStatus::Error => t(lang, "a rencontré un souci", "hit a problem"),
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use serde_json::json;
311 use sparrow_core::event::{OutcomeSummary, RunId, TokenUsage};
312
313 fn run() -> RunId {
314 RunId("test".into())
315 }
316
317 #[test]
318 fn experience_config_resolves_mode_and_lang() {
319 use crate::config::ExperienceConfig;
320 let pro = ExperienceConfig {
321 mode: "pro".into(),
322 language: "en".into(),
323 };
324 assert!(!pro.is_simple());
325 assert_eq!(pro.lang(), Lang::En);
326
327 let auto = ExperienceConfig {
328 mode: "auto".into(),
329 language: "fr".into(),
330 };
331 assert!(auto.is_simple(), "auto resolves to simple (human-first)");
332 assert_eq!(auto.lang(), Lang::Fr);
333
334 let builder = ExperienceConfig {
335 mode: "builder".into(),
336 language: "fr".into(),
337 };
338 assert!(builder.is_builder());
339 assert!(!builder.is_simple());
340
341 assert!(ExperienceConfig::default().is_simple());
343 }
344
345 #[test]
346 fn lang_from_code_defaults_to_french() {
347 assert_eq!(Lang::from_code("fr-FR"), Lang::Fr);
348 assert_eq!(Lang::from_code("en_US"), Lang::En);
349 assert_eq!(Lang::from_code("de"), Lang::Fr); assert_eq!(Lang::from_code(""), Lang::Fr);
351 }
352
353 #[test]
354 fn run_started_is_human_in_both_languages() {
355 let ev = Event::RunStarted {
356 run: run(),
357 task: "x".into(),
358 agent: "sparrow".into(),
359 };
360 assert_eq!(
361 humanize(&ev, Lang::Fr).unwrap(),
362 "C'est parti, je m'occupe de ça."
363 );
364 assert_eq!(humanize(&ev, Lang::En).unwrap(), "On it.");
365 }
366
367 #[test]
368 fn tool_proposed_names_the_file_in_plain_words() {
369 let ev = Event::ToolUseProposed {
370 run: run(),
371 id: "1".into(),
372 name: "fs_write".into(),
373 args: json!({"path": "poeme.txt", "content": "x"}),
374 risk: RiskLevel::Mutating,
375 };
376 assert_eq!(humanize(&ev, Lang::Fr).unwrap(), "Je crée poeme.txt…");
377 }
378
379 #[test]
380 fn telemetry_events_have_no_status_line() {
381 for ev in [
382 Event::TokenUsage {
383 run: run(),
384 input: 10,
385 output: 5,
386 },
387 Event::CostUpdate {
388 run: run(),
389 usd: 0.01,
390 },
391 Event::ReasoningDelta {
392 run: run(),
393 text: "…".into(),
394 },
395 Event::RouteSelected {
396 run: run(),
397 chain: vec![],
398 context_window: 1,
399 },
400 ] {
401 assert!(
402 humanize(&ev, Lang::Fr).is_none(),
403 "telemetry must be silent in simple mode"
404 );
405 }
406 }
407
408 #[test]
409 fn no_jargon_leaks_into_simple_mode() {
410 let banned = ["run ", "tier", "T1", "tok", "route ", "↑", "↓", "$0.0"];
413 let samples = [
414 Event::RunStarted {
415 run: run(),
416 task: "t".into(),
417 agent: "sparrow".into(),
418 },
419 Event::CheckpointCreated {
420 run: run(),
421 id: sparrow_core::event::CheckpointId("c".into()),
422 label: "l".into(),
423 },
424 Event::RunFinished {
425 run: run(),
426 outcome: OutcomeSummary {
427 status: "completed".into(),
428 diffs: vec![],
429 cost_usd: 0.0,
430 tokens: TokenUsage {
431 input: 0,
432 output: 0,
433 },
434 cost_comparison: String::new(),
435 duration_ms: None,
436 },
437 },
438 ];
439 for ev in samples {
440 if let Some(phrase) = humanize(&ev, Lang::Fr) {
441 for bad in banned {
442 assert!(
443 !phrase.contains(bad),
444 "simple-mode phrase `{phrase}` leaked jargon `{bad}`"
445 );
446 }
447 }
448 }
449 }
450
451 #[test]
452 fn error_is_reassuring_and_hides_raw_blobs() {
453 let ev = Event::Error {
454 run: run(),
455 message: "OpenAI-compatible API error 400: {\"error\":{\"message\":\"…\"}}".into(),
456 };
457 let line = humanize(&ev, Lang::Fr).unwrap();
458 assert!(!line.contains('{'), "raw JSON must not leak in simple mode");
459 assert!(line.contains("doctor"), "must offer an exit door");
460 }
461
462 #[test]
463 fn run_finished_reports_files_changed() {
464 let ev = Event::RunFinished {
465 run: run(),
466 outcome: OutcomeSummary {
467 status: "completed".into(),
468 diffs: vec![sparrow_core::event::FileDiff {
469 file: "a.txt".into(),
470 plus: 1,
471 minus: 0,
472 }],
473 cost_usd: 0.0,
474 tokens: TokenUsage {
475 input: 0,
476 output: 0,
477 },
478 cost_comparison: String::new(),
479 duration_ms: None,
480 },
481 };
482 assert_eq!(
483 humanize(&ev, Lang::Fr).unwrap(),
484 "Terminé ! 1 fichier(s) modifié(s)."
485 );
486 }
487}