par_term/ai_inspector/chat/
state.rs1use par_term_acp::SessionUpdate;
5
6use super::text_utils::{extract_code_block_commands, truncate_replay_text};
7use super::types::ChatMessage;
8
9pub struct ChatState {
11 pub messages: Vec<ChatMessage>,
13 pub input: String,
15 input_history: Vec<String>,
17 input_history_cursor: Option<usize>,
19 input_history_draft: Option<String>,
21 pub streaming: bool,
23 agent_text_buffer: String,
25}
26
27impl ChatState {
28 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 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 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 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 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 self.flush_agent_message();
95 }
96 }
97 }
98
99 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 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 pub fn streaming_text(&self) -> &str {
124 &self.agent_text_buffer
125 }
126
127 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 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 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 pub fn add_system_message(&mut self, text: String) {
230 self.messages.push(ChatMessage::System(text));
231 }
232
233 pub fn add_command_suggestion(&mut self, command: String) {
235 self.messages.push(ChatMessage::CommandSuggestion(command));
236 }
237
238 pub fn add_auto_approved(&mut self, description: String) {
240 self.messages.push(ChatMessage::AutoApproved(description));
241 }
242
243 pub fn clear(&mut self) {
245 self.messages.clear();
246 self.agent_text_buffer.clear();
247 self.streaming = false;
248 }
249
250 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 }
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 }
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}