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.mark_dirty();
271 }
272 InputAction::CopyMessage(idx) => {
273 if idx < app.messages.len() {
274 app::copy_to_clipboard(&app.messages[idx].content);
275 app.status_message = Some(app::StatusMessage::info("copied to clipboard"));
276 }
277 }
278 InputAction::OpenRenamePopup => {
279 app.rename_input = app.conversation_title.clone().unwrap_or_default();
280 app.rename_visible = true;
281 }
282 InputAction::OpenAgentSelector => {
283 let acp_lock = acp.lock().await;
284 let modes = acp_lock.available_modes();
285 let current = acp_lock.current_mode().unwrap_or("").to_string();
286 let entries: Vec<AgentEntry> = modes
287 .iter()
288 .map(|m| AgentEntry {
289 name: m.id.clone(),
290 description: m.description.clone().unwrap_or_else(|| m.name.clone()),
291 })
292 .collect();
293 drop(acp_lock);
294 if entries.is_empty() {
295 app.status_message = Some(app::StatusMessage::info("no modes available"));
296 } else {
297 app.agent_selector.open(entries, ¤t);
298 }
299 }
300 InputAction::SelectAgent { name } => {
301 let acp_clone = Arc::clone(acp);
302 let mode_id = name.clone();
303 tokio::spawn(async move {
304 let mut c = acp_clone.lock().await;
305 let _ = c.set_mode(&mode_id).await;
306 });
307 app.model_name = name.clone();
308 app.mark_dirty();
309 }
310 InputAction::ToggleAgent => {
311 let mut acp_lock = acp.lock().await;
312 let modes = acp_lock.available_modes().to_vec();
313 let current = acp_lock.current_mode().unwrap_or("").to_string();
314 if !modes.is_empty() {
315 let idx = modes.iter().position(|m| m.id == current).unwrap_or(0);
316 let next = &modes[(idx + 1) % modes.len()];
317 let next_id = next.id.clone();
318 let _ = acp_lock.set_mode(&next_id).await;
319 acp_lock.set_current_mode(&next_id);
320 drop(acp_lock);
321 app.model_name = next_id;
322 app.mark_dirty();
323 }
324 }
325 InputAction::NewConversation
326 | InputAction::OpenModelSelector
327 | InputAction::OpenSessionSelector
328 | InputAction::ResumeSession { .. }
329 | InputAction::SelectModel { .. }
330 | InputAction::OpenThinkingSelector
331 | InputAction::SetThinkingLevel(_)
332 | InputAction::CycleThinkingLevel
333 | InputAction::TruncateToMessage(_)
334 | InputAction::RevertToMessage(_)
335 | InputAction::ForkFromMessage(_)
336 | InputAction::AnswerQuestion(_)
337 | InputAction::LoadSkill { .. }
338 | InputAction::RunCustomCommand { .. }
339 | InputAction::ExportSession(_)
340 | InputAction::RenameSession(_)
341 | InputAction::AnswerPermission(_)
342 | InputAction::OpenLoginPopup
343 | InputAction::LoginSubmitApiKey { .. }
344 | InputAction::LoginOAuth { .. }
345 | InputAction::AskAside { .. }
346 | InputAction::None => {
347 app.status_message = Some(app::StatusMessage::info("not available in ACP mode"));
348 }
349 }
350 LoopSignal::Continue
351}
352
353fn handle_acp_extension_method(
354 tx: &mpsc::UnboundedSender<crate::agent::AgentEvent>,
355 method: &str,
356 params: &serde_json::Value,
357) -> bool {
358 match method {
359 "cursor/update_todos" => {
360 if let Some(items) = params["todos"].as_array() {
361 let todos: Vec<crate::agent::TodoItem> = items
362 .iter()
363 .filter_map(|t| {
364 Some(crate::agent::TodoItem {
365 content: t["content"].as_str()?.to_string(),
366 status: match t["status"].as_str().unwrap_or("pending") {
367 "in_progress" => crate::agent::TodoStatus::InProgress,
368 "completed" => crate::agent::TodoStatus::Completed,
369 _ => crate::agent::TodoStatus::Pending,
370 },
371 })
372 })
373 .collect();
374 let _ = tx.send(crate::agent::AgentEvent::TodoUpdate(todos));
375 }
376 true
377 }
378 "cursor/ask_question" => {
379 let question = params["question"].as_str().unwrap_or("").to_string();
380 let options: Vec<String> = params["options"]
381 .as_array()
382 .map(|a| {
383 a.iter()
384 .filter_map(|v| v.as_str().map(String::from))
385 .collect()
386 })
387 .unwrap_or_default();
388 let (resp_tx, _) = tokio::sync::oneshot::channel();
389 let _ = tx.send(crate::agent::AgentEvent::Question {
390 id: uuid::Uuid::new_v4().to_string(),
391 question,
392 options,
393 responder: crate::agent::QuestionResponder(resp_tx),
394 });
395 true
396 }
397 "cursor/create_plan" | "cursor/task" | "cursor/generate_image" => true,
398 _ => false,
399 }
400}
401
402async fn handle_acp_incoming_request(
403 client: &mut crate::acp::AcpClient,
404 id: u64,
405 method: &str,
406 params: serde_json::Value,
407) {
408 match method {
409 "fs/read_text_file" => {
410 let path = params["path"].as_str().unwrap_or("");
411 match std::fs::read_to_string(path) {
412 Ok(content) => {
413 let _ = client
414 .respond(id, serde_json::json!({"content": content}))
415 .await;
416 }
417 Err(e) => {
418 let _ = client.respond_error(id, -32603, &e.to_string()).await;
419 }
420 }
421 }
422 "fs/write_text_file" => {
423 let path = params["path"].as_str().unwrap_or("");
424 let content = params["content"].as_str().unwrap_or("");
425 match std::fs::write(path, content) {
426 Ok(()) => {
427 let _ = client.respond(id, serde_json::json!({})).await;
428 }
429 Err(e) => {
430 let _ = client.respond_error(id, -32603, &e.to_string()).await;
431 }
432 }
433 }
434 "terminal/create" => {
435 let command = params["command"].as_str().unwrap_or("sh");
436 let args: Vec<String> = params["args"]
437 .as_array()
438 .map(|a| {
439 a.iter()
440 .filter_map(|v| v.as_str().map(String::from))
441 .collect()
442 })
443 .unwrap_or_default();
444 let cwd = params["cwd"].as_str();
445 let mut cmd = tokio::process::Command::new(command);
446 cmd.args(&args);
447 if let Some(d) = cwd {
448 cmd.current_dir(d);
449 }
450 cmd.stdout(std::process::Stdio::piped());
451 cmd.stderr(std::process::Stdio::piped());
452 match cmd.spawn() {
453 Ok(_child) => {
454 let tid = uuid::Uuid::new_v4().to_string();
455 let _ = client
456 .respond(id, serde_json::json!({"terminalId": tid}))
457 .await;
458 }
459 Err(e) => {
460 let _ = client.respond_error(id, -32603, &e.to_string()).await;
461 }
462 }
463 }
464 "session/request_permission" => {
465 let options = params["options"].as_array();
466 let allow_id = options
467 .and_then(|opts| {
468 opts.iter().find(|o| {
469 o["kind"].as_str() == Some("allow_once")
470 || o["kind"].as_str() == Some("allow-once")
471 })
472 })
473 .and_then(|o| o["optionId"].as_str())
474 .unwrap_or("allow-once");
475 let _ = client
476 .respond(
477 id,
478 serde_json::json!({
479 "outcome": { "outcome": "selected", "optionId": allow_id }
480 }),
481 )
482 .await;
483 }
484 _ => {
485 let _ = client
486 .respond_error(id, -32601, &format!("unsupported: {}", method))
487 .await;
488 }
489 }
490}
491
492pub enum LoopSignal {
493 Continue,
494 Quit,
495 CancelStream,
496 OpenEditor,
497}
498
499pub async fn dispatch_action(
500 app: &mut App,
501 agent: &Arc<Mutex<Agent>>,
502 action: InputAction,
503 agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
504 agent_task: &mut Option<tokio::task::JoinHandle<()>>,
505) -> LoopSignal {
506 match action {
507 InputAction::Quit => return LoopSignal::Quit,
508 InputAction::CancelStream => {
509 if let Some(handle) = agent_task.take() {
510 handle.abort();
511 }
512 *agent_rx = None;
513 app.is_streaming = false;
514 app.streaming_started = None;
515 if !app.current_response.is_empty()
516 || !app.current_tool_calls.is_empty()
517 || !app.streaming_segments.is_empty()
518 {
519 if !app.current_response.is_empty() {
520 app.streaming_segments
521 .push(StreamSegment::Text(std::mem::take(
522 &mut app.current_response,
523 )));
524 }
525 let content: String = app
526 .streaming_segments
527 .iter()
528 .filter_map(|s| {
529 if let StreamSegment::Text(t) = s {
530 Some(t.as_str())
531 } else {
532 None
533 }
534 })
535 .collect();
536 let thinking = if app.current_thinking.is_empty() {
537 None
538 } else {
539 Some(std::mem::take(&mut app.current_thinking))
540 };
541 let tool_calls = std::mem::take(&mut app.current_tool_calls);
542 let segments = std::mem::take(&mut app.streaming_segments);
543 app.messages.push(ChatMessage {
544 role: "assistant".to_string(),
545 content: content.clone(),
546 tool_calls: tool_calls.clone(),
547 thinking: thinking.clone(),
548 model: Some(app.model_name.clone()),
549 segments: Some(segments),
550 chips: None,
551 });
552 let interrupted_tools: Vec<InterruptedToolCall> = tool_calls
553 .into_iter()
554 .map(|tc| InterruptedToolCall {
555 name: tc.name,
556 input: tc.input,
557 output: tc.output,
558 is_error: tc.is_error,
559 })
560 .collect();
561 if let Err(e) =
562 agent
563 .lock()
564 .await
565 .add_interrupted_message(content, interrupted_tools, thinking)
566 {
567 tracing::warn!("Failed to persist interrupted message: {}", e);
568 }
569 } else {
570 app.current_response.clear();
571 app.current_thinking.clear();
572 app.current_tool_calls.clear();
573 app.streaming_segments.clear();
574 }
575 app.pending_tool_name = None;
576 app.pending_question = None;
577 app.pending_permission = None;
578 app.status_message = Some(app::StatusMessage::info("cancelled"));
579 return LoopSignal::CancelStream;
580 }
581 InputAction::SendMessage(msg) => {
582 let images: Vec<(String, String)> = app
583 .take_attachments()
584 .into_iter()
585 .map(|a| (a.media_type, a.data))
586 .collect();
587
588 let (tx, rx) = mpsc::unbounded_channel();
589 *agent_rx = Some(rx);
590
591 let agent_clone = Arc::clone(agent);
592 let err_tx = tx.clone();
593 *agent_task = Some(tokio::spawn(async move {
594 let mut agent = agent_clone.lock().await;
595 let result = if images.is_empty() {
596 agent.send_message(&msg, tx).await
597 } else {
598 agent.send_message_with_images(&msg, images, tx).await
599 };
600 if let Err(e) = result {
601 tracing::error!("Agent send_message error: {}", e);
602 let _ = err_tx.send(crate::agent::AgentEvent::Error(format!("{e}")));
603 }
604 }));
605 }
606 InputAction::NewConversation => {
607 let mut agent_lock = agent.lock().await;
608 match agent_lock.new_conversation() {
609 Ok(()) => app.clear_conversation(),
610 Err(e) => {
611 app.status_message = Some(app::StatusMessage::error(format!(
612 "failed to start new conversation: {e}"
613 )))
614 }
615 }
616 }
617 InputAction::OpenModelSelector => {
618 let agent_lock = agent.lock().await;
619 let current_provider = agent_lock.current_provider_name().to_string();
620 let current_model = agent_lock.current_model().to_string();
621
622 let grouped = if let Some(ref cached) = app.cached_model_groups {
623 cached.clone()
624 } else {
625 let cached = agent_lock.cached_all_models();
626 let has_all = cached.iter().all(|(_, models)| !models.is_empty());
627 if has_all {
628 app.cached_model_groups = Some(cached.clone());
629 cached
630 } else {
631 let (tx, rx) = tokio::sync::oneshot::channel();
632 let agent_clone = Arc::clone(agent);
633 tokio::spawn(async move {
634 let mut lock = agent_clone.lock().await;
635 let result = lock.fetch_all_models().await;
636 let provider = lock.current_provider_name().to_string();
637 let model = lock.current_model().to_string();
638 let _ = tx.send((result, provider, model));
639 });
640 app.model_fetch_rx = Some(rx);
641 cached
642 }
643 };
644 drop(agent_lock);
645
646 app.model_selector.favorites = app.favorite_models.clone();
647 app.model_selector
648 .open(grouped, ¤t_provider, ¤t_model);
649 }
650 InputAction::OpenAgentSelector => {
651 let agent_lock = agent.lock().await;
652 let entries: Vec<AgentEntry> = agent_lock
653 .agent_profiles()
654 .iter()
655 .map(|p| AgentEntry {
656 name: p.name.clone(),
657 description: p.description.clone(),
658 })
659 .collect();
660 let current = agent_lock.current_agent_name().to_string();
661 drop(agent_lock);
662 app.agent_selector.open(entries, ¤t);
663 }
664 InputAction::OpenSessionSelector => {
665 let agent_lock = agent.lock().await;
666 let current_id = agent_lock.conversation_id().to_string();
667 let sessions = agent_lock.list_sessions().unwrap_or_default();
668 drop(agent_lock);
669 let entries: Vec<SessionEntry> = sessions
670 .into_iter()
671 .map(|s| {
672 let title = if let Some(t) = &s.title {
673 t.clone()
674 } else if s.id == current_id {
675 app.conversation_title
676 .clone()
677 .unwrap_or_else(|| "new conversation".to_string())
678 } else {
679 "untitled".to_string()
680 };
681 SessionEntry {
682 id: s.id.clone(),
683 title,
684 subtitle: format!("{} ยท {}", time_ago(&s.updated_at), s.provider),
685 }
686 })
687 .collect();
688 app.session_selector.open(entries);
689 }
690 InputAction::ResumeSession { id } => {
691 let mut agent_lock = agent.lock().await;
692 match agent_lock.get_session(&id) {
693 Ok(conv) => {
694 let title = conv.title.clone();
695 let conv_model = conv.model.clone();
696 let messages_for_ui: Vec<(String, String)> = conv
697 .messages
698 .iter()
699 .map(|m| (m.role.clone(), m.content.clone()))
700 .collect();
701 match agent_lock.resume_conversation(&conv) {
702 Ok(()) => {
703 drop(agent_lock);
704 app.clear_conversation();
705 app.conversation_title = title;
706 for (role, content) in messages_for_ui {
707 let model = if role == "assistant" {
708 Some(conv_model.clone())
709 } else {
710 None
711 };
712 app.messages.push(ChatMessage {
713 role,
714 content,
715 tool_calls: Vec::new(),
716 thinking: None,
717 model,
718 segments: None,
719 chips: None,
720 });
721 }
722 app.scroll_to_bottom();
723 }
724 Err(e) => {
725 drop(agent_lock);
726 app.status_message = Some(app::StatusMessage::error(format!(
727 "failed to resume session: {e}"
728 )));
729 }
730 }
731 }
732 Err(e) => {
733 drop(agent_lock);
734 app.status_message =
735 Some(app::StatusMessage::error(format!("session not found: {e}")));
736 }
737 }
738 }
739 InputAction::SelectModel { provider, model } => {
740 let mut agent_lock = agent.lock().await;
741 agent_lock.set_active_provider(&provider, &model);
742 let cw = agent_lock.context_window();
743 if cw > 0 {
744 app.context_window = cw;
745 } else {
746 app.context_window = agent_lock.fetch_context_window().await;
747 }
748 }
749 InputAction::SelectAgent { name } => {
750 let mut agent_lock = agent.lock().await;
751 agent_lock.switch_agent(&name);
752 app.model_name = agent_lock.current_model().to_string();
753 app.provider_name = agent_lock.current_provider_name().to_string();
754 let cw = agent_lock.context_window();
755 if cw > 0 {
756 app.context_window = cw;
757 } else {
758 app.context_window = agent_lock.fetch_context_window().await;
759 }
760 }
761 InputAction::ScrollUp(n) => app.scroll_up(n),
762 InputAction::ScrollDown(n) => app.scroll_down(n),
763 InputAction::ScrollToTop => app.scroll_to_top(),
764 InputAction::ScrollToBottom => app.scroll_to_bottom(),
765 InputAction::ClearConversation => app.clear_conversation(),
766 InputAction::ToggleThinking => {
767 app.thinking_expanded = !app.thinking_expanded;
768 app.mark_dirty();
769 }
770 InputAction::OpenThinkingSelector => {
771 let level = app.thinking_level();
772 app.thinking_selector.open(level);
773 }
774 InputAction::SetThinkingLevel(budget) => {
775 let mut agent_lock = agent.lock().await;
776 agent_lock.set_thinking_budget(budget);
777 }
778 InputAction::CycleThinkingLevel => {
779 let next = app.thinking_level().next();
780 let budget = next.budget_tokens();
781 app.thinking_budget = budget;
782 let mut agent_lock = agent.lock().await;
783 agent_lock.set_thinking_budget(budget);
784 }
785 InputAction::TruncateToMessage(idx) => {
786 app.messages.truncate(idx + 1);
787 app.current_response.clear();
788 app.current_thinking.clear();
789 app.current_tool_calls.clear();
790 app.streaming_segments.clear();
791 app.scroll_to_bottom();
792 let mut agent_lock = agent.lock().await;
793 agent_lock.truncate_messages(idx + 1);
794 }
795 InputAction::RevertToMessage(idx) => {
796 let prompt = if idx < app.messages.len() && app.messages[idx].role == "user" {
797 app.messages[idx].content.clone()
798 } else if idx > 0 && app.messages[idx - 1].role == "user" {
799 app.messages[idx - 1].content.clone()
800 } else {
801 String::new()
802 };
803 app.current_response.clear();
804 app.current_thinking.clear();
805 app.current_tool_calls.clear();
806 app.streaming_segments.clear();
807 let mut agent_lock = agent.lock().await;
808 match agent_lock.revert_to_message(idx) {
809 Ok(restored) => {
810 drop(agent_lock);
811 app.messages.truncate(idx);
812 app.input = prompt;
813 app.cursor_pos = app.input.len();
814 app.chips.clear();
815 app.mark_dirty();
816 app.scroll_to_bottom();
817 let count = restored.len();
818 if count > 0 {
819 app.status_message = Some(app::StatusMessage::info(format!(
820 "reverted {count} file{}",
821 if count == 1 { "" } else { "s" }
822 )));
823 }
824 }
825 Err(e) => {
826 drop(agent_lock);
827 app.status_message =
828 Some(app::StatusMessage::error(format!("revert failed: {e}")));
829 }
830 }
831 }
832 InputAction::CopyMessage(idx) => {
833 if idx < app.messages.len() {
834 app::copy_to_clipboard(&app.messages[idx].content);
835 app.status_message = Some(app::StatusMessage::info("copied to clipboard"));
836 }
837 }
838 InputAction::ForkFromMessage(idx) => {
839 let fork_messages: Vec<(String, String, Option<String>)> = app.messages[..=idx]
840 .iter()
841 .map(|m| (m.role.clone(), m.content.clone(), m.model.clone()))
842 .collect();
843 let prompt = fork_messages
844 .iter()
845 .rev()
846 .find(|(role, _, _)| role == "user")
847 .map(|(_, content, _)| content.clone())
848 .unwrap_or_default();
849 let mut agent_lock = agent.lock().await;
850 match agent_lock.fork_conversation(idx + 1) {
851 Ok(()) => {
852 drop(agent_lock);
853 app.clear_conversation();
854 for (role, content, model) in fork_messages {
855 app.messages.push(ChatMessage {
856 role,
857 content,
858 tool_calls: Vec::new(),
859 thinking: None,
860 model,
861 segments: None,
862 chips: None,
863 });
864 }
865 app.input = prompt;
866 app.cursor_pos = app.input.len();
867 app.chips.clear();
868 app.scroll_to_bottom();
869 }
870 Err(e) => {
871 drop(agent_lock);
872 app.status_message =
873 Some(app::StatusMessage::error(format!("fork failed: {e}")));
874 }
875 }
876 }
877 InputAction::AnswerQuestion(answer) => {
878 app.messages.push(ChatMessage {
879 role: "user".to_string(),
880 content: answer,
881 tool_calls: Vec::new(),
882 thinking: None,
883 model: None,
884 segments: None,
885 chips: None,
886 });
887 app.scroll_to_bottom();
888 }
889 InputAction::LoadSkill { name } => {
890 let display = format!("/{}", name);
891 app.messages.push(ChatMessage {
892 role: "user".to_string(),
893 content: display,
894 tool_calls: Vec::new(),
895 thinking: None,
896 model: None,
897 segments: None,
898 chips: None,
899 });
900 app.scroll_to_bottom();
901 let msg = format!("Load and use the {} skill", name);
902 let (tx, rx) = mpsc::unbounded_channel();
903 *agent_rx = Some(rx);
904 let agent_clone = Arc::clone(agent);
905 *agent_task = Some(tokio::spawn(async move {
906 let mut agent = agent_clone.lock().await;
907 if let Err(e) = agent.send_message(&msg, tx).await {
908 tracing::error!("Agent send_message error: {}", e);
909 }
910 }));
911 }
912 InputAction::RunCustomCommand { name, args } => {
913 let display = format!("/{} {}", name, args).trim_end().to_string();
914 app.messages.push(ChatMessage {
915 role: "user".to_string(),
916 content: display,
917 tool_calls: Vec::new(),
918 thinking: None,
919 model: None,
920 segments: None,
921 chips: None,
922 });
923 let agent_lock = agent.lock().await;
924 match agent_lock.execute_command(&name, &args) {
925 Ok(output) => {
926 app.messages.push(ChatMessage {
927 role: "assistant".to_string(),
928 content: output,
929 tool_calls: Vec::new(),
930 thinking: None,
931 model: None,
932 segments: None,
933 chips: None,
934 });
935 }
936 Err(e) => {
937 app.status_message =
938 Some(app::StatusMessage::error(format!("command error: {e}")));
939 }
940 }
941 drop(agent_lock);
942 app.scroll_to_bottom();
943 }
944 InputAction::ToggleAgent => {
945 let mut agent_lock = agent.lock().await;
946 let current = agent_lock.current_agent_name().to_string();
947 let names: Vec<String> = agent_lock
948 .agent_profiles()
949 .iter()
950 .map(|p| p.name.clone())
951 .collect();
952 let idx = names.iter().position(|n| n == ¤t).unwrap_or(0);
953 let next = names[(idx + 1) % names.len()].clone();
954 agent_lock.switch_agent(&next);
955 app.agent_name = agent_lock.current_agent_name().to_string();
956 app.model_name = agent_lock.current_model().to_string();
957 app.provider_name = agent_lock.current_provider_name().to_string();
958 }
959 InputAction::ExportSession(path_opt) => {
960 let agent_lock = agent.lock().await;
961 let cwd = agent_lock.cwd().to_string();
962 drop(agent_lock);
963 let title = app
964 .conversation_title
965 .as_deref()
966 .unwrap_or("session")
967 .to_string();
968 let path = match path_opt {
969 Some(p) => p,
970 None => {
971 let slug: String = title
972 .chars()
973 .map(|c| {
974 if c.is_alphanumeric() {
975 c.to_ascii_lowercase()
976 } else {
977 '-'
978 }
979 })
980 .collect();
981 format!("{}/session-{}.md", cwd, slug)
982 }
983 };
984 let mut md = format!("# Session: {}\n\n", title);
985 for msg in &app.messages {
986 match msg.role.as_str() {
987 "user" => {
988 md.push_str("---\n\n## User\n\n");
989 md.push_str(&msg.content);
990 md.push_str("\n\n");
991 }
992 "assistant" => {
993 md.push_str("---\n\n## Assistant\n\n");
994 md.push_str(&msg.content);
995 md.push_str("\n\n");
996 for tc in &msg.tool_calls {
997 let status = if tc.is_error { "error" } else { "done" };
998 md.push_str(&format!("- `{}` ({})\n", tc.name, status));
999 }
1000 }
1001 _ => {}
1002 }
1003 }
1004 match std::fs::write(&path, &md) {
1005 Ok(()) => {
1006 app.status_message =
1007 Some(app::StatusMessage::success(format!("exported to {}", path)))
1008 }
1009 Err(e) => {
1010 app.status_message =
1011 Some(app::StatusMessage::error(format!("export failed: {e}")))
1012 }
1013 }
1014 }
1015 InputAction::OpenExternalEditor => return LoopSignal::OpenEditor,
1016 InputAction::OpenLoginPopup => {
1017 app.login_popup.open();
1018 }
1019 InputAction::LoginSubmitApiKey { provider, key } => {
1020 let cred = crate::auth::ProviderCredential::ApiKey { key };
1021 match crate::auth::Credentials::load() {
1022 Ok(mut creds) => {
1023 creds.set(&provider, cred);
1024 if let Err(e) = creds.save() {
1025 app.status_message =
1026 Some(app::StatusMessage::error(format!("save failed: {e}")));
1027 } else {
1028 app.status_message = Some(app::StatusMessage::success(format!(
1029 "{} credentials saved",
1030 provider
1031 )));
1032 }
1033 }
1034 Err(e) => {
1035 app.status_message =
1036 Some(app::StatusMessage::error(format!("load creds: {e}")));
1037 }
1038 }
1039 }
1040 InputAction::LoginOAuth {
1041 provider,
1042 create_key,
1043 code,
1044 verifier,
1045 } => {
1046 app.status_message = Some(app::StatusMessage::info("exchanging code..."));
1047 app.login_popup.close();
1048 tokio::spawn(async move {
1049 match crate::auth::oauth::exchange_oauth_code(&code, &verifier, create_key).await {
1050 Ok(cred) => {
1051 if let Ok(mut creds) = crate::auth::Credentials::load() {
1052 creds.set(&provider, cred);
1053 let _ = creds.save();
1054 }
1055 tracing::info!("{} OAuth credentials saved", provider);
1056 }
1057 Err(e) => {
1058 tracing::warn!("OAuth exchange failed: {}", e);
1059 }
1060 }
1061 });
1062 }
1063 InputAction::AskAside { question } => {
1064 let agent_lock = agent.lock().await;
1065 let provider = agent_lock.aside_provider();
1066 let messages = agent_lock.messages().to_vec();
1067 let bg_tx = agent_lock.background_tx();
1068 drop(agent_lock);
1069 if let Some(tx) = bg_tx {
1070 app.aside_popup.open(question.clone());
1071 let mut aside_messages = messages;
1072 aside_messages.push(crate::provider::Message {
1073 role: crate::provider::Role::User,
1074 content: vec![crate::provider::ContentBlock::Text(question)],
1075 });
1076 tokio::spawn(async move {
1077 let system = "You are answering a quick side question. Be concise and helpful. \
1078 You have full visibility into the conversation so far. \
1079 You have no tools available.";
1080 match provider
1081 .stream(&aside_messages, Some(system), &[], 2048, 0)
1082 .await
1083 {
1084 Ok(mut rx) => {
1085 while let Some(event) = rx.recv().await {
1086 match event.event_type {
1087 crate::provider::StreamEventType::TextDelta(text) => {
1088 let _ = tx.send(crate::agent::AgentEvent::AsideDelta(text));
1089 }
1090 crate::provider::StreamEventType::MessageEnd { .. } => {
1091 let _ = tx.send(crate::agent::AgentEvent::AsideDone);
1092 break;
1093 }
1094 _ => {}
1095 }
1096 }
1097 }
1098 Err(e) => {
1099 let _ = tx.send(crate::agent::AgentEvent::AsideError(format!("{e}")));
1100 }
1101 }
1102 });
1103 } else {
1104 app.status_message = Some(app::StatusMessage::error("aside not available"));
1105 }
1106 }
1107 InputAction::AnswerPermission(_) | InputAction::None => {}
1108 InputAction::OpenRenamePopup => {
1109 app.rename_input = app.conversation_title.clone().unwrap_or_default();
1110 app.rename_visible = true;
1111 }
1112 InputAction::RenameSession(title) => {
1113 let agent_lock = agent.lock().await;
1114 if let Err(e) = agent_lock.rename_session(&title) {
1115 app.status_message = Some(app::StatusMessage::error(format!("rename failed: {e}")));
1116 } else {
1117 app.conversation_title = Some(title);
1118 }
1119 app.rename_visible = false;
1120 }
1121 }
1122 LoopSignal::Continue
1123}