Skip to main content

dot/tui/
actions.rs

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, &params) {
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, &current);
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, &current_provider, &current_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, &current);
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 == &current).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}