Skip to main content

sloop/
render.rs

1//! Human rendering of response envelopes. JSON envelopes remain the internal
2//! and `--json` representation; this module is a one-way translation applied
3//! at the final write, so the socket API and agent-facing output are
4//! unaffected by presentation changes here.
5
6use std::fmt::Write;
7
8use serde_json::Value;
9
10use crate::protocol::{ErrorBody, ResponseEnvelope};
11
12/// Renders a response envelope as human-readable text. `verb` selects a
13/// verb-specific layout; unknown or absent verbs fall back to pretty JSON so
14/// no response is ever silently dropped.
15pub fn render(verb: Option<&str>, response: &ResponseEnvelope) -> String {
16    if let Some(error) = &response.error {
17        return render_error(error);
18    }
19    let data = response.data.as_ref().unwrap_or(&Value::Null);
20    if data["implemented"] == Value::Bool(false) {
21        let verb = data["verb"].as_str().unwrap_or("this verb");
22        return format!("{verb} is not implemented by the daemon yet\n");
23    }
24    match verb {
25        Some("daemon") => render_daemon(data),
26        Some("restart") => render_restart(data),
27        Some("init") => render_init(data),
28        Some("post") => render_post(data),
29        Some("run") => render_run(data),
30        Some("retry" | "hold" | "ready") => render_ticket_transition(data),
31        Some("list") => render_list(data),
32        Some("status") => render_status(data),
33        Some("pause" | "resume") => render_scheduler_transition(data),
34        Some("stop") => render_stop(data),
35        Some("wait") => render_wait(data),
36        Some("cancel") => render_cancel(data),
37        Some("logs") => render_logs(data),
38        Some("reindex") => render_reindex(data),
39        Some("show") => render_show(data),
40        _ => fallback(data),
41    }
42}
43
44pub fn render_error(error: &ErrorBody) -> String {
45    let code = serde_json::to_value(error.code)
46        .ok()
47        .and_then(|value| value.as_str().map(str::to_owned))
48        .unwrap_or_else(|| "error".into());
49    let mut text = format!("error ({code}): {}\n", error.message);
50    if error.details.as_object().is_some_and(|map| !map.is_empty()) {
51        let _ = writeln!(text, "  details: {}", error.details);
52    }
53    text
54}
55
56fn render_daemon(data: &Value) -> String {
57    let pid = &data["pid"];
58    let state = if data["started"] == Value::Bool(true) {
59        "started"
60    } else {
61        "running"
62    };
63    let mut text = format!("daemon {state} (pid {pid})\n");
64    if let Some(socket) = data["socket"].as_str() {
65        let _ = writeln!(text, "socket: {socket}");
66    }
67    if let Some(log) = data["log"].as_str() {
68        let _ = writeln!(text, "log: {log}");
69    }
70    text
71}
72
73fn render_restart(data: &Value) -> String {
74    let active = data["active_runs"].as_u64().unwrap_or(0);
75    let noun = if active == 1 { "run" } else { "runs" };
76    format!("daemon draining for restart ({active} {noun} active)\n")
77}
78
79fn render_init(data: &Value) -> String {
80    let mut text = format!(
81        "initialized sloop in {}\n",
82        data["repository_root"].as_str().unwrap_or("?")
83    );
84    for (label, key) in [("created", "created"), ("existing", "existing")] {
85        for path in string_items(&data[key]) {
86            let _ = writeln!(text, "  {label}: {path}");
87        }
88    }
89    text
90}
91
92fn render_post(data: &Value) -> String {
93    let ticket = &data["ticket"];
94    let mut text = format!(
95        "ticket {} registered from {} (project {}, {})\n",
96        ticket["id"].as_str().unwrap_or("?"),
97        ticket["file"].as_str().unwrap_or("?"),
98        ticket["project"].as_str().unwrap_or("?"),
99        ticket["state"].as_str().unwrap_or("?"),
100    );
101    text.push_str(&render_activation(&data["activation"]));
102    text
103}
104
105fn render_run(data: &Value) -> String {
106    render_activation(&data["activation"])
107}
108
109fn render_activation(activation: &Value) -> String {
110    let Some(fields) = activation.as_object() else {
111        return String::new();
112    };
113    let mut text = format!(
114        "activation {} {} ({}",
115        fields.get("id").and_then(Value::as_str).unwrap_or("?"),
116        fields
117            .get("state")
118            .and_then(Value::as_str)
119            .unwrap_or("queued"),
120        fields.get("kind").and_then(Value::as_str).unwrap_or("?"),
121    );
122    for key in ["ticket", "project", "time"] {
123        if let Some(value) = fields.get(key).and_then(Value::as_str) {
124            let _ = write!(text, ", {key} {value}");
125        }
126    }
127    text.push_str(")\n");
128    text
129}
130
131fn render_status(data: &Value) -> String {
132    let daemon = &data["daemon"];
133    let state = if daemon["draining"] == Value::Bool(true) {
134        let active = data["gate"]["active_agents"].as_u64().unwrap_or(0);
135        let noun = if active == 1 { "run" } else { "runs" };
136        format!(", draining for restart ({active} {noun} active)")
137    } else if daemon["paused"] == Value::Bool(true) {
138        ", paused".into()
139    } else {
140        String::new()
141    };
142    let mut text = format!("daemon: pid {}{state}\n", daemon["pid"]);
143    let _ = writeln!(
144        text,
145        "agents: {} active of {} max",
146        data["gate"]["active_agents"], data["gate"]["max_agents"]
147    );
148    if data["gate"]["storage"]["writable"] == Value::Bool(false) {
149        text.push_str("storage: database full (dispatch blocked until space is available)\n");
150    }
151    if let Some(hours) = data["gate"]["running_hours"].as_object() {
152        let state = if hours.get("open") == Some(&Value::Bool(true)) {
153            "open"
154        } else {
155            "closed"
156        };
157        let _ = writeln!(
158            text,
159            "running hours: {}-{} ({state})",
160            hours.get("start").and_then(Value::as_str).unwrap_or("?"),
161            hours.get("end").and_then(Value::as_str).unwrap_or("?"),
162        );
163    }
164    if let Some(next_wake) = data["next_wake"].as_str() {
165        let _ = writeln!(text, "next wake: {next_wake}");
166    }
167
168    let tickets = &data["tickets"];
169    let counts: Vec<String> = [
170        "ready",
171        "held",
172        "blocked",
173        "claimed",
174        "merged",
175        "failed",
176        "needs_review",
177    ]
178    .iter()
179    .map(|state| format!("{} {state}", tickets[*state]))
180    .collect();
181    let _ = writeln!(text, "tickets: {}", counts.join(", "));
182
183    // Run lines lead with the alias and the ticket's name, so the line answers
184    // "what is this working on" without a second command. Queued activations
185    // are not runs and keep their own shape.
186    let runs = data["runs"]
187        .as_array()
188        .map(Vec::as_slice)
189        .unwrap_or_default();
190    if runs.is_empty() {
191        text.push_str("runs: none\n");
192    } else {
193        text.push_str("runs:\n");
194        for run in runs {
195            let _ = writeln!(
196                text,
197                "  {} {} — {} (project {})",
198                run["alias"].as_str().unwrap_or("?"),
199                run["state"].as_str().unwrap_or("?"),
200                run["ticket_name"].as_str().unwrap_or("?"),
201                run["project"].as_str().unwrap_or("-"),
202            );
203        }
204    }
205
206    let queued = data["queued_activations"]
207        .as_array()
208        .map(Vec::as_slice)
209        .unwrap_or_default();
210    if queued.is_empty() {
211        text.push_str("queued: none\n");
212    } else {
213        text.push_str("queued:\n");
214        for item in queued {
215            let _ = writeln!(
216                text,
217                "  {} {} (ticket {}, project {})",
218                item["id"].as_str().unwrap_or("?"),
219                item["state"].as_str().unwrap_or("?"),
220                item["ticket"].as_str().unwrap_or("-"),
221                item["project"].as_str().unwrap_or("-"),
222            );
223        }
224    }
225    text
226}
227
228fn render_list(data: &Value) -> String {
229    let tickets = data["tickets"]
230        .as_array()
231        .map(Vec::as_slice)
232        .unwrap_or_default();
233    if tickets.is_empty() {
234        return "no tickets\n".into();
235    }
236
237    let id_width = tickets
238        .iter()
239        .filter_map(|ticket| ticket["id"].as_str())
240        .map(str::len)
241        .max()
242        .unwrap_or(1);
243    let state_width = tickets
244        .iter()
245        .filter_map(|ticket| ticket["state"].as_str())
246        .map(str::len)
247        .max()
248        .unwrap_or(1);
249    let mut text = String::new();
250    for ticket in tickets {
251        let id = ticket["id"].as_str().unwrap_or("?");
252        let state = ticket["state"].as_str().unwrap_or("?");
253        let project = ticket["project"].as_str().unwrap_or("?");
254        let name = ticket["name"].as_str().unwrap_or("?");
255        let _ = write!(
256            text,
257            "{id:id_width$}  {state:state_width$}  ({project})  {name}"
258        );
259        let terminal = matches!(state, "merged" | "needs_review");
260        if ticket["run"].is_null()
261            && !terminal
262            && let Some(reason) = ticket["reason"].as_str()
263        {
264            let _ = write!(text, "  — {reason}");
265        }
266        text.push('\n');
267    }
268    text
269}
270
271fn render_ticket_transition(data: &Value) -> String {
272    format!(
273        "ticket {}: {} -> {}\n",
274        data["ticket"].as_str().unwrap_or("?"),
275        data["previous_state"].as_str().unwrap_or("?"),
276        data["state"].as_str().unwrap_or("?"),
277    )
278}
279
280fn render_scheduler_transition(data: &Value) -> String {
281    if data["paused"] == Value::Bool(true) {
282        "scheduler paused\n".into()
283    } else {
284        "scheduler resumed\n".into()
285    }
286}
287
288fn render_stop(data: &Value) -> String {
289    if data["running"] == Value::Bool(false) {
290        return "daemon is not running\n".into();
291    }
292    let mut text = format!("daemon stopping (pid {})\n", data["pid"]);
293    for run in string_items(&data["cancelled_runs"]) {
294        let _ = writeln!(text, "  cancelled: {run}");
295    }
296    text
297}
298
299fn render_wait(data: &Value) -> String {
300    let mut rendered = String::new();
301    if let Some(note) = data["note"].as_str() {
302        let _ = writeln!(rendered, "{note}");
303    }
304    let _ = writeln!(
305        rendered,
306        "run {} {}",
307        data["alias"].as_str().unwrap_or("?"),
308        data["state"].as_str().unwrap_or("?"),
309    );
310    if let Some(reason) = data["reason"].as_str() {
311        let _ = writeln!(rendered, "reason: {reason}");
312    }
313    rendered
314}
315
316fn render_cancel(data: &Value) -> String {
317    let mut text = format!("run {} cancelling\n", data["alias"].as_str().unwrap_or("?"));
318    if let Some(worktree) = data["worktree"].as_str() {
319        let _ = writeln!(text, "worktree preserved at {worktree}");
320    }
321    text
322}
323
324fn render_logs(data: &Value) -> String {
325    let entries = data["entries"]
326        .as_array()
327        .map(Vec::as_slice)
328        .unwrap_or_default();
329    let mut text = String::new();
330    if let Some(note) = data["note"].as_str() {
331        let _ = writeln!(text, "{note}");
332    }
333    if entries.is_empty() {
334        let _ = writeln!(
335            text,
336            "no output captured for run {}",
337            data["alias"].as_str().unwrap_or("?")
338        );
339        return text;
340    }
341    for entry in entries {
342        let timestamp = entry["timestamp"].as_str().unwrap_or("?");
343        let mut origin = entry["source"].as_str().unwrap_or("?").to_owned();
344        if let Some(stage) = entry["stage"].as_str() {
345            let _ = write!(origin, ":{stage}");
346        }
347        // Bytes that failed UTF-8 decoding are stored as base64; a human
348        // view labels them rather than printing garbage.
349        let line = entry["text"].as_str().unwrap_or("<binary output>");
350        let _ = writeln!(text, "[{timestamp}] [{origin}] {line}");
351    }
352    text
353}
354
355fn render_reindex(data: &Value) -> String {
356    format!(
357        "reindexed {} projects and {} tickets; {} ticket states changed; {} rows dropped\n",
358        data["projects_indexed"],
359        data["tickets_indexed"],
360        data["tickets_state_changed"],
361        data["rows_dropped"]
362    )
363}
364
365fn render_show(data: &Value) -> String {
366    match data["kind"].as_str() {
367        Some("ticket") => render_ticket_show(data),
368        Some("run") => render_run_show(data),
369        Some("project") => render_project_show(data),
370        _ => fallback(data),
371    }
372}
373
374/// A scannable frontmatter summary followed by the ticket body. The worker's
375/// own `show` carries no `body`, so the same layout renders just the summary
376/// for it — no worker-specific branch and no behavior change.
377fn render_ticket_show(data: &Value) -> String {
378    let value = &data["value"];
379    let mut text = format!(
380        "{}  {}  ({})\n",
381        value["id"].as_str().unwrap_or("?"),
382        value["name"].as_str().unwrap_or("?"),
383        value["state"].as_str().unwrap_or("?"),
384    );
385    if let Some(project) = value["project"].as_str() {
386        let _ = writeln!(text, "project: {project}");
387    }
388    if let Some(worktree) = value["worktree"].as_str() {
389        let _ = writeln!(text, "worktree: {worktree}");
390    }
391    let blocked_by = string_items(&value["blocked_by"]).collect::<Vec<_>>();
392    if !blocked_by.is_empty() {
393        let _ = writeln!(text, "blocked_by: {}", blocked_by.join(", "));
394    }
395    for (label, key) in [
396        ("target", "target"),
397        ("model", "model"),
398        ("effort", "effort"),
399    ] {
400        if let Some(field) = value[key].as_str() {
401            let _ = writeln!(text, "{label}: {field}");
402        }
403    }
404    if let Some(reason) = value["reason"].as_str() {
405        let _ = writeln!(text, "reason: {reason}");
406    }
407    if let Some(body) = value["body"]
408        .as_str()
409        .map(str::trim)
410        .filter(|body| !body.is_empty())
411    {
412        let _ = write!(text, "\n{body}\n");
413    }
414    text
415}
416
417/// A run's identity and settled evidence, one fact per line.
418fn render_run_show(data: &Value) -> String {
419    let value = &data["value"];
420    let mut text = String::new();
421    if let Some(note) = value["note"].as_str() {
422        let _ = writeln!(text, "{note}");
423    }
424    let _ = writeln!(
425        text,
426        "{}  ({})",
427        value["alias"].as_str().unwrap_or("?"),
428        value["state"].as_str().unwrap_or("?"),
429    );
430    let ticket = value["ticket"].as_str().unwrap_or("?");
431    match value["ticket_name"].as_str() {
432        Some(name) => {
433            let _ = writeln!(text, "ticket: {ticket}  {name}");
434        }
435        None => {
436            let _ = writeln!(text, "ticket: {ticket}");
437        }
438    }
439    if let Some(branch) = value["branch"].as_str() {
440        let _ = writeln!(text, "branch: {branch}");
441    }
442    if let Some(worktree) = value["worktree"].as_str() {
443        let _ = writeln!(text, "worktree: {worktree}");
444    }
445    if let Some(exit_code) = value["exit_code"].as_i64() {
446        let _ = writeln!(text, "exit: {exit_code}");
447    }
448    if let Some(reason) = value["reason"].as_str() {
449        let _ = writeln!(text, "reason: {reason}");
450    }
451    text
452}
453
454fn render_project_show(data: &Value) -> String {
455    let project = &data["value"];
456    let mut text = format!(
457        "project {} ({})\n",
458        project["title"].as_str().unwrap_or("?"),
459        project["id"].as_str().unwrap_or("?"),
460    );
461    let tickets = project["tickets"]
462        .as_array()
463        .map(Vec::as_slice)
464        .unwrap_or_default();
465    if tickets.is_empty() {
466        text.push_str("no tickets\n");
467        return text;
468    }
469    for ticket in tickets {
470        let _ = writeln!(
471            text,
472            "\n{}  {}  ({})",
473            ticket["id"].as_str().unwrap_or("?"),
474            ticket["name"].as_str().unwrap_or("?"),
475            ticket["state"].as_str().unwrap_or("?"),
476        );
477        for note in ticket["notes"]
478            .as_array()
479            .map(Vec::as_slice)
480            .unwrap_or_default()
481        {
482            let _ = writeln!(
483                text,
484                "  note {} [{}]: {}",
485                note["id"].as_str().unwrap_or("?"),
486                note["run"].as_str().unwrap_or("?"),
487                note["text"].as_str().unwrap_or("?"),
488            );
489        }
490        for commit in ticket["commits"]
491            .as_array()
492            .map(Vec::as_slice)
493            .unwrap_or_default()
494        {
495            let _ = writeln!(
496                text,
497                "  commit {} [{}]: {}",
498                commit["hash"].as_str().unwrap_or("?"),
499                commit["run"].as_str().unwrap_or("?"),
500                commit["message"].as_str().unwrap_or("?"),
501            );
502        }
503    }
504    text
505}
506
507fn fallback(data: &Value) -> String {
508    let mut text = serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string());
509    text.push('\n');
510    text
511}
512
513fn string_items(value: &Value) -> impl Iterator<Item = &str> {
514    value
515        .as_array()
516        .map(Vec::as_slice)
517        .unwrap_or_default()
518        .iter()
519        .filter_map(Value::as_str)
520}
521
522#[cfg(test)]
523mod tests {
524    use serde_json::json;
525
526    use super::render;
527    use crate::protocol::{ErrorBody, ErrorCode, ResponseEnvelope};
528
529    #[test]
530    fn errors_render_the_code_and_message() {
531        let response = ResponseEnvelope::failure(
532            None,
533            ErrorBody {
534                code: ErrorCode::Conflict,
535                message: "run `R1` is `merged` and cannot be cancelled".into(),
536                details: json!({}),
537            },
538        );
539
540        let text = render(Some("cancel"), &response);
541        assert_eq!(
542            text,
543            "error (conflict): run `R1` is `merged` and cannot be cancelled\n"
544        );
545    }
546
547    #[test]
548    fn status_renders_counts_and_active_runs() {
549        let response = ResponseEnvelope::success(
550            None,
551            json!({
552                "daemon": {"pid": 42, "paused": false},
553                "gate": {
554                    "active_agents": 1,
555                    "max_agents": 2,
556                    "running_hours": {"start": "22:00", "end": "06:00", "open": false}
557                },
558                "next_wake": "2026-07-15T22:00:00Z",
559                "runs": [{
560                    "id": "3f2a9c1b7d4e5061a2b3c4d5e6f70819", "alias": "T1-r1",
561                    "state": "running", "ticket": "T1", "ticket_name": "Generalized stages",
562                    "project": "default"
563                }],
564                "queued_activations": [],
565                "tickets": {
566                    "ready": 1, "held": 2, "blocked": 0, "claimed": 1,
567                    "merged": 3, "failed": 0, "needs_review": 0
568                }
569            }),
570        );
571
572        let text = render(Some("status"), &response);
573        assert!(text.contains("daemon: pid 42\n"), "{text}");
574        assert!(text.contains("agents: 1 active of 2 max"), "{text}");
575        assert!(
576            text.contains("running hours: 22:00-06:00 (closed)"),
577            "{text}"
578        );
579        assert!(text.contains("next wake: 2026-07-15T22:00:00Z"), "{text}");
580        assert!(
581            text.contains(
582                "tickets: 1 ready, 2 held, 0 blocked, 1 claimed, 3 merged, 0 failed, 0 needs_review"
583            ),
584            "{text}"
585        );
586        assert!(
587            text.contains("T1-r1 running — Generalized stages (project default)"),
588            "{text}"
589        );
590        assert!(text.contains("queued: none"), "{text}");
591    }
592
593    #[test]
594    fn status_renders_a_full_database_as_a_dispatch_gate() {
595        let response = ResponseEnvelope::success(
596            None,
597            json!({
598                "daemon": {"pid": 42, "paused": false},
599                "gate": {
600                    "active_agents": 0,
601                    "max_agents": 1,
602                    "storage": {"writable": false, "reason": "database_full"}
603                },
604                "runs": [],
605                "queued_activations": [],
606                "tickets": {}
607            }),
608        );
609
610        let text = render(Some("status"), &response);
611        assert!(
612            text.contains("storage: database full (dispatch blocked"),
613            "{text}"
614        );
615    }
616
617    #[test]
618    fn list_renders_reasons_but_not_running_or_terminal_reasons() {
619        let response = ResponseEnvelope::success(
620            None,
621            json!({
622                "tickets": [
623                    {"id": "TICKET-1", "name": "Fix dispatch", "project": "default", "state": "ready", "run": null,
624                     "reason": "scheduler is paused; resume with `sloop resume`"},
625                    {"id": "T2", "name": "Add retries", "project": "default", "state": "claimed", "run": "R1",
626                     "reason": "claimed by run R1"},
627                    {"id": "T3", "name": "Polish the UI", "project": "web", "state": "merged", "run": null,
628                     "reason": null}
629                ]
630            }),
631        );
632
633        assert_eq!(
634            render(Some("list"), &response),
635            "TICKET-1  ready    (default)  Fix dispatch  — scheduler is paused; resume with `sloop resume`\n\
636             T2        claimed  (default)  Add retries\n\
637             T3        merged   (web)  Polish the UI\n"
638        );
639    }
640
641    #[test]
642    fn empty_list_says_there_are_no_tickets() {
643        let response = ResponseEnvelope::success(None, json!({"tickets": []}));
644        assert_eq!(render(Some("list"), &response), "no tickets\n");
645    }
646
647    #[test]
648    fn hold_and_ready_render_the_transition() {
649        let response = ResponseEnvelope::success(
650            None,
651            json!({
652                "ticket": "T1",
653                "previous_state": "held",
654                "state": "ready",
655                "overridden": true,
656            }),
657        );
658
659        for verb in ["hold", "ready"] {
660            assert_eq!(render(Some(verb), &response), "ticket T1: held -> ready\n");
661        }
662    }
663
664    #[test]
665    fn retry_renders_the_transition() {
666        let response = ResponseEnvelope::success(
667            None,
668            json!({
669                "ticket": "T1",
670                "previous_state": "failed",
671                "state": "ready",
672            }),
673        );
674
675        assert_eq!(
676            render(Some("retry"), &response),
677            "ticket T1: failed -> ready\n"
678        );
679    }
680
681    #[test]
682    fn unknown_shapes_fall_back_to_pretty_json() {
683        let response = ResponseEnvelope::success(None, json!({"surprise": true}));
684        let text = render(Some("mystery"), &response);
685        assert!(text.contains("\"surprise\": true"), "{text}");
686    }
687
688    #[test]
689    fn project_show_groups_activity_by_ticket() {
690        let response = ResponseEnvelope::success(
691            None,
692            json!({
693                "ref": "backend",
694                "kind": "project",
695                "value": {
696                    "id": "backend",
697                    "title": "Backend",
698                    "tickets": [{
699                        "id": "T1",
700                        "name": "Persist cooldowns",
701                        "state": "merged",
702                        "notes": [{"id": "N1", "run": "R1", "text": "halfway"}],
703                        "commits": [{"hash": "abc1234", "run": "R1", "message": "persist cooldowns"}]
704                    }]
705                }
706            }),
707        );
708
709        assert_eq!(
710            render(Some("show"), &response),
711            concat!(
712                "project Backend (backend)\n",
713                "\n",
714                "T1  Persist cooldowns  (merged)\n",
715                "  note N1 [R1]: halfway\n",
716                "  commit abc1234 [R1]: persist cooldowns\n",
717            )
718        );
719    }
720
721    #[test]
722    fn ticket_show_renders_a_summary_then_the_body() {
723        let response = ResponseEnvelope::success(
724            None,
725            json!({
726                "ref": "TICK-1",
727                "kind": "ticket",
728                "value": {
729                    "id": "TICK-1",
730                    "name": "cooldown",
731                    "state": "ready",
732                    "project": "default",
733                    "worktree": "sloop/TICK-1",
734                    "blocked_by": ["TICK-0"],
735                    "target": "claude",
736                    "model": "opus",
737                    "effort": "high",
738                    "body": "# Persist cooldowns\n\nSurvive restarts.",
739                }
740            }),
741        );
742
743        assert_eq!(
744            render(Some("show"), &response),
745            concat!(
746                "TICK-1  cooldown  (ready)\n",
747                "project: default\n",
748                "worktree: sloop/TICK-1\n",
749                "blocked_by: TICK-0\n",
750                "target: claude\n",
751                "model: opus\n",
752                "effort: high\n",
753                "\n",
754                "# Persist cooldowns\n\nSurvive restarts.\n",
755            )
756        );
757    }
758
759    #[test]
760    fn ticket_show_without_a_body_renders_only_the_summary() {
761        let response = ResponseEnvelope::success(
762            None,
763            json!({
764                "ref": "T1",
765                "kind": "ticket",
766                "value": {"id": "T1", "name": "work", "state": "ready", "blocked_by": []}
767            }),
768        );
769
770        assert_eq!(render(Some("show"), &response), "T1  work  (ready)\n");
771    }
772
773    #[test]
774    fn run_show_renders_the_run_evidence_summary() {
775        let response = ResponseEnvelope::success(
776            None,
777            json!({
778                "ref": "R14",
779                "kind": "run",
780                "value": {
781                    "id": "R14",
782                    "alias": "TICK-1-r2",
783                    "ticket": "TICK-1",
784                    "ticket_name": "cooldown",
785                    "state": "merged",
786                    "terminal": true,
787                    "branch": "sloop/R14-TICK-1",
788                    "worktree": "/repo/.worktrees/R14",
789                    "exit_code": 0,
790                    "reason": null,
791                    "classification": null,
792                }
793            }),
794        );
795
796        assert_eq!(
797            render(Some("show"), &response),
798            concat!(
799                "TICK-1-r2  (merged)\n",
800                "ticket: TICK-1  cooldown\n",
801                "branch: sloop/R14-TICK-1\n",
802                "worktree: /repo/.worktrees/R14\n",
803                "exit: 0\n",
804            )
805        );
806    }
807
808    #[test]
809    fn pause_and_resume_render_the_scheduler_state() {
810        for (verb, paused, expected) in [
811            ("pause", true, "scheduler paused\n"),
812            ("resume", false, "scheduler resumed\n"),
813        ] {
814            let response = ResponseEnvelope::success(None, json!({"paused": paused}));
815            assert_eq!(render(Some(verb), &response), expected);
816        }
817    }
818
819    #[test]
820    fn restart_renders_the_active_drain_count() {
821        assert_eq!(
822            render(
823                Some("restart"),
824                &ResponseEnvelope::success(None, json!({"active_runs": 1}))
825            ),
826            "daemon draining for restart (1 run active)\n"
827        );
828    }
829
830    #[test]
831    fn reindex_summarizes_the_rebuilt_state() {
832        let response = ResponseEnvelope::success(
833            None,
834            json!({
835                "projects_indexed": 2,
836                "tickets_indexed": 5,
837                "tickets_state_changed": 1,
838                "state_changes": [
839                    {"ticket": "T1", "previous_state": "ready", "state": "merged"}
840                ],
841                "rows_dropped": 7,
842            }),
843        );
844        let text = render(Some("reindex"), &response);
845        assert_eq!(
846            text,
847            "reindexed 2 projects and 5 tickets; 1 ticket states changed; 7 rows dropped\n"
848        );
849    }
850
851    #[test]
852    fn log_entries_render_timestamp_origin_and_text() {
853        let response = ResponseEnvelope::success(
854            None,
855            json!({
856                "run": "R1",
857                "entries": [
858                    {"timestamp": "2026-07-17T12:34:56Z", "source": "agent", "stage": null, "text": "hello"},
859                    {"timestamp": "2026-07-17T12:34:57Z", "source": "aftercare", "stage": "test", "bytes_b64": "AAE="}
860                ],
861                "next_cursor": 2,
862                "complete": true
863            }),
864        );
865
866        let text = render(Some("logs"), &response);
867        assert_eq!(
868            text,
869            "[2026-07-17T12:34:56Z] [agent] hello\n\
870             [2026-07-17T12:34:57Z] [aftercare:test] <binary output>\n"
871        );
872    }
873}