Skip to main content

par_term/ai_inspector/chat/
state.rs

1//! `ChatState` — manages the conversation history, streaming buffer, and
2//! message assembly for the AI Inspector panel.
3
4use par_term_acp::SessionUpdate;
5
6use super::text_utils::{extract_code_block_commands, truncate_replay_text};
7use super::types::ChatMessage;
8
9/// Chat state for the agent conversation.
10pub struct ChatState {
11    /// All messages in the conversation history.
12    pub messages: Vec<ChatMessage>,
13    /// The current text input from the user (not yet sent).
14    pub input: String,
15    /// Previously submitted user prompts, newest first.
16    input_history: Vec<String>,
17    /// Current position while navigating input history.
18    input_history_cursor: Option<usize>,
19    /// Draft text to restore when navigating back past the newest history entry.
20    input_history_draft: Option<String>,
21    /// Whether the agent is currently streaming a response.
22    pub streaming: bool,
23    /// Buffer for assembling agent message chunks before flushing.
24    agent_text_buffer: String,
25}
26
27impl ChatState {
28    /// Create a new empty chat state.
29    pub fn new() -> Self {
30        Self {
31            messages: Vec::new(),
32            input: String::new(),
33            input_history: Vec::new(),
34            input_history_cursor: None,
35            input_history_draft: None,
36            streaming: false,
37            agent_text_buffer: String::new(),
38        }
39    }
40
41    /// Process an incoming [`SessionUpdate`] from the agent, updating chat
42    /// state accordingly.
43    ///
44    /// Non-chunk updates automatically flush any accumulated agent text
45    /// buffer so that the complete message is recorded before tool calls
46    /// or other events.
47    pub fn handle_update(&mut self, update: SessionUpdate) {
48        match update {
49            SessionUpdate::AgentMessageChunk { text } => {
50                self.agent_text_buffer.push_str(&text);
51                self.streaming = true;
52            }
53            SessionUpdate::AgentThoughtChunk { text } => {
54                // Coalesce consecutive thought chunks into a single Thinking message.
55                if let Some(ChatMessage::Thinking(existing)) = self.messages.last_mut() {
56                    existing.push_str(&text);
57                } else {
58                    self.messages.push(ChatMessage::Thinking(text));
59                }
60            }
61            SessionUpdate::ToolCall(info) => {
62                // Flush any pending agent text before recording a tool call.
63                self.flush_agent_message();
64                self.messages.push(ChatMessage::ToolCall {
65                    tool_call_id: info.tool_call_id,
66                    title: info.title,
67                    kind: info.kind,
68                    status: info.status,
69                });
70            }
71            SessionUpdate::ToolCallUpdate(info) => {
72                // Find the matching tool call by id (searching from most recent).
73                for msg in self.messages.iter_mut().rev() {
74                    if let ChatMessage::ToolCall {
75                        tool_call_id,
76                        status,
77                        title,
78                        ..
79                    } = msg
80                        && *tool_call_id == info.tool_call_id
81                    {
82                        if let Some(new_status) = &info.status {
83                            *status = new_status.clone();
84                        }
85                        if let Some(new_title) = &info.title {
86                            *title = new_title.clone();
87                        }
88                        break;
89                    }
90                }
91            }
92            _ => {
93                // For any other update type, flush pending text.
94                self.flush_agent_message();
95            }
96        }
97    }
98
99    /// Flush the agent text buffer into a completed [`ChatMessage::Agent`]
100    /// message and reset streaming state.
101    ///
102    /// Also extracts any fenced bash/sh code blocks and appends them as
103    /// [`ChatMessage::CommandSuggestion`] entries so the UI can offer
104    /// "Run in terminal" buttons.
105    pub fn flush_agent_message(&mut self) {
106        if !self.agent_text_buffer.is_empty() {
107            let text = std::mem::take(&mut self.agent_text_buffer);
108            let trimmed = text.trim_end().to_string();
109
110            // Extract fenced code blocks with bash/sh language tags
111            let commands = extract_code_block_commands(&trimmed);
112
113            self.messages.push(ChatMessage::Agent(trimmed));
114
115            for cmd in commands {
116                self.messages.push(ChatMessage::CommandSuggestion(cmd));
117            }
118        }
119        self.streaming = false;
120    }
121
122    /// Returns the current in-progress streaming text (not yet flushed).
123    pub fn streaming_text(&self) -> &str {
124        &self.agent_text_buffer
125    }
126
127    /// Add a user message to the conversation.
128    ///
129    /// Flushes any pending agent text first so messages stay interleaved.
130    /// The message starts as `pending: true` (queued, not yet sent).
131    pub fn add_user_message(&mut self, text: String) {
132        self.flush_agent_message();
133        self.messages.push(ChatMessage::User {
134            text,
135            pending: true,
136        });
137    }
138
139    pub fn set_input_history(&mut self, entries: Vec<String>) {
140        self.input_history = par_term_config::normalize_assistant_input_history(entries);
141        self.reset_input_history_navigation();
142    }
143
144    pub fn input_history_entries(&self) -> &[String] {
145        &self.input_history
146    }
147
148    pub fn record_user_input_history(&mut self, text: &str) {
149        let trimmed = text.trim();
150        if trimmed.is_empty() {
151            self.reset_input_history_navigation();
152            return;
153        }
154
155        let mut entries = Vec::with_capacity(self.input_history.len() + 1);
156        entries.push(trimmed.to_string());
157        entries.extend(self.input_history.iter().cloned());
158        self.input_history = par_term_config::normalize_assistant_input_history(entries);
159        self.reset_input_history_navigation();
160    }
161
162    pub fn navigate_input_history_older(&mut self) -> bool {
163        if self.input_history.is_empty() {
164            return false;
165        }
166
167        let next_cursor = match self.input_history_cursor {
168            Some(cursor) if cursor + 1 < self.input_history.len() => cursor + 1,
169            Some(_) => return false,
170            None => {
171                self.input_history_draft = Some(self.input.clone());
172                0
173            }
174        };
175
176        self.input_history_cursor = Some(next_cursor);
177        self.input = self.input_history[next_cursor].clone();
178        true
179    }
180
181    pub fn navigate_input_history_newer(&mut self) -> bool {
182        let Some(cursor) = self.input_history_cursor else {
183            return false;
184        };
185
186        if cursor == 0 {
187            self.input_history_cursor = None;
188            self.input = self.input_history_draft.take().unwrap_or_default();
189            return true;
190        }
191
192        let next_cursor = cursor - 1;
193        self.input_history_cursor = Some(next_cursor);
194        self.input = self.input_history[next_cursor].clone();
195        true
196    }
197
198    pub fn reset_input_history_navigation(&mut self) {
199        self.input_history_cursor = None;
200        self.input_history_draft = None;
201    }
202
203    /// Mark the oldest pending user message as sent (no longer cancellable).
204    pub fn mark_oldest_pending_sent(&mut self) {
205        for msg in &mut self.messages {
206            if let ChatMessage::User { pending, .. } = msg
207                && *pending
208            {
209                *pending = false;
210                return;
211            }
212        }
213    }
214
215    /// Cancel and remove the most recent pending user message.
216    ///
217    /// Returns `true` if a message was removed.
218    pub fn cancel_last_pending(&mut self) -> bool {
219        for i in (0..self.messages.len()).rev() {
220            if let ChatMessage::User { pending: true, .. } = &self.messages[i] {
221                self.messages.remove(i);
222                return true;
223            }
224        }
225        false
226    }
227
228    /// Add a system message to the conversation.
229    pub fn add_system_message(&mut self, text: String) {
230        self.messages.push(ChatMessage::System(text));
231    }
232
233    /// Add a command suggestion to the conversation.
234    pub fn add_command_suggestion(&mut self, command: String) {
235        self.messages.push(ChatMessage::CommandSuggestion(command));
236    }
237
238    /// Add an auto-approved tool call notice to the conversation.
239    pub fn add_auto_approved(&mut self, description: String) {
240        self.messages.push(ChatMessage::AutoApproved(description));
241    }
242
243    /// Clear all chat messages and reset streaming state.
244    pub fn clear(&mut self) {
245        self.messages.clear();
246        self.agent_text_buffer.clear();
247        self.streaming = false;
248    }
249
250    /// Build a bounded transcript prompt for restoring local chat context
251    /// into a newly connected ACP session.
252    ///
253    /// This is best-effort: it preserves visible UI conversation context
254    /// (user/agent/system/tool summaries), not the agent's internal session
255    /// state or permission request identifiers.
256    pub fn build_context_replay_prompt(&self) -> Option<String> {
257        const MAX_ENTRIES: usize = 24;
258        const MAX_TOTAL_CHARS: usize = 16_000;
259        const MAX_ENTRY_CHARS: usize = 1_200;
260
261        let mut entries: Vec<String> = Vec::new();
262
263        for msg in &self.messages {
264            match msg {
265                ChatMessage::User {
266                    text,
267                    pending: false,
268                } => {
269                    entries.push(format!(
270                        "[User]\n{}",
271                        truncate_replay_text(text, MAX_ENTRY_CHARS)
272                    ));
273                }
274                ChatMessage::User { pending: true, .. } => {
275                    // Skip queued/unsent prompts: the new session never saw them.
276                }
277                ChatMessage::Agent(text) => {
278                    entries.push(format!(
279                        "[Assistant]\n{}",
280                        truncate_replay_text(text, MAX_ENTRY_CHARS)
281                    ));
282                }
283                ChatMessage::System(text) => {
284                    entries.push(format!(
285                        "[System]\n{}",
286                        truncate_replay_text(text, MAX_ENTRY_CHARS / 2)
287                    ));
288                }
289                ChatMessage::AutoApproved(desc) => {
290                    entries.push(format!(
291                        "[Tool Auto-Approved]\n{}",
292                        truncate_replay_text(desc, MAX_ENTRY_CHARS / 2)
293                    ));
294                }
295                ChatMessage::ToolCall {
296                    title,
297                    kind,
298                    status,
299                    ..
300                } => {
301                    entries.push(format!(
302                        "[Tool Call]\n{} ({kind}) - {status}",
303                        truncate_replay_text(title, MAX_ENTRY_CHARS / 2)
304                    ));
305                }
306                ChatMessage::Permission {
307                    description,
308                    resolved,
309                    ..
310                } => {
311                    let state = if *resolved { "resolved" } else { "unresolved" };
312                    entries.push(format!(
313                        "[Permission Request - {state}]\n{}",
314                        truncate_replay_text(description, MAX_ENTRY_CHARS / 2)
315                    ));
316                }
317                ChatMessage::Thinking(_) | ChatMessage::CommandSuggestion(_) => {
318                    // Skip internal reasoning and derived command suggestions to
319                    // reduce noise/duplication in the replay transcript.
320                }
321            }
322        }
323
324        if !self.agent_text_buffer.trim().is_empty() {
325            entries.push(format!(
326                "[Assistant Partial]\n{}",
327                truncate_replay_text(&self.agent_text_buffer, MAX_ENTRY_CHARS)
328            ));
329        }
330
331        if entries.is_empty() {
332            return None;
333        }
334
335        let mut selected: Vec<String> = Vec::new();
336        let mut total_chars = 0usize;
337        for entry in entries.iter().rev() {
338            let entry_chars = entry.chars().count();
339            if !selected.is_empty()
340                && (selected.len() >= MAX_ENTRIES || total_chars + entry_chars > MAX_TOTAL_CHARS)
341            {
342                break;
343            }
344            total_chars += entry_chars;
345            selected.push(entry.clone());
346        }
347        selected.reverse();
348
349        let mut prompt = String::from(
350            "[System: par-term context restore]\n\
351The following is a best-effort transcript reconstructed from the local UI chat \
352history after reconnecting or switching agent/provider. It preserves visible \
353conversation context only (not hidden session state, pending permissions, or \
354tool-call IDs). Use it to continue the conversation naturally from the latest \
355user request. Do not restate the transcript unless asked.\n\n",
356        );
357
358        if selected.len() < entries.len() {
359            prompt.push_str("[Older transcript entries omitted for length.]\n\n");
360        }
361
362        prompt.push_str(&selected.join("\n\n"));
363        Some(prompt)
364    }
365}
366
367impl Default for ChatState {
368    fn default() -> Self {
369        Self::new()
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::ChatState;
376
377    #[test]
378    fn assistant_input_history_records_trimmed_newest_unique_entries() {
379        let mut state = ChatState::new();
380
381        state.record_user_input_history("  newest  ");
382        state.record_user_input_history("");
383        state.record_user_input_history("older");
384        state.record_user_input_history("newest");
385
386        assert_eq!(
387            state.input_history_entries(),
388            ["newest".to_string(), "older".to_string()]
389        );
390    }
391
392    #[test]
393    fn assistant_input_history_navigate_older_snapshots_draft() {
394        let mut state = ChatState::new();
395        state.set_input_history(vec!["newest".to_string(), "older".to_string()]);
396        state.input = "draft".to_string();
397
398        assert!(state.navigate_input_history_older());
399        assert_eq!(state.input, "newest");
400
401        assert!(state.navigate_input_history_older());
402        assert_eq!(state.input, "older");
403
404        assert!(!state.navigate_input_history_older());
405        assert_eq!(state.input, "older");
406    }
407
408    #[test]
409    fn assistant_input_history_navigate_newer_restores_saved_draft() {
410        let mut state = ChatState::new();
411        state.set_input_history(vec!["newest".to_string(), "older".to_string()]);
412        state.input = "draft".to_string();
413        assert!(state.navigate_input_history_older());
414        assert!(state.navigate_input_history_older());
415
416        assert!(state.navigate_input_history_newer());
417        assert_eq!(state.input, "newest");
418
419        assert!(state.navigate_input_history_newer());
420        assert_eq!(state.input, "draft");
421
422        assert!(!state.navigate_input_history_newer());
423        assert_eq!(state.input, "draft");
424    }
425
426    #[test]
427    fn assistant_input_history_recording_new_prompt_resets_navigation_state() {
428        let mut state = ChatState::new();
429        state.set_input_history(vec!["newest".to_string(), "older".to_string()]);
430        state.input = "draft".to_string();
431        assert!(state.navigate_input_history_older());
432
433        state.record_user_input_history("fresh");
434        state.input = "current draft".to_string();
435
436        assert!(state.navigate_input_history_older());
437        assert_eq!(state.input, "fresh");
438        assert!(state.navigate_input_history_newer());
439        assert_eq!(state.input, "current draft");
440    }
441}