Skip to main content

codeswarm_adapters/
details.rs

1//! Replay-safe state for expensive tool, thought, terminal, and diff details.
2//!
3//! The model stores source text but does not parse, wrap, or render it. New
4//! records start collapsed, so a renderer can show a cheap summary and defer
5//! materializing the detail until the user explicitly expands it. All state
6//! changes are represented as serializable events and can be replayed without
7//! a terminal or UI.
8
9use serde::{Deserialize, Serialize};
10
11/// The kinds of content that may be expensive to display.
12#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
13pub enum DetailKind {
14    Tool,
15    Thought,
16    Terminal,
17    Diff,
18}
19
20/// A detail record. `content` remains unparsed until a caller asks for it.
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22pub struct DetailRecord {
23    pub id: String,
24    pub kind: DetailKind,
25    pub summary: String,
26    pub content: String,
27    expanded: bool,
28}
29
30impl DetailRecord {
31    /// Construct a collapsed record; callers must opt into expansion.
32    pub fn collapsed(
33        id: impl Into<String>,
34        kind: DetailKind,
35        summary: impl Into<String>,
36        content: impl Into<String>,
37    ) -> Self {
38        Self {
39            id: id.into(),
40            kind,
41            summary: summary.into(),
42            content: content.into(),
43            expanded: false,
44        }
45    }
46
47    pub fn is_expanded(&self) -> bool {
48        self.expanded
49    }
50
51    /// Return only the cheap text needed for the current collapsed/expanded
52    /// projection. No parsing or wrapping occurs here.
53    pub fn projected_text(&self) -> &str {
54        if self.expanded {
55            &self.content
56        } else {
57            &self.summary
58        }
59    }
60
61    /// Access the original source for copy/export only when it is requested.
62    pub fn source(&self) -> &str {
63        &self.content
64    }
65}
66
67/// The complete event vocabulary for detail state. Events are pure data and
68/// can be persisted beside the normalized agent event stream.
69#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
70pub enum DetailEvent {
71    Upsert { record: DetailRecord },
72    SetExpanded { id: String, expanded: bool },
73}
74
75/// Ordered detail state with stable IDs. Upserting an existing record keeps
76/// its insertion position and its expansion state, which prevents a streaming
77/// terminal/tool update from unexpectedly re-expanding expensive output.
78#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
79pub struct DetailModel {
80    records: Vec<DetailRecord>,
81}
82
83impl DetailModel {
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    pub fn len(&self) -> usize {
89        self.records.len()
90    }
91
92    pub fn is_empty(&self) -> bool {
93        self.records.is_empty()
94    }
95
96    pub fn get(&self, id: &str) -> Option<&DetailRecord> {
97        self.records.iter().find(|record| record.id == id)
98    }
99
100    pub fn iter(&self) -> impl Iterator<Item = &DetailRecord> {
101        self.records.iter()
102    }
103
104    /// Apply a detail event. `false` means a SetExpanded target was absent.
105    pub fn apply(&mut self, event: DetailEvent) -> bool {
106        match event {
107            DetailEvent::Upsert { mut record } => {
108                if let Some(existing) = self.records.iter().find(|item| item.id == record.id) {
109                    record.expanded = existing.expanded;
110                }
111                if let Some(existing) = self.records.iter_mut().find(|item| item.id == record.id) {
112                    *existing = record;
113                } else {
114                    self.records.push(record);
115                }
116                true
117            }
118            DetailEvent::SetExpanded { id, expanded } => {
119                let Some(record) = self.records.iter_mut().find(|record| record.id == id) else {
120                    return false;
121                };
122                record.expanded = expanded;
123                true
124            }
125        }
126    }
127
128    pub fn set_expanded(&mut self, id: impl Into<String>, expanded: bool) -> bool {
129        self.apply(DetailEvent::SetExpanded {
130            id: id.into(),
131            expanded,
132        })
133    }
134
135    pub fn toggle(&mut self, id: &str) -> bool {
136        let Some(expanded) = self.get(id).map(|record| !record.expanded) else {
137            return false;
138        };
139        self.set_expanded(id, expanded)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::{DetailEvent, DetailKind, DetailModel, DetailRecord};
146
147    fn terminal() -> DetailRecord {
148        DetailRecord::collapsed(
149            "terminal-1",
150            DetailKind::Terminal,
151            "$ cargo test (output hidden)",
152            "full terminal output\n".repeat(100),
153        )
154    }
155
156    fn diff() -> DetailRecord {
157        DetailRecord::collapsed(
158            "diff-1",
159            DetailKind::Diff,
160            "3 files changed",
161            "--- a/src/lib.rs\n+++ b/src/lib.rs\n",
162        )
163    }
164
165    #[test]
166    fn expensive_terminal_and_diff_details_start_collapsed() {
167        let mut model = DetailModel::new();
168        model.apply(DetailEvent::Upsert { record: terminal() });
169        model.apply(DetailEvent::Upsert { record: diff() });
170        assert_eq!(model.len(), 2);
171        assert!(!model.get("terminal-1").expect("terminal").is_expanded());
172        assert_eq!(
173            model.get("terminal-1").expect("terminal").projected_text(),
174            "$ cargo test (output hidden)"
175        );
176        assert_eq!(
177            model.get("diff-1").expect("diff").projected_text(),
178            "3 files changed"
179        );
180    }
181
182    #[test]
183    fn explicit_toggle_is_the_only_expansion_path() {
184        let mut model = DetailModel::new();
185        model.apply(DetailEvent::Upsert { record: terminal() });
186        assert!(model.toggle("terminal-1"));
187        assert!(model.get("terminal-1").expect("terminal").is_expanded());
188        assert!(
189            model
190                .get("terminal-1")
191                .expect("terminal")
192                .projected_text()
193                .contains("full terminal output")
194        );
195        assert!(model.toggle("terminal-1"));
196        assert!(!model.get("terminal-1").expect("terminal").is_expanded());
197        assert!(!model.toggle("missing"));
198    }
199
200    #[test]
201    fn replacing_streaming_detail_preserves_position_and_expansion() {
202        let mut model = DetailModel::new();
203        model.apply(DetailEvent::Upsert { record: terminal() });
204        model.apply(DetailEvent::Upsert { record: diff() });
205        assert!(model.set_expanded("terminal-1", true));
206        let mut update = terminal();
207        update.content = "new output".into();
208        update.summary = "updated terminal".into();
209        model.apply(DetailEvent::Upsert { record: update });
210        let ids = model
211            .iter()
212            .map(|record| record.id.as_str())
213            .collect::<Vec<_>>();
214        assert_eq!(ids, ["terminal-1", "diff-1"]);
215        assert!(model.get("terminal-1").expect("terminal").is_expanded());
216        assert_eq!(
217            model.get("terminal-1").expect("terminal").source(),
218            "new output"
219        );
220    }
221
222    #[test]
223    fn detail_events_replay_to_identical_state() {
224        let events = vec![
225            DetailEvent::Upsert { record: terminal() },
226            DetailEvent::Upsert { record: diff() },
227            DetailEvent::SetExpanded {
228                id: "diff-1".into(),
229                expanded: true,
230            },
231        ];
232        let encoded = serde_json::to_string(&events).expect("serialize");
233        let decoded: Vec<DetailEvent> = serde_json::from_str(&encoded).expect("deserialize");
234        let mut first = DetailModel::new();
235        let mut second = DetailModel::new();
236        for event in events {
237            first.apply(event);
238        }
239        for event in decoded {
240            second.apply(event);
241        }
242        assert_eq!(first, second);
243    }
244
245    #[test]
246    fn absent_expansion_target_is_a_safe_replay_noop() {
247        let mut model = DetailModel::new();
248        assert!(!model.apply(DetailEvent::SetExpanded {
249            id: "late-detail".into(),
250            expanded: true,
251        }));
252        assert!(model.is_empty());
253    }
254}