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