Skip to main content

par_term/ai_inspector/
chat.rs

1//! Chat data model for agent conversations in the AI Inspector.
2//!
3//! Provides [`ChatState`] for tracking the conversation between the user and
4//! an ACP agent, including streaming message assembly, tool call tracking,
5//! permission requests, and system messages.
6
7use par_term_acp::SessionUpdate;
8
9/// A message in the chat history.
10#[derive(Debug, Clone)]
11pub enum ChatMessage {
12    /// A message sent by the user.
13    User(String),
14    /// A completed response from the agent.
15    Agent(String),
16    /// The agent's internal reasoning / chain-of-thought.
17    Thinking(String),
18    /// A tool call initiated by the agent.
19    ToolCall {
20        tool_call_id: String,
21        title: String,
22        kind: String,
23        status: String,
24    },
25    /// A command suggestion from the agent.
26    CommandSuggestion(String),
27    /// A permission request from the agent awaiting user action.
28    Permission {
29        request_id: u64,
30        description: String,
31        options: Vec<(String, String)>, // (option_id, label)
32        resolved: bool,
33    },
34    /// A tool call that was automatically approved.
35    AutoApproved(String),
36    /// A system-level informational message.
37    System(String),
38}
39
40/// System guidance prepended to the first user prompt so the agent always
41/// wraps shell commands in fenced code blocks (which the UI extracts as
42/// runnable `CommandSuggestion` entries).
43pub const AGENT_SYSTEM_GUIDANCE: &str = "\
44[System context] You are an AI assistant running via the ACP (Agent Communication \
45Protocol) inside par-term, a GPU-accelerated terminal emulator. \
46You have filesystem access through ACP: you can read and write files. \
47IMPORTANT: Some local tools like Find/Glob may not work in this ACP environment. \
48If a file search or directory listing fails, do NOT stop — instead work around it: \
49use shell commands (ls, find) wrapped in code blocks to discover files, or ask the \
50user for paths. Always continue helping even when a tool call fails. \
51When you suggest shell commands, ALWAYS wrap them in a fenced code block with a \
52shell language tag (```bash, ```sh, ```zsh, or ```shell). \
53The terminal UI will detect these blocks and render them with \"Run\" and \"Paste\" \
54buttons so the user can execute them directly. When the user runs a command, \
55you will receive a notification with the exit code, and the command output will \
56be visible to you through the normal terminal capture channel. \
57Do NOT add disclaimers about output not being captured. \
58Plain-text command suggestions will NOT be actionable. \
59Never use bare ``` blocks for commands — always include the language tag. \
60To modify par-term settings (shaders, font_size, window_opacity, etc.), use the \
61`config_update` MCP tool (available via par-term-config MCP server). \
62Example: call config_update with updates: {\"custom_shader\": \"crt.glsl\", \
63\"custom_shader_enabled\": true}. Changes apply immediately — no restart needed. \
64IMPORTANT: Do NOT edit ~/.config/par-term/config.yaml directly — always use the \
65config_update tool instead. Direct config.yaml edits race with par-term's own \
66config saves and will be silently overwritten.\n\n";
67
68/// Chat state for the agent conversation.
69pub struct ChatState {
70    /// All messages in the conversation history.
71    pub messages: Vec<ChatMessage>,
72    /// The current text input from the user (not yet sent).
73    pub input: String,
74    /// Whether the agent is currently streaming a response.
75    pub streaming: bool,
76    /// Whether the system guidance has been sent with the first prompt.
77    pub system_prompt_sent: bool,
78    /// Buffer for assembling agent message chunks before flushing.
79    agent_text_buffer: String,
80}
81
82impl ChatState {
83    /// Create a new empty chat state.
84    pub fn new() -> Self {
85        Self {
86            messages: Vec::new(),
87            input: String::new(),
88            streaming: false,
89            system_prompt_sent: false,
90            agent_text_buffer: String::new(),
91        }
92    }
93
94    /// Process an incoming [`SessionUpdate`] from the agent, updating chat
95    /// state accordingly.
96    ///
97    /// Non-chunk updates automatically flush any accumulated agent text
98    /// buffer so that the complete message is recorded before tool calls
99    /// or other events.
100    pub fn handle_update(&mut self, update: SessionUpdate) {
101        match update {
102            SessionUpdate::AgentMessageChunk { text } => {
103                self.agent_text_buffer.push_str(&text);
104                self.streaming = true;
105            }
106            SessionUpdate::AgentThoughtChunk { text } => {
107                // Coalesce consecutive thought chunks into a single Thinking message.
108                if let Some(ChatMessage::Thinking(existing)) = self.messages.last_mut() {
109                    existing.push_str(&text);
110                } else {
111                    self.messages.push(ChatMessage::Thinking(text));
112                }
113            }
114            SessionUpdate::ToolCall(info) => {
115                // Flush any pending agent text before recording a tool call.
116                self.flush_agent_message();
117                self.messages.push(ChatMessage::ToolCall {
118                    tool_call_id: info.tool_call_id,
119                    title: info.title,
120                    kind: info.kind,
121                    status: info.status,
122                });
123            }
124            SessionUpdate::ToolCallUpdate(info) => {
125                // Find the matching tool call by id (searching from most recent).
126                for msg in self.messages.iter_mut().rev() {
127                    if let ChatMessage::ToolCall {
128                        tool_call_id,
129                        status,
130                        title,
131                        ..
132                    } = msg
133                        && *tool_call_id == info.tool_call_id
134                    {
135                        if let Some(new_status) = &info.status {
136                            *status = new_status.clone();
137                        }
138                        if let Some(new_title) = &info.title {
139                            *title = new_title.clone();
140                        }
141                        break;
142                    }
143                }
144            }
145            _ => {
146                // For any other update type, flush pending text.
147                self.flush_agent_message();
148            }
149        }
150    }
151
152    /// Flush the agent text buffer into a completed [`ChatMessage::Agent`]
153    /// message and reset streaming state.
154    ///
155    /// Also extracts any fenced bash/sh code blocks and appends them as
156    /// [`ChatMessage::CommandSuggestion`] entries so the UI can offer
157    /// "Run in terminal" buttons.
158    pub fn flush_agent_message(&mut self) {
159        if !self.agent_text_buffer.is_empty() {
160            let text = std::mem::take(&mut self.agent_text_buffer);
161            let trimmed = text.trim_end().to_string();
162
163            // Extract fenced code blocks with bash/sh language tags
164            let commands = extract_code_block_commands(&trimmed);
165
166            self.messages.push(ChatMessage::Agent(trimmed));
167
168            for cmd in commands {
169                self.messages.push(ChatMessage::CommandSuggestion(cmd));
170            }
171        }
172        self.streaming = false;
173    }
174
175    /// Returns the current in-progress streaming text (not yet flushed).
176    pub fn streaming_text(&self) -> &str {
177        &self.agent_text_buffer
178    }
179
180    /// Add a user message to the conversation.
181    ///
182    /// Flushes any pending agent text first so messages stay interleaved.
183    pub fn add_user_message(&mut self, text: String) {
184        self.flush_agent_message();
185        self.messages.push(ChatMessage::User(text));
186    }
187
188    /// Add a system message to the conversation.
189    pub fn add_system_message(&mut self, text: String) {
190        self.messages.push(ChatMessage::System(text));
191    }
192
193    /// Add a command suggestion to the conversation.
194    pub fn add_command_suggestion(&mut self, command: String) {
195        self.messages.push(ChatMessage::CommandSuggestion(command));
196    }
197
198    /// Add an auto-approved tool call notice to the conversation.
199    pub fn add_auto_approved(&mut self, description: String) {
200        self.messages.push(ChatMessage::AutoApproved(description));
201    }
202}
203
204/// Extract shell commands from fenced code blocks in text.
205///
206/// Looks for code blocks tagged with `bash`, `sh`, `shell`, or `zsh`.
207/// Each line of the code block is treated as a separate command.
208/// Lines starting with `#` (comments) or empty lines are skipped.
209fn extract_code_block_commands(text: &str) -> Vec<String> {
210    let mut commands = Vec::new();
211    let mut in_block = false;
212    let mut is_shell_block = false;
213
214    for line in text.lines() {
215        let trimmed = line.trim();
216        if trimmed.starts_with("```") {
217            if in_block {
218                // End of block
219                in_block = false;
220                is_shell_block = false;
221            } else {
222                // Start of block — check language tag
223                let lang = trimmed.trim_start_matches('`').trim().to_lowercase();
224                is_shell_block = lang == "bash" || lang == "sh" || lang == "shell" || lang == "zsh";
225                in_block = true;
226            }
227            continue;
228        }
229
230        if in_block && is_shell_block {
231            let cmd = trimmed.strip_prefix("$ ").unwrap_or(trimmed);
232            if !cmd.is_empty() && !cmd.starts_with('#') {
233                commands.push(cmd.to_string());
234            }
235        }
236    }
237
238    commands
239}
240
241impl Default for ChatState {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Tests
249// ---------------------------------------------------------------------------
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use par_term_acp::{ToolCallInfo, ToolCallUpdateInfo};
255
256    #[test]
257    fn test_new_chat_state() {
258        let state = ChatState::new();
259        assert!(state.messages.is_empty());
260        assert!(state.input.is_empty());
261        assert!(!state.streaming);
262    }
263
264    #[test]
265    fn test_default_chat_state() {
266        let state = ChatState::default();
267        assert!(state.messages.is_empty());
268        assert!(!state.streaming);
269    }
270
271    #[test]
272    fn test_handle_agent_message_chunks() {
273        let mut state = ChatState::new();
274        state.handle_update(SessionUpdate::AgentMessageChunk {
275            text: "Hello ".to_string(),
276        });
277        state.handle_update(SessionUpdate::AgentMessageChunk {
278            text: "world".to_string(),
279        });
280        assert!(state.streaming);
281        assert_eq!(state.streaming_text(), "Hello world");
282
283        state.flush_agent_message();
284        assert!(!state.streaming);
285        assert_eq!(state.messages.len(), 1);
286        match &state.messages[0] {
287            ChatMessage::Agent(text) => assert_eq!(text, "Hello world"),
288            _ => panic!("Expected Agent message"),
289        }
290    }
291
292    #[test]
293    fn test_flush_empty_buffer_no_message() {
294        let mut state = ChatState::new();
295        state.flush_agent_message();
296        assert!(state.messages.is_empty());
297        assert!(!state.streaming);
298    }
299
300    #[test]
301    fn test_flush_trims_trailing_whitespace() {
302        let mut state = ChatState::new();
303        state.handle_update(SessionUpdate::AgentMessageChunk {
304            text: "Hello  \n\n".to_string(),
305        });
306        state.flush_agent_message();
307        match &state.messages[0] {
308            ChatMessage::Agent(text) => assert_eq!(text, "Hello"),
309            _ => panic!("Expected Agent message"),
310        }
311    }
312
313    #[test]
314    fn test_handle_thinking_chunks() {
315        let mut state = ChatState::new();
316        state.handle_update(SessionUpdate::AgentThoughtChunk {
317            text: "Let me ".to_string(),
318        });
319        state.handle_update(SessionUpdate::AgentThoughtChunk {
320            text: "think...".to_string(),
321        });
322        assert_eq!(state.messages.len(), 1);
323        match &state.messages[0] {
324            ChatMessage::Thinking(text) => assert_eq!(text, "Let me think..."),
325            _ => panic!("Expected Thinking message"),
326        }
327    }
328
329    #[test]
330    fn test_thinking_not_coalesced_after_other_message() {
331        let mut state = ChatState::new();
332        state.handle_update(SessionUpdate::AgentThoughtChunk {
333            text: "First thought".to_string(),
334        });
335        state.add_user_message("Interruption".to_string());
336        state.handle_update(SessionUpdate::AgentThoughtChunk {
337            text: "Second thought".to_string(),
338        });
339        assert_eq!(state.messages.len(), 3);
340        match &state.messages[0] {
341            ChatMessage::Thinking(text) => assert_eq!(text, "First thought"),
342            _ => panic!("Expected Thinking"),
343        }
344        match &state.messages[2] {
345            ChatMessage::Thinking(text) => assert_eq!(text, "Second thought"),
346            _ => panic!("Expected Thinking"),
347        }
348    }
349
350    #[test]
351    fn test_handle_tool_call_and_update() {
352        let mut state = ChatState::new();
353        state.handle_update(SessionUpdate::ToolCall(ToolCallInfo {
354            tool_call_id: "tc-1".to_string(),
355            title: "Reading file".to_string(),
356            kind: "read".to_string(),
357            status: "in_progress".to_string(),
358            content: None,
359        }));
360        state.handle_update(SessionUpdate::ToolCallUpdate(ToolCallUpdateInfo {
361            tool_call_id: "tc-1".to_string(),
362            status: Some("completed".to_string()),
363            title: None,
364            content: None,
365        }));
366        assert_eq!(state.messages.len(), 1);
367        match &state.messages[0] {
368            ChatMessage::ToolCall { status, title, .. } => {
369                assert_eq!(status, "completed");
370                assert_eq!(title, "Reading file");
371            }
372            _ => panic!("Expected ToolCall"),
373        }
374    }
375
376    #[test]
377    fn test_tool_call_update_matches_by_id() {
378        let mut state = ChatState::new();
379        state.handle_update(SessionUpdate::ToolCall(ToolCallInfo {
380            tool_call_id: "tc-1".to_string(),
381            title: "Read file A".to_string(),
382            kind: "read".to_string(),
383            status: "in_progress".to_string(),
384            content: None,
385        }));
386        state.handle_update(SessionUpdate::ToolCall(ToolCallInfo {
387            tool_call_id: "tc-2".to_string(),
388            title: "Read file B".to_string(),
389            kind: "read".to_string(),
390            status: "in_progress".to_string(),
391            content: None,
392        }));
393
394        // Update the first tool call, not the second.
395        state.handle_update(SessionUpdate::ToolCallUpdate(ToolCallUpdateInfo {
396            tool_call_id: "tc-1".to_string(),
397            status: Some("completed".to_string()),
398            title: Some("Read file A (done)".to_string()),
399            content: None,
400        }));
401
402        match &state.messages[0] {
403            ChatMessage::ToolCall {
404                tool_call_id,
405                status,
406                title,
407                ..
408            } => {
409                assert_eq!(tool_call_id, "tc-1");
410                assert_eq!(status, "completed");
411                assert_eq!(title, "Read file A (done)");
412            }
413            _ => panic!("Expected ToolCall"),
414        }
415        // Second tool call unchanged.
416        match &state.messages[1] {
417            ChatMessage::ToolCall {
418                tool_call_id,
419                status,
420                title,
421                ..
422            } => {
423                assert_eq!(tool_call_id, "tc-2");
424                assert_eq!(status, "in_progress");
425                assert_eq!(title, "Read file B");
426            }
427            _ => panic!("Expected ToolCall"),
428        }
429    }
430
431    #[test]
432    fn test_tool_call_update_nonexistent_id_is_noop() {
433        let mut state = ChatState::new();
434        state.handle_update(SessionUpdate::ToolCall(ToolCallInfo {
435            tool_call_id: "tc-1".to_string(),
436            title: "Read file".to_string(),
437            kind: "read".to_string(),
438            status: "in_progress".to_string(),
439            content: None,
440        }));
441        // Update for a different id should be a no-op.
442        state.handle_update(SessionUpdate::ToolCallUpdate(ToolCallUpdateInfo {
443            tool_call_id: "tc-999".to_string(),
444            status: Some("completed".to_string()),
445            title: None,
446            content: None,
447        }));
448        match &state.messages[0] {
449            ChatMessage::ToolCall { status, .. } => assert_eq!(status, "in_progress"),
450            _ => panic!("Expected ToolCall"),
451        }
452    }
453
454    #[test]
455    fn test_handle_unknown_update_is_noop() {
456        let mut state = ChatState::new();
457        state.handle_update(SessionUpdate::Unknown(serde_json::json!({"foo": "bar"})));
458        assert!(state.messages.is_empty());
459    }
460
461    #[test]
462    fn test_add_messages() {
463        let mut state = ChatState::new();
464        state.add_user_message("test".to_string());
465        state.add_system_message("system".to_string());
466        state.add_command_suggestion("cargo test".to_string());
467        state.add_auto_approved("read file".to_string());
468        assert_eq!(state.messages.len(), 4);
469
470        assert!(matches!(&state.messages[0], ChatMessage::User(t) if t == "test"));
471        assert!(matches!(&state.messages[1], ChatMessage::System(t) if t == "system"));
472        assert!(
473            matches!(&state.messages[2], ChatMessage::CommandSuggestion(t) if t == "cargo test")
474        );
475        assert!(matches!(&state.messages[3], ChatMessage::AutoApproved(t) if t == "read file"));
476    }
477
478    #[test]
479    fn test_extract_code_block_commands_bash() {
480        let text = "Here's a command:\n```bash\ncargo test\ncargo build --release\n```\nDone.";
481        let cmds = extract_code_block_commands(text);
482        assert_eq!(cmds, vec!["cargo test", "cargo build --release"]);
483    }
484
485    #[test]
486    fn test_extract_code_block_commands_sh() {
487        let text = "Try this:\n```sh\n$ echo hello\n$ ls -la\n```";
488        let cmds = extract_code_block_commands(text);
489        assert_eq!(cmds, vec!["echo hello", "ls -la"]);
490    }
491
492    #[test]
493    fn test_extract_code_block_commands_skips_comments_and_empty() {
494        let text = "```bash\n# This is a comment\n\necho hello\n```";
495        let cmds = extract_code_block_commands(text);
496        assert_eq!(cmds, vec!["echo hello"]);
497    }
498
499    #[test]
500    fn test_extract_code_block_commands_ignores_non_shell() {
501        let text = "```python\nprint('hello')\n```\n```bash\necho hi\n```";
502        let cmds = extract_code_block_commands(text);
503        assert_eq!(cmds, vec!["echo hi"]);
504    }
505
506    #[test]
507    fn test_extract_code_block_commands_no_blocks() {
508        let text = "No code blocks here.";
509        let cmds = extract_code_block_commands(text);
510        assert!(cmds.is_empty());
511    }
512
513    #[test]
514    fn test_extract_code_block_commands_ignores_bare_blocks() {
515        let text =
516            "Description:\n```\nThis is just text, not a command.\n```\n```bash\ngit status\n```";
517        let cmds = extract_code_block_commands(text);
518        assert_eq!(cmds, vec!["git status"]);
519    }
520
521    #[test]
522    fn test_flush_extracts_command_suggestions() {
523        let mut state = ChatState::new();
524        state.handle_update(SessionUpdate::AgentMessageChunk {
525            text: "Try this:\n```bash\ncargo test\n```".to_string(),
526        });
527        state.flush_agent_message();
528        assert_eq!(state.messages.len(), 2);
529        assert!(matches!(&state.messages[0], ChatMessage::Agent(_)));
530        assert!(
531            matches!(&state.messages[1], ChatMessage::CommandSuggestion(cmd) if cmd == "cargo test")
532        );
533    }
534}