1use std::sync::Arc;
2
3use tokio::sync::{Mutex, mpsc};
4
5use crate::agent::{Agent, InterruptedToolCall};
6use crate::tui::app::{self, App, ChatMessage};
7use crate::tui::input::InputAction;
8use crate::tui::tools::StreamSegment;
9use crate::tui::widgets::{AgentEntry, SessionEntry, time_ago};
10
11pub async fn dispatch_acp_action(
12 app: &mut App,
13 acp: &Arc<Mutex<crate::acp::AcpClient>>,
14 action: InputAction,
15 agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
16 agent_task: &mut Option<tokio::task::JoinHandle<()>>,
17) -> LoopSignal {
18 match action {
19 InputAction::Quit => return LoopSignal::Quit,
20 InputAction::CancelStream => {
21 if let Some(handle) = agent_task.take() {
22 handle.abort();
23 }
24 *agent_rx = None;
25 let acp_clone = Arc::clone(acp);
26 tokio::spawn(async move {
27 let mut c = acp_clone.lock().await;
28 let _ = c.cancel().await;
29 });
30 app.is_streaming = false;
31 app.streaming_started = None;
32 if !app.current_response.is_empty()
33 || !app.current_tool_calls.is_empty()
34 || !app.streaming_segments.is_empty()
35 {
36 if !app.current_response.is_empty() {
37 app.streaming_segments
38 .push(StreamSegment::Text(std::mem::take(
39 &mut app.current_response,
40 )));
41 }
42 let content: String = app
43 .streaming_segments
44 .iter()
45 .filter_map(|s| {
46 if let StreamSegment::Text(t) = s {
47 Some(t.as_str())
48 } else {
49 None
50 }
51 })
52 .collect();
53 let thinking = if app.current_thinking.is_empty() {
54 None
55 } else {
56 Some(std::mem::take(&mut app.current_thinking))
57 };
58 app.messages.push(ChatMessage {
59 role: "assistant".to_string(),
60 content,
61 tool_calls: std::mem::take(&mut app.current_tool_calls),
62 thinking,
63 model: Some(app.model_name.clone()),
64 segments: Some(std::mem::take(&mut app.streaming_segments)),
65 chips: None,
66 });
67 } else {
68 app.current_response.clear();
69 app.current_thinking.clear();
70 app.current_tool_calls.clear();
71 app.streaming_segments.clear();
72 }
73 app.pending_tool_name = None;
74 app.pending_question = None;
75 app.pending_permission = None;
76 app.status_message = Some(app::StatusMessage::info("cancelled"));
77 return LoopSignal::CancelStream;
78 }
79 InputAction::SendMessage(msg) => {
80 let (tx, rx) = mpsc::unbounded_channel();
81 *agent_rx = Some(rx);
82 let acp_clone = Arc::clone(acp);
83 *agent_task = Some(tokio::spawn(async move {
84 let mut client = acp_clone.lock().await;
85 if let Err(e) = client.send_prompt(&msg).await {
86 let _ = tx.send(crate::agent::AgentEvent::Error(format!("{e}")));
87 return;
88 }
89 drop(client);
90 loop {
91 let mut client = acp_clone.lock().await;
92 match client.read_next().await {
93 Ok(acp_msg) => {
94 drop(client);
95 match acp_msg {
96 crate::acp::AcpMessage::Notification(n) => {
97 use crate::acp::types::SessionUpdate;
98 match n.update {
99 SessionUpdate::AgentMessageChunk {
100 content: crate::acp::ContentBlock::Text { text },
101 } => {
102 let _ =
103 tx.send(crate::agent::AgentEvent::TextDelta(text));
104 }
105 SessionUpdate::ThoughtChunk {
106 content: crate::acp::ContentBlock::Text { text },
107 } => {
108 let _ = tx.send(
109 crate::agent::AgentEvent::ThinkingDelta(text),
110 );
111 }
112 SessionUpdate::ToolCall {
113 tool_call_id,
114 title,
115 status,
116 content,
117 raw_input,
118 ..
119 } => {
120 let _ =
121 tx.send(crate::agent::AgentEvent::ToolCallStart {
122 id: tool_call_id.clone(),
123 name: title.clone(),
124 });
125 if status == crate::acp::ToolCallStatus::InProgress {
126 let input = raw_input
127 .as_ref()
128 .map(|v| {
129 serde_json::to_string_pretty(v)
130 .unwrap_or_default()
131 })
132 .unwrap_or_default();
133 let _ = tx.send(
134 crate::agent::AgentEvent::ToolCallExecuting {
135 id: tool_call_id.clone(),
136 name: title.clone(),
137 input,
138 },
139 );
140 }
141 if status == crate::acp::ToolCallStatus::Completed
142 || status == crate::acp::ToolCallStatus::Failed
143 {
144 let output = content.as_ref().map(|c| {
145 c.iter().filter_map(|tc| {
146 if let crate::acp::ToolCallContent::Content {
147 content: crate::acp::ContentBlock::Text { text },
148 } = tc
149 {
150 return Some(text.clone());
151 }
152 None
153 }).collect::<Vec<_>>().join("\n")
154 }).unwrap_or_default();
155 let _ = tx.send(
156 crate::agent::AgentEvent::ToolCallResult {
157 id: tool_call_id,
158 name: title,
159 output,
160 is_error: status
161 == crate::acp::ToolCallStatus::Failed,
162 },
163 );
164 }
165 }
166 SessionUpdate::ToolCallUpdate {
167 tool_call_id,
168 title,
169 status: Some(s),
170 content,
171 ..
172 } if s == crate::acp::ToolCallStatus::Completed
173 || s == crate::acp::ToolCallStatus::Failed =>
174 {
175 let output = content.as_ref().map(|c| {
176 c.iter().filter_map(|tc| {
177 if let crate::acp::ToolCallContent::Content {
178 content: crate::acp::ContentBlock::Text { text },
179 } = tc
180 {
181 return Some(text.clone());
182 }
183 None
184 }).collect::<Vec<_>>().join("\n")
185 }).unwrap_or_default();
186 let _ =
187 tx.send(crate::agent::AgentEvent::ToolCallResult {
188 id: tool_call_id,
189 name: title.unwrap_or_default(),
190 output,
191 is_error: s
192 == crate::acp::ToolCallStatus::Failed,
193 });
194 }
195 SessionUpdate::ToolCallUpdate { .. } => {}
196 SessionUpdate::Plan { entries } => {
197 let todos: Vec<crate::agent::TodoItem> = entries
198 .iter()
199 .map(|e| crate::agent::TodoItem {
200 content: e.content.clone(),
201 status: match e.status {
202 crate::acp::PlanEntryStatus::Pending => {
203 crate::agent::TodoStatus::Pending
204 }
205 crate::acp::PlanEntryStatus::InProgress => {
206 crate::agent::TodoStatus::InProgress
207 }
208 crate::acp::PlanEntryStatus::Completed => {
209 crate::agent::TodoStatus::Completed
210 }
211 },
212 })
213 .collect();
214 let _ = tx
215 .send(crate::agent::AgentEvent::TodoUpdate(todos));
216 }
217 SessionUpdate::CurrentModeUpdate { mode_id } => {
218 let mut c = acp_clone.lock().await;
219 c.set_current_mode(&mode_id);
220 }
221 SessionUpdate::ConfigOptionsUpdate { config_options } => {
222 let mut c = acp_clone.lock().await;
223 c.set_config_options(config_options);
224 }
225 _ => {}
226 }
227 }
228 crate::acp::AcpMessage::PromptComplete(_) => {
229 let _ = tx.send(crate::agent::AgentEvent::TextComplete(
230 String::new(),
231 ));
232 let _ = tx.send(crate::agent::AgentEvent::Done {
233 usage: crate::provider::Usage::default(),
234 });
235 break;
236 }
237 crate::acp::AcpMessage::IncomingRequest { id, method, params } => {
238 let mut client = acp_clone.lock().await;
239 if handle_acp_extension_method(&tx, &method, ¶ms) {
240 let _ = client.respond(id, serde_json::json!({})).await;
241 } else {
242 handle_acp_incoming_request(
243 &mut client,
244 id,
245 &method,
246 params,
247 )
248 .await;
249 }
250 }
251 crate::acp::AcpMessage::Response { .. } => {}
252 }
253 }
254 Err(e) => {
255 let _ = tx.send(crate::agent::AgentEvent::Error(format!("{e}")));
256 break;
257 }
258 }
259 }
260 }));
261 }
262 InputAction::OpenExternalEditor => return LoopSignal::OpenEditor,
263 InputAction::ScrollUp(n) => app.scroll_up(n),
264 InputAction::ScrollDown(n) => app.scroll_down(n),
265 InputAction::ScrollToTop => app.scroll_to_top(),
266 InputAction::ScrollToBottom => app.scroll_to_bottom(),
267 InputAction::ClearConversation => app.clear_conversation(),
268 InputAction::ToggleThinking => {
269 app.thinking_expanded = !app.thinking_expanded;
270 app.thinking_collapse_at = None;
271 app.auto_opened_thinking = false;
272 app.mark_dirty();
273 }
274 InputAction::CopyMessage(idx) => {
275 if idx < app.messages.len() {
276 app::copy_to_clipboard(&app.messages[idx].content);
277 app.status_message = Some(app::StatusMessage::info("copied to clipboard"));
278 }
279 }
280 InputAction::OpenRenamePopup => {
281 app.rename_input = app.conversation_title.clone().unwrap_or_default();
282 app.rename_visible = true;
283 }
284 InputAction::OpenAgentSelector => {
285 let acp_lock = acp.lock().await;
286 let modes = acp_lock.available_modes();
287 let current = acp_lock.current_mode().unwrap_or("").to_string();
288 let entries: Vec<AgentEntry> = modes
289 .iter()
290 .map(|m| AgentEntry {
291 name: m.id.clone(),
292 description: m.description.clone().unwrap_or_else(|| m.name.clone()),
293 })
294 .collect();
295 drop(acp_lock);
296 if entries.is_empty() {
297 app.status_message = Some(app::StatusMessage::info("no modes available"));
298 } else {
299 app.agent_selector.open(entries, ¤t);
300 }
301 }
302 InputAction::SelectAgent { name } => {
303 let acp_clone = Arc::clone(acp);
304 let mode_id = name.clone();
305 tokio::spawn(async move {
306 let mut c = acp_clone.lock().await;
307 let _ = c.set_mode(&mode_id).await;
308 });
309 app.model_name = name.clone();
310 app.mark_dirty();
311 }
312 InputAction::ToggleAgent => {
313 let mut acp_lock = acp.lock().await;
314 let modes = acp_lock.available_modes().to_vec();
315 let current = acp_lock.current_mode().unwrap_or("").to_string();
316 if !modes.is_empty() {
317 let idx = modes.iter().position(|m| m.id == current).unwrap_or(0);
318 let next = &modes[(idx + 1) % modes.len()];
319 let next_id = next.id.clone();
320 let _ = acp_lock.set_mode(&next_id).await;
321 acp_lock.set_current_mode(&next_id);
322 drop(acp_lock);
323 app.model_name = next_id;
324 app.mark_dirty();
325 }
326 }
327 InputAction::NewConversation
328 | InputAction::OpenModelSelector
329 | InputAction::OpenSessionSelector
330 | InputAction::ResumeSession { .. }
331 | InputAction::SelectModel { .. }
332 | InputAction::OpenThinkingSelector
333 | InputAction::SetThinkingLevel(_)
334 | InputAction::CycleThinkingLevel
335 | InputAction::TruncateToMessage(_)
336 | InputAction::RevertToMessage(_)
337 | InputAction::ForkFromMessage(_)
338 | InputAction::AnswerQuestion(_)
339 | InputAction::LoadSkill { .. }
340 | InputAction::RunCustomCommand { .. }
341 | InputAction::ExportSession(_)
342 | InputAction::RenameSession(_)
343 | InputAction::AnswerPermission(_)
344 | InputAction::OpenLoginPopup
345 | InputAction::LoginSubmitApiKey { .. }
346 | InputAction::LoginOAuth { .. }
347 | InputAction::AskAside { .. }
348 | InputAction::None => {
349 app.status_message = Some(app::StatusMessage::info("not available in ACP mode"));
350 }
351 }
352 LoopSignal::Continue
353}
354
355fn handle_acp_extension_method(
356 tx: &mpsc::UnboundedSender<crate::agent::AgentEvent>,
357 method: &str,
358 params: &serde_json::Value,
359) -> bool {
360 match method {
361 "cursor/update_todos" => {
362 if let Some(items) = params["todos"].as_array() {
363 let todos: Vec<crate::agent::TodoItem> = items
364 .iter()
365 .filter_map(|t| {
366 Some(crate::agent::TodoItem {
367 content: t["content"].as_str()?.to_string(),
368 status: match t["status"].as_str().unwrap_or("pending") {
369 "in_progress" => crate::agent::TodoStatus::InProgress,
370 "completed" => crate::agent::TodoStatus::Completed,
371 _ => crate::agent::TodoStatus::Pending,
372 },
373 })
374 })
375 .collect();
376 let _ = tx.send(crate::agent::AgentEvent::TodoUpdate(todos));
377 }
378 true
379 }
380 "cursor/ask_question" => {
381 let question = params["question"].as_str().unwrap_or("").to_string();
382 let options: Vec<String> = params["options"]
383 .as_array()
384 .map(|a| {
385 a.iter()
386 .filter_map(|v| v.as_str().map(String::from))
387 .collect()
388 })
389 .unwrap_or_default();
390 let (resp_tx, _) = tokio::sync::oneshot::channel();
391 let _ = tx.send(crate::agent::AgentEvent::Question {
392 id: uuid::Uuid::new_v4().to_string(),
393 question,
394 options,
395 responder: crate::agent::QuestionResponder(resp_tx),
396 });
397 true
398 }
399 "cursor/create_plan" | "cursor/task" | "cursor/generate_image" => true,
400 _ => false,
401 }
402}
403
404async fn handle_acp_incoming_request(
405 client: &mut crate::acp::AcpClient,
406 id: u64,
407 method: &str,
408 params: serde_json::Value,
409) {
410 match method {
411 "fs/read_text_file" => {
412 let path = params["path"].as_str().unwrap_or("");
413 match std::fs::read_to_string(path) {
414 Ok(content) => {
415 let _ = client
416 .respond(id, serde_json::json!({"content": content}))
417 .await;
418 }
419 Err(e) => {
420 let _ = client.respond_error(id, -32603, &e.to_string()).await;
421 }
422 }
423 }
424 "fs/write_text_file" => {
425 let path = params["path"].as_str().unwrap_or("");
426 let content = params["content"].as_str().unwrap_or("");
427 match std::fs::write(path, content) {
428 Ok(()) => {
429 let _ = client.respond(id, serde_json::json!({})).await;
430 }
431 Err(e) => {
432 let _ = client.respond_error(id, -32603, &e.to_string()).await;
433 }
434 }
435 }
436 "terminal/create" => {
437 let command = params["command"].as_str().unwrap_or("sh");
438 let args: Vec<String> = params["args"]
439 .as_array()
440 .map(|a| {
441 a.iter()
442 .filter_map(|v| v.as_str().map(String::from))
443 .collect()
444 })
445 .unwrap_or_default();
446 let cwd = params["cwd"].as_str();
447 let mut cmd = tokio::process::Command::new(command);
448 cmd.args(&args);
449 if let Some(d) = cwd {
450 cmd.current_dir(d);
451 }
452 cmd.stdout(std::process::Stdio::piped());
453 cmd.stderr(std::process::Stdio::piped());
454 match cmd.spawn() {
455 Ok(_child) => {
456 let tid = uuid::Uuid::new_v4().to_string();
457 let _ = client
458 .respond(id, serde_json::json!({"terminalId": tid}))
459 .await;
460 }
461 Err(e) => {
462 let _ = client.respond_error(id, -32603, &e.to_string()).await;
463 }
464 }
465 }
466 "session/request_permission" => {
467 let options = params["options"].as_array();
468 let allow_id = options
469 .and_then(|opts| {
470 opts.iter().find(|o| {
471 o["kind"].as_str() == Some("allow_once")
472 || o["kind"].as_str() == Some("allow-once")
473 })
474 })
475 .and_then(|o| o["optionId"].as_str())
476 .unwrap_or("allow-once");
477 let _ = client
478 .respond(
479 id,
480 serde_json::json!({
481 "outcome": { "outcome": "selected", "optionId": allow_id }
482 }),
483 )
484 .await;
485 }
486 _ => {
487 let _ = client
488 .respond_error(id, -32601, &format!("unsupported: {}", method))
489 .await;
490 }
491 }
492}
493
494pub enum LoopSignal {
495 Continue,
496 Quit,
497 CancelStream,
498 OpenEditor,
499}
500
501pub async fn dispatch_action(
502 app: &mut App,
503 agent: &Arc<Mutex<Agent>>,
504 action: InputAction,
505 agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
506 agent_task: &mut Option<tokio::task::JoinHandle<()>>,
507) -> LoopSignal {
508 match action {
509 InputAction::Quit => return LoopSignal::Quit,
510 InputAction::CancelStream => {
511 if let Some(handle) = agent_task.take() {
512 handle.abort();
513 }
514 *agent_rx = None;
515 app.is_streaming = false;
516 app.streaming_started = None;
517 if !app.current_response.is_empty()
518 || !app.current_tool_calls.is_empty()
519 || !app.streaming_segments.is_empty()
520 {
521 if !app.current_response.is_empty() {
522 app.streaming_segments
523 .push(StreamSegment::Text(std::mem::take(
524 &mut app.current_response,
525 )));
526 }
527 let content: String = app
528 .streaming_segments
529 .iter()
530 .filter_map(|s| {
531 if let StreamSegment::Text(t) = s {
532 Some(t.as_str())
533 } else {
534 None
535 }
536 })
537 .collect();
538 let thinking = if app.current_thinking.is_empty() {
539 None
540 } else {
541 Some(std::mem::take(&mut app.current_thinking))
542 };
543 let tool_calls = std::mem::take(&mut app.current_tool_calls);
544 let segments = std::mem::take(&mut app.streaming_segments);
545 app.messages.push(ChatMessage {
546 role: "assistant".to_string(),
547 content: content.clone(),
548 tool_calls: tool_calls.clone(),
549 thinking: thinking.clone(),
550 model: Some(app.model_name.clone()),
551 segments: Some(segments),
552 chips: None,
553 });
554 let interrupted_tools: Vec<InterruptedToolCall> = tool_calls
555 .into_iter()
556 .map(|tc| InterruptedToolCall {
557 name: tc.name,
558 input: tc.input,
559 output: tc.output,
560 is_error: tc.is_error,
561 })
562 .collect();
563 if let Err(e) =
564 agent
565 .lock()
566 .await
567 .add_interrupted_message(content, interrupted_tools, thinking)
568 {
569 tracing::warn!("Failed to persist interrupted message: {}", e);
570 }
571 } else {
572 app.current_response.clear();
573 app.current_thinking.clear();
574 app.current_tool_calls.clear();
575 app.streaming_segments.clear();
576 }
577 app.pending_tool_name = None;
578 app.pending_question = None;
579 app.pending_permission = None;
580 app.status_message = Some(app::StatusMessage::info("cancelled"));
581 return LoopSignal::CancelStream;
582 }
583 InputAction::SendMessage(msg) => {
584 let images: Vec<(String, String)> = app
585 .take_attachments()
586 .into_iter()
587 .map(|a| (a.media_type, a.data))
588 .collect();
589
590 let (tx, rx) = mpsc::unbounded_channel();
591 *agent_rx = Some(rx);
592
593 let agent_clone = Arc::clone(agent);
594 let err_tx = tx.clone();
595 *agent_task = Some(tokio::spawn(async move {
596 let mut agent = agent_clone.lock().await;
597 let result = if images.is_empty() {
598 agent.send_message(&msg, tx).await
599 } else {
600 agent.send_message_with_images(&msg, images, tx).await
601 };
602 if let Err(e) = result {
603 tracing::error!("Agent send_message error: {}", e);
604 let _ = err_tx.send(crate::agent::AgentEvent::Error(format!("{e}")));
605 }
606 }));
607 }
608 InputAction::NewConversation => {
609 let mut agent_lock = agent.lock().await;
610 match agent_lock.new_conversation() {
611 Ok(()) => app.clear_conversation(),
612 Err(e) => {
613 app.status_message = Some(app::StatusMessage::error(format!(
614 "failed to start new conversation: {e}"
615 )))
616 }
617 }
618 }
619 InputAction::OpenModelSelector => {
620 let agent_lock = agent.lock().await;
621 let current_provider = agent_lock.current_provider_name().to_string();
622 let current_model = agent_lock.current_model().to_string();
623
624 let grouped = if let Some(ref cached) = app.cached_model_groups {
625 cached.clone()
626 } else {
627 let cached = agent_lock.cached_all_models();
628 let has_all = cached.iter().all(|(_, models)| !models.is_empty());
629 if has_all {
630 app.cached_model_groups = Some(cached.clone());
631 cached
632 } else {
633 let (tx, rx) = tokio::sync::oneshot::channel();
634 let agent_clone = Arc::clone(agent);
635 tokio::spawn(async move {
636 let mut lock = agent_clone.lock().await;
637 let result = lock.fetch_all_models().await;
638 let provider = lock.current_provider_name().to_string();
639 let model = lock.current_model().to_string();
640 let _ = tx.send((result, provider, model));
641 });
642 app.model_fetch_rx = Some(rx);
643 cached
644 }
645 };
646 drop(agent_lock);
647
648 app.model_selector.favorites = app.favorite_models.clone();
649 app.model_selector
650 .open(grouped, ¤t_provider, ¤t_model);
651 }
652 InputAction::OpenAgentSelector => {
653 let agent_lock = agent.lock().await;
654 let entries: Vec<AgentEntry> = agent_lock
655 .agent_profiles()
656 .iter()
657 .map(|p| AgentEntry {
658 name: p.name.clone(),
659 description: p.description.clone(),
660 })
661 .collect();
662 let current = agent_lock.current_agent_name().to_string();
663 drop(agent_lock);
664 app.agent_selector.open(entries, ¤t);
665 }
666 InputAction::OpenSessionSelector => {
667 let agent_lock = agent.lock().await;
668 let current_id = agent_lock.conversation_id().to_string();
669 let sessions = agent_lock.list_sessions().unwrap_or_default();
670 drop(agent_lock);
671 let entries: Vec<SessionEntry> = sessions
672 .into_iter()
673 .map(|s| {
674 let title = if let Some(t) = &s.title {
675 t.clone()
676 } else if s.id == current_id {
677 app.conversation_title
678 .clone()
679 .unwrap_or_else(|| "new conversation".to_string())
680 } else {
681 "untitled".to_string()
682 };
683 SessionEntry {
684 id: s.id.clone(),
685 title,
686 subtitle: format!("{} ยท {}", time_ago(&s.updated_at), s.provider),
687 }
688 })
689 .collect();
690 app.session_selector.open(entries);
691 }
692 InputAction::ResumeSession { id } => {
693 let mut agent_lock = agent.lock().await;
694 match agent_lock.get_session(&id) {
695 Ok(conv) => {
696 let title = conv.title.clone();
697 let conv_model = conv.model.clone();
698 let messages_for_ui: Vec<_> = conv
699 .messages
700 .iter()
701 .map(|m| {
702 let db_tcs = agent_lock.get_tool_calls(&m.id).unwrap_or_default();
703 (m.role.clone(), m.content.clone(), db_tcs)
704 })
705 .collect();
706 match agent_lock.resume_conversation(&conv) {
707 Ok(()) => {
708 drop(agent_lock);
709 app.clear_conversation();
710 app.conversation_title = title;
711 for (role, content, db_tcs) in messages_for_ui {
712 let model = if role == "assistant" {
713 Some(conv_model.clone())
714 } else {
715 None
716 };
717 let tool_calls: Vec<crate::tui::tools::ToolCallDisplay> = db_tcs
718 .into_iter()
719 .map(|tc| {
720 let category =
721 crate::tui::tools::ToolCategory::from_name(&tc.name);
722 let detail = crate::tui::tools::extract_tool_detail(
723 &tc.name, &tc.input,
724 );
725 crate::tui::tools::ToolCallDisplay {
726 name: tc.name,
727 input: tc.input,
728 output: tc.output,
729 is_error: tc.is_error,
730 category,
731 detail,
732 }
733 })
734 .collect();
735 let has_tools = !tool_calls.is_empty();
736 let clean_content = if has_tools {
737 content.replace("[tool use]", "").trim().to_string()
738 } else {
739 content
740 };
741 let segments = if has_tools {
742 let mut segs = Vec::new();
743 if !clean_content.is_empty() {
744 segs.push(crate::tui::tools::StreamSegment::Text(
745 clean_content.clone(),
746 ));
747 }
748 for tc in &tool_calls {
749 segs.push(crate::tui::tools::StreamSegment::ToolCall(
750 tc.clone(),
751 ));
752 }
753 Some(segs)
754 } else {
755 None
756 };
757 app.messages.push(ChatMessage {
758 role,
759 content: clean_content,
760 tool_calls,
761 thinking: None,
762 model,
763 segments,
764 chips: None,
765 });
766 }
767 app.scroll_to_bottom();
768 }
769 Err(e) => {
770 drop(agent_lock);
771 app.status_message = Some(app::StatusMessage::error(format!(
772 "failed to resume session: {e}"
773 )));
774 }
775 }
776 }
777 Err(e) => {
778 drop(agent_lock);
779 app.status_message =
780 Some(app::StatusMessage::error(format!("session not found: {e}")));
781 }
782 }
783 }
784 InputAction::SelectModel { provider, model } => {
785 let mut agent_lock = agent.lock().await;
786 agent_lock.set_active_provider(&provider, &model);
787 let cw = agent_lock.context_window();
788 if cw > 0 {
789 app.context_window = cw;
790 } else {
791 app.context_window = agent_lock.fetch_context_window().await;
792 }
793 }
794 InputAction::SelectAgent { name } => {
795 let mut agent_lock = agent.lock().await;
796 agent_lock.switch_agent(&name);
797 app.model_name = agent_lock.current_model().to_string();
798 app.provider_name = agent_lock.current_provider_name().to_string();
799 let cw = agent_lock.context_window();
800 if cw > 0 {
801 app.context_window = cw;
802 } else {
803 app.context_window = agent_lock.fetch_context_window().await;
804 }
805 }
806 InputAction::ScrollUp(n) => app.scroll_up(n),
807 InputAction::ScrollDown(n) => app.scroll_down(n),
808 InputAction::ScrollToTop => app.scroll_to_top(),
809 InputAction::ScrollToBottom => app.scroll_to_bottom(),
810 InputAction::ClearConversation => app.clear_conversation(),
811 InputAction::ToggleThinking => {
812 app.thinking_expanded = !app.thinking_expanded;
813 app.thinking_collapse_at = None;
814 app.auto_opened_thinking = false;
815 app.mark_dirty();
816 }
817 InputAction::OpenThinkingSelector => {
818 let level = app.thinking_level();
819 app.thinking_selector.open(level);
820 }
821 InputAction::SetThinkingLevel(budget) => {
822 let mut agent_lock = agent.lock().await;
823 agent_lock.set_thinking_budget(budget);
824 }
825 InputAction::CycleThinkingLevel => {
826 let next = app.thinking_level().next();
827 let budget = next.budget_tokens();
828 app.thinking_budget = budget;
829 let mut agent_lock = agent.lock().await;
830 agent_lock.set_thinking_budget(budget);
831 }
832 InputAction::TruncateToMessage(idx) => {
833 app.messages.truncate(idx + 1);
834 app.current_response.clear();
835 app.current_thinking.clear();
836 app.current_tool_calls.clear();
837 app.streaming_segments.clear();
838 app.scroll_to_bottom();
839 let mut agent_lock = agent.lock().await;
840 agent_lock.truncate_messages(idx + 1);
841 }
842 InputAction::RevertToMessage(idx) => {
843 let prompt = if idx < app.messages.len() && app.messages[idx].role == "user" {
844 app.messages[idx].content.clone()
845 } else if idx > 0 && app.messages[idx - 1].role == "user" {
846 app.messages[idx - 1].content.clone()
847 } else {
848 String::new()
849 };
850 app.current_response.clear();
851 app.current_thinking.clear();
852 app.current_tool_calls.clear();
853 app.streaming_segments.clear();
854 let mut agent_lock = agent.lock().await;
855 match agent_lock.revert_to_message(idx) {
856 Ok(restored) => {
857 drop(agent_lock);
858 app.messages.truncate(idx);
859 app.input = prompt;
860 app.cursor_pos = app.input.len();
861 app.chips.clear();
862 app.mark_dirty();
863 app.scroll_to_bottom();
864 let count = restored.len();
865 if count > 0 {
866 app.status_message = Some(app::StatusMessage::info(format!(
867 "reverted {count} file{}",
868 if count == 1 { "" } else { "s" }
869 )));
870 }
871 }
872 Err(e) => {
873 drop(agent_lock);
874 app.status_message =
875 Some(app::StatusMessage::error(format!("revert failed: {e}")));
876 }
877 }
878 }
879 InputAction::CopyMessage(idx) => {
880 if idx < app.messages.len() {
881 app::copy_to_clipboard(&app.messages[idx].content);
882 app.status_message = Some(app::StatusMessage::info("copied to clipboard"));
883 }
884 }
885 InputAction::ForkFromMessage(idx) => {
886 let fork_messages: Vec<(String, String, Option<String>)> = app.messages[..=idx]
887 .iter()
888 .map(|m| (m.role.clone(), m.content.clone(), m.model.clone()))
889 .collect();
890 let prompt = fork_messages
891 .iter()
892 .rev()
893 .find(|(role, _, _)| role == "user")
894 .map(|(_, content, _)| content.clone())
895 .unwrap_or_default();
896 let mut agent_lock = agent.lock().await;
897 match agent_lock.fork_conversation(idx + 1) {
898 Ok(()) => {
899 drop(agent_lock);
900 app.clear_conversation();
901 for (role, content, model) in fork_messages {
902 app.messages.push(ChatMessage {
903 role,
904 content,
905 tool_calls: Vec::new(),
906 thinking: None,
907 model,
908 segments: None,
909 chips: None,
910 });
911 }
912 app.input = prompt;
913 app.cursor_pos = app.input.len();
914 app.chips.clear();
915 app.scroll_to_bottom();
916 }
917 Err(e) => {
918 drop(agent_lock);
919 app.status_message =
920 Some(app::StatusMessage::error(format!("fork failed: {e}")));
921 }
922 }
923 }
924 InputAction::AnswerQuestion(answer) => {
925 app.messages.push(ChatMessage {
926 role: "user".to_string(),
927 content: answer,
928 tool_calls: Vec::new(),
929 thinking: None,
930 model: None,
931 segments: None,
932 chips: None,
933 });
934 app.scroll_to_bottom();
935 }
936 InputAction::LoadSkill { name } => {
937 let display = format!("/{}", name);
938 app.messages.push(ChatMessage {
939 role: "user".to_string(),
940 content: display,
941 tool_calls: Vec::new(),
942 thinking: None,
943 model: None,
944 segments: None,
945 chips: None,
946 });
947 app.scroll_to_bottom();
948 let msg = format!("Load and use the {} skill", name);
949 let (tx, rx) = mpsc::unbounded_channel();
950 *agent_rx = Some(rx);
951 let agent_clone = Arc::clone(agent);
952 *agent_task = Some(tokio::spawn(async move {
953 let mut agent = agent_clone.lock().await;
954 if let Err(e) = agent.send_message(&msg, tx).await {
955 tracing::error!("Agent send_message error: {}", e);
956 }
957 }));
958 }
959 InputAction::RunCustomCommand { name, args } => {
960 let display = format!("/{} {}", name, args).trim_end().to_string();
961 app.messages.push(ChatMessage {
962 role: "user".to_string(),
963 content: display,
964 tool_calls: Vec::new(),
965 thinking: None,
966 model: None,
967 segments: None,
968 chips: None,
969 });
970 let agent_lock = agent.lock().await;
971 match agent_lock.execute_command(&name, &args) {
972 Ok(output) => {
973 app.messages.push(ChatMessage {
974 role: "assistant".to_string(),
975 content: output,
976 tool_calls: Vec::new(),
977 thinking: None,
978 model: None,
979 segments: None,
980 chips: None,
981 });
982 }
983 Err(e) => {
984 app.status_message =
985 Some(app::StatusMessage::error(format!("command error: {e}")));
986 }
987 }
988 drop(agent_lock);
989 app.scroll_to_bottom();
990 }
991 InputAction::ToggleAgent => {
992 let mut agent_lock = agent.lock().await;
993 let current = agent_lock.current_agent_name().to_string();
994 let names: Vec<String> = agent_lock
995 .agent_profiles()
996 .iter()
997 .map(|p| p.name.clone())
998 .collect();
999 let idx = names.iter().position(|n| n == ¤t).unwrap_or(0);
1000 let next = names[(idx + 1) % names.len()].clone();
1001 agent_lock.switch_agent(&next);
1002 app.agent_name = agent_lock.current_agent_name().to_string();
1003 app.model_name = agent_lock.current_model().to_string();
1004 app.provider_name = agent_lock.current_provider_name().to_string();
1005 }
1006 InputAction::ExportSession(path_opt) => {
1007 let agent_lock = agent.lock().await;
1008 let cwd = agent_lock.cwd().to_string();
1009 drop(agent_lock);
1010 let title = app
1011 .conversation_title
1012 .as_deref()
1013 .unwrap_or("session")
1014 .to_string();
1015 let path = match path_opt {
1016 Some(p) => p,
1017 None => {
1018 let slug: String = title
1019 .chars()
1020 .map(|c| {
1021 if c.is_alphanumeric() {
1022 c.to_ascii_lowercase()
1023 } else {
1024 '-'
1025 }
1026 })
1027 .collect();
1028 format!("{}/session-{}.md", cwd, slug)
1029 }
1030 };
1031 let mut md = format!("# Session: {}\n\n", title);
1032 for msg in &app.messages {
1033 match msg.role.as_str() {
1034 "user" => {
1035 md.push_str("---\n\n## User\n\n");
1036 md.push_str(&msg.content);
1037 md.push_str("\n\n");
1038 }
1039 "assistant" => {
1040 md.push_str("---\n\n## Assistant\n\n");
1041 md.push_str(&msg.content);
1042 md.push_str("\n\n");
1043 for tc in &msg.tool_calls {
1044 let status = if tc.is_error { "error" } else { "done" };
1045 md.push_str(&format!("- `{}` ({})\n", tc.name, status));
1046 }
1047 }
1048 _ => {}
1049 }
1050 }
1051 match std::fs::write(&path, &md) {
1052 Ok(()) => {
1053 app.status_message =
1054 Some(app::StatusMessage::success(format!("exported to {}", path)))
1055 }
1056 Err(e) => {
1057 app.status_message =
1058 Some(app::StatusMessage::error(format!("export failed: {e}")))
1059 }
1060 }
1061 }
1062 InputAction::OpenExternalEditor => return LoopSignal::OpenEditor,
1063 InputAction::OpenLoginPopup => {
1064 app.login_popup.open();
1065 }
1066 InputAction::LoginSubmitApiKey { provider, key } => {
1067 let cred = crate::auth::ProviderCredential::ApiKey { key };
1068 match crate::auth::Credentials::load() {
1069 Ok(mut creds) => {
1070 creds.set(&provider, cred);
1071 if let Err(e) = creds.save() {
1072 app.status_message =
1073 Some(app::StatusMessage::error(format!("save failed: {e}")));
1074 } else {
1075 app.status_message = Some(app::StatusMessage::success(format!(
1076 "{} credentials saved",
1077 provider
1078 )));
1079 }
1080 }
1081 Err(e) => {
1082 app.status_message =
1083 Some(app::StatusMessage::error(format!("load creds: {e}")));
1084 }
1085 }
1086 }
1087 InputAction::LoginOAuth {
1088 provider,
1089 create_key,
1090 code,
1091 verifier,
1092 } => {
1093 app.status_message = Some(app::StatusMessage::info("exchanging code..."));
1094 app.login_popup.close();
1095 tokio::spawn(async move {
1096 match crate::auth::oauth::exchange_oauth_code(&code, &verifier, create_key).await {
1097 Ok(cred) => {
1098 if let Ok(mut creds) = crate::auth::Credentials::load() {
1099 creds.set(&provider, cred);
1100 let _ = creds.save();
1101 }
1102 tracing::info!("{} OAuth credentials saved", provider);
1103 }
1104 Err(e) => {
1105 tracing::warn!("OAuth exchange failed: {}", e);
1106 }
1107 }
1108 });
1109 }
1110 InputAction::AskAside { question } => {
1111 let agent_lock = agent.lock().await;
1112 let provider = agent_lock.aside_provider();
1113 let messages = agent_lock.messages().to_vec();
1114 let bg_tx = agent_lock.background_tx();
1115 drop(agent_lock);
1116 if let Some(tx) = bg_tx {
1117 app.aside_popup.open(question.clone());
1118 let mut aside_messages = messages;
1119 aside_messages.push(crate::provider::Message {
1120 role: crate::provider::Role::User,
1121 content: vec![crate::provider::ContentBlock::Text(question)],
1122 });
1123 tokio::spawn(async move {
1124 let system = "You are answering a quick side question. Be concise and helpful. \
1125 You have full visibility into the conversation so far. \
1126 You have no tools available.";
1127 match provider
1128 .stream(&aside_messages, Some(system), &[], 2048, 0)
1129 .await
1130 {
1131 Ok(mut rx) => {
1132 while let Some(event) = rx.recv().await {
1133 match event.event_type {
1134 crate::provider::StreamEventType::TextDelta(text) => {
1135 let _ = tx.send(crate::agent::AgentEvent::AsideDelta(text));
1136 }
1137 crate::provider::StreamEventType::MessageEnd { .. } => {
1138 let _ = tx.send(crate::agent::AgentEvent::AsideDone);
1139 break;
1140 }
1141 _ => {}
1142 }
1143 }
1144 }
1145 Err(e) => {
1146 let _ = tx.send(crate::agent::AgentEvent::AsideError(format!("{e}")));
1147 }
1148 }
1149 });
1150 } else {
1151 app.status_message = Some(app::StatusMessage::error("aside not available"));
1152 }
1153 }
1154 InputAction::AnswerPermission(_) | InputAction::None => {}
1155 InputAction::OpenRenamePopup => {
1156 app.rename_input = app.conversation_title.clone().unwrap_or_default();
1157 app.rename_visible = true;
1158 }
1159 InputAction::RenameSession(title) => {
1160 let agent_lock = agent.lock().await;
1161 if let Err(e) = agent_lock.rename_session(&title) {
1162 app.status_message = Some(app::StatusMessage::error(format!("rename failed: {e}")));
1163 } else {
1164 app.conversation_title = Some(title);
1165 }
1166 app.rename_visible = false;
1167 }
1168 }
1169 LoopSignal::Continue
1170}