Skip to main content

wisp/components/
conversation_window.rs

1use std::mem::{Discriminant, discriminant};
2
3use crate::components::thought_message::ThoughtMessage;
4use crate::components::tool_call_statuses::ToolCallStatuses;
5use tui::{Line, ViewContext, render_markdown};
6
7#[derive(Debug, Clone)]
8pub enum SegmentContent {
9    UserMessage(String),
10    Text(String),
11    Thought(String),
12    ToolCall(String),
13}
14
15#[derive(Debug)]
16struct Segment {
17    content: SegmentContent,
18}
19
20pub struct ConversationBuffer {
21    segments: Vec<Segment>,
22    thought_block_open: bool,
23}
24
25impl Default for ConversationBuffer {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl ConversationBuffer {
32    pub fn new() -> Self {
33        Self {
34            segments: Vec::new(),
35            thought_block_open: false,
36        }
37    }
38
39    #[cfg(test)]
40    pub(crate) fn segments(&self) -> impl ExactSizeIterator<Item = &SegmentContent> {
41        self.segments.iter().map(|s| &s.content)
42    }
43
44    pub fn push_user_message(&mut self, text: &str) {
45        self.close_thought_block();
46        self.segments.push(Segment {
47            content: SegmentContent::UserMessage(text.to_string()),
48        });
49    }
50
51    pub fn append_text_chunk(&mut self, chunk: &str) {
52        if chunk.is_empty() {
53            return;
54        }
55
56        self.close_thought_block();
57
58        if let Some(segment) = self.segments.last_mut()
59            && let SegmentContent::Text(existing) = &mut segment.content
60        {
61            existing.push_str(chunk);
62        } else {
63            self.segments.push(Segment {
64                content: SegmentContent::Text(chunk.to_string()),
65            });
66        }
67    }
68
69    pub fn append_thought_chunk(&mut self, chunk: &str) {
70        if chunk.is_empty() {
71            return;
72        }
73
74        if self.thought_block_open
75            && let Some(segment) = self.segments.last_mut()
76            && let SegmentContent::Thought(existing) = &mut segment.content
77        {
78            existing.push_str(chunk);
79            return;
80        }
81
82        self.segments.push(Segment {
83            content: SegmentContent::Thought(chunk.to_string()),
84        });
85        self.thought_block_open = true;
86    }
87
88    pub(crate) fn close_thought_block(&mut self) {
89        self.thought_block_open = false;
90    }
91
92    pub(crate) fn clear(&mut self) {
93        self.segments.clear();
94        self.thought_block_open = false;
95    }
96
97    pub(crate) fn ensure_tool_segment(&mut self, tool_id: &str) {
98        let has_segment = self
99            .segments
100            .iter()
101            .any(|s| matches!(&s.content, SegmentContent::ToolCall(id) if id == tool_id));
102
103        if !has_segment {
104            self.segments.push(Segment {
105                content: SegmentContent::ToolCall(tool_id.to_string()),
106            });
107        }
108    }
109
110    #[cfg(test)]
111    fn drain_segments_except(
112        &mut self,
113        mut keep: impl FnMut(&SegmentContent) -> bool,
114    ) -> Vec<Segment> {
115        let old = std::mem::take(&mut self.segments);
116        let (kept, removed) = old.into_iter().partition(|s| keep(&s.content));
117        self.segments = kept;
118        removed
119    }
120
121    #[cfg(test)]
122    pub(crate) fn drain_completed(
123        &mut self,
124        tool_call_statuses: &ToolCallStatuses,
125    ) -> (Vec<SegmentContent>, Vec<String>) {
126        let drained = self.drain_segments_except(|seg| {
127            matches!(seg, SegmentContent::ToolCall(id) if tool_call_statuses.is_tool_running(id))
128        });
129
130        let mut content = Vec::new();
131        let mut completed_tool_ids = Vec::new();
132
133        for segment in drained {
134            if let SegmentContent::ToolCall(ref id) = segment.content {
135                completed_tool_ids.push(id.clone());
136            }
137            content.push(segment.content);
138        }
139
140        (content, completed_tool_ids)
141    }
142}
143
144pub struct ConversationWindow<'a> {
145    pub conversation: &'a ConversationBuffer,
146    pub tool_call_statuses: &'a ToolCallStatuses,
147}
148
149impl ConversationWindow<'_> {
150    pub fn render(&self, context: &ViewContext) -> Vec<Line> {
151        let mut lines = Vec::new();
152        let mut last_segment_kind = None;
153
154        for segment in &self.conversation.segments {
155            let kind = discriminant(&segment.content);
156            let rendered =
157                render_stream_segment(&segment.content, self.tool_call_statuses, context);
158            extend_with_vertical_margin(&mut lines, &mut last_segment_kind, kind, &rendered);
159        }
160
161        lines
162    }
163}
164
165fn render_stream_segment(
166    segment: &SegmentContent,
167    tool_call_statuses: &ToolCallStatuses,
168    context: &ViewContext,
169) -> Vec<Line> {
170    match segment {
171        SegmentContent::UserMessage(text) => vec![Line::new(text.clone())],
172        SegmentContent::Thought(text) => ThoughtMessage { text }.render(context),
173        SegmentContent::Text(text) => render_markdown(text, context),
174        SegmentContent::ToolCall(id) => tool_call_statuses.render_tool(id, context),
175    }
176}
177
178fn extend_with_vertical_margin(
179    target: &mut Vec<Line>,
180    last_segment_kind: &mut Option<Discriminant<SegmentContent>>,
181    kind: Discriminant<SegmentContent>,
182    lines: &[Line],
183) {
184    if lines.is_empty() {
185        return;
186    }
187
188    if let Some(prev_kind) = *last_segment_kind
189        && prev_kind != kind
190    {
191        target.push(Line::new(String::new()));
192    }
193
194    target.extend_from_slice(lines);
195    *last_segment_kind = Some(kind);
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn buffer_closes_thought_block_when_text_arrives() {
204        let mut buffer = ConversationBuffer::new();
205        buffer.append_thought_chunk("thinking");
206        buffer.append_text_chunk("answer");
207        buffer.append_thought_chunk("new thought");
208
209        let segments: Vec<_> = buffer.segments().collect();
210        assert_eq!(segments.len(), 3);
211        assert!(matches!(segments[0], SegmentContent::Thought(_)));
212        assert!(matches!(segments[1], SegmentContent::Text(_)));
213        assert!(matches!(segments[2], SegmentContent::Thought(_)));
214    }
215
216    #[test]
217    fn buffer_coalesces_contiguous_thought_chunks() {
218        let mut buffer = ConversationBuffer::new();
219        buffer.append_thought_chunk("a");
220        buffer.append_thought_chunk("b");
221
222        let segments: Vec<_> = buffer.segments().collect();
223        assert_eq!(segments.len(), 1);
224        match segments[0] {
225            SegmentContent::Thought(text) => assert_eq!(text, "ab"),
226            _ => panic!("expected thought segment"),
227        }
228    }
229
230    #[test]
231    fn clear_removes_segments_and_resets_state() {
232        let mut buffer = ConversationBuffer::new();
233        buffer.append_thought_chunk("thinking");
234        buffer.append_text_chunk("answer");
235        assert_eq!(buffer.segments().len(), 2);
236
237        buffer.clear();
238
239        assert_eq!(buffer.segments().len(), 0);
240        buffer.append_thought_chunk("new");
241        assert_eq!(buffer.segments().len(), 1);
242    }
243
244    #[test]
245    fn drain_completed_returns_content_and_tool_ids() {
246        use agent_client_protocol as acp;
247
248        let mut buffer = ConversationBuffer::new();
249        buffer.append_text_chunk("hello");
250        buffer.ensure_tool_segment("tool-1");
251
252        let mut statuses = ToolCallStatuses::new();
253        let tc = acp::ToolCall::new("tool-1", "Read file");
254        statuses.on_tool_call(&tc);
255        let update = acp::ToolCallUpdate::new(
256            "tool-1",
257            acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed),
258        );
259        statuses.on_tool_call_update(&update);
260
261        let (content, tool_ids) = buffer.drain_completed(&statuses);
262
263        assert_eq!(content.len(), 2, "should have text and tool content");
264        assert!(matches!(content[0], SegmentContent::Text(_)));
265        assert!(matches!(content[1], SegmentContent::ToolCall(_)));
266        assert_eq!(tool_ids, vec!["tool-1"]);
267        assert_eq!(buffer.segments().len(), 0, "all segments should be drained");
268    }
269
270    #[test]
271    fn drain_completed_keeps_running_tools() {
272        use agent_client_protocol as acp;
273
274        let mut buffer = ConversationBuffer::new();
275        buffer.append_text_chunk("hello");
276        buffer.ensure_tool_segment("tool-1");
277
278        let mut statuses = ToolCallStatuses::new();
279        let tc = acp::ToolCall::new("tool-1", "Read file");
280        statuses.on_tool_call(&tc);
281        // tool-1 stays Running (no completion update)
282
283        let (content, tool_ids) = buffer.drain_completed(&statuses);
284
285        assert_eq!(content.len(), 1, "text segment should still be drained");
286        assert!(matches!(content[0], SegmentContent::Text(_)));
287        assert!(tool_ids.is_empty(), "running tool should not be drained");
288        let segments: Vec<_> = buffer.segments().collect();
289        assert_eq!(segments.len(), 1, "running tool should remain");
290        assert!(matches!(
291            segments[0],
292            SegmentContent::ToolCall(id) if id == "tool-1"
293        ));
294    }
295}