1use par_term_acp::SessionUpdate;
8
9#[derive(Debug, Clone)]
11pub enum ChatMessage {
12 User(String),
14 Agent(String),
16 Thinking(String),
18 ToolCall {
20 tool_call_id: String,
21 title: String,
22 kind: String,
23 status: String,
24 },
25 CommandSuggestion(String),
27 Permission {
29 request_id: u64,
30 description: String,
31 options: Vec<(String, String)>, resolved: bool,
33 },
34 AutoApproved(String),
36 System(String),
38}
39
40pub 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
68pub struct ChatState {
70 pub messages: Vec<ChatMessage>,
72 pub input: String,
74 pub streaming: bool,
76 pub system_prompt_sent: bool,
78 agent_text_buffer: String,
80}
81
82impl ChatState {
83 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 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 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 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 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 self.flush_agent_message();
148 }
149 }
150 }
151
152 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 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 pub fn streaming_text(&self) -> &str {
177 &self.agent_text_buffer
178 }
179
180 pub fn add_user_message(&mut self, text: String) {
184 self.flush_agent_message();
185 self.messages.push(ChatMessage::User(text));
186 }
187
188 pub fn add_system_message(&mut self, text: String) {
190 self.messages.push(ChatMessage::System(text));
191 }
192
193 pub fn add_command_suggestion(&mut self, command: String) {
195 self.messages.push(ChatMessage::CommandSuggestion(command));
196 }
197
198 pub fn add_auto_approved(&mut self, description: String) {
200 self.messages.push(ChatMessage::AutoApproved(description));
201 }
202}
203
204fn 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 in_block = false;
220 is_shell_block = false;
221 } else {
222 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#[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 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 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 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}