wisp/components/
conversation_window.rs1use 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 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}