Skip to main content

dot/tui/
actions.rs

1use std::sync::Arc;
2
3use tokio::sync::{Mutex, mpsc};
4
5use crate::agent::Agent;
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 enum LoopSignal {
12    Continue,
13    Quit,
14    CancelStream,
15    OpenEditor,
16}
17
18pub async fn dispatch_action(
19    app: &mut App,
20    agent: &Arc<Mutex<Agent>>,
21    action: InputAction,
22    agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
23    agent_task: &mut Option<tokio::task::JoinHandle<()>>,
24) -> LoopSignal {
25    match action {
26        InputAction::Quit => return LoopSignal::Quit,
27        InputAction::CancelStream => {
28            if let Some(handle) = agent_task.take() {
29                handle.abort();
30            }
31            *agent_rx = None;
32            app.is_streaming = false;
33            app.streaming_started = None;
34            if !app.current_response.is_empty()
35                || !app.current_tool_calls.is_empty()
36                || !app.streaming_segments.is_empty()
37            {
38                if !app.current_response.is_empty() {
39                    app.streaming_segments
40                        .push(StreamSegment::Text(std::mem::take(
41                            &mut app.current_response,
42                        )));
43                }
44                let content: String = app
45                    .streaming_segments
46                    .iter()
47                    .filter_map(|s| {
48                        if let StreamSegment::Text(t) = s {
49                            Some(t.as_str())
50                        } else {
51                            None
52                        }
53                    })
54                    .collect();
55                let thinking = if app.current_thinking.is_empty() {
56                    None
57                } else {
58                    Some(std::mem::take(&mut app.current_thinking))
59                };
60                app.messages.push(ChatMessage {
61                    role: "assistant".to_string(),
62                    content,
63                    tool_calls: std::mem::take(&mut app.current_tool_calls),
64                    thinking,
65                    model: Some(app.model_name.clone()),
66                    segments: Some(std::mem::take(&mut app.streaming_segments)),
67                });
68            } else {
69                app.current_response.clear();
70                app.current_thinking.clear();
71                app.current_tool_calls.clear();
72                app.streaming_segments.clear();
73            }
74            app.pending_tool_name = None;
75            app.pending_question = None;
76            app.pending_permission = None;
77            app.status_message = Some(app::StatusMessage::info("cancelled"));
78            return LoopSignal::CancelStream;
79        }
80        InputAction::SendMessage(msg) => {
81            let images: Vec<(String, String)> = app
82                .take_attachments()
83                .into_iter()
84                .map(|a| (a.media_type, a.data))
85                .collect();
86
87            let (tx, rx) = mpsc::unbounded_channel();
88            *agent_rx = Some(rx);
89
90            let agent_clone = Arc::clone(agent);
91            let err_tx = tx.clone();
92            *agent_task = Some(tokio::spawn(async move {
93                let mut agent = agent_clone.lock().await;
94                let result = if images.is_empty() {
95                    agent.send_message(&msg, tx).await
96                } else {
97                    agent.send_message_with_images(&msg, images, tx).await
98                };
99                if let Err(e) = result {
100                    tracing::error!("Agent send_message error: {}", e);
101                    let _ = err_tx.send(crate::agent::AgentEvent::Error(format!("{e}")));
102                }
103            }));
104        }
105        InputAction::NewConversation => {
106            let mut agent_lock = agent.lock().await;
107            match agent_lock.new_conversation() {
108                Ok(()) => app.clear_conversation(),
109                Err(e) => {
110                    app.status_message = Some(app::StatusMessage::error(format!(
111                        "failed to start new conversation: {e}"
112                    )))
113                }
114            }
115        }
116        InputAction::OpenModelSelector => {
117            let agent_lock = agent.lock().await;
118            let grouped = agent_lock.fetch_all_models().await;
119            let current_provider = agent_lock.current_provider_name().to_string();
120            let current_model = agent_lock.current_model().to_string();
121            drop(agent_lock);
122            app.model_selector.favorites = app.favorite_models.clone();
123            app.model_selector
124                .open(grouped, &current_provider, &current_model);
125        }
126        InputAction::OpenAgentSelector => {
127            let agent_lock = agent.lock().await;
128            let entries: Vec<AgentEntry> = agent_lock
129                .agent_profiles()
130                .iter()
131                .map(|p| AgentEntry {
132                    name: p.name.clone(),
133                    description: p.description.clone(),
134                })
135                .collect();
136            let current = agent_lock.current_agent_name().to_string();
137            drop(agent_lock);
138            app.agent_selector.open(entries, &current);
139        }
140        InputAction::OpenSessionSelector => {
141            let agent_lock = agent.lock().await;
142            let current_id = agent_lock.conversation_id().to_string();
143            let sessions = agent_lock.list_sessions().unwrap_or_default();
144            drop(agent_lock);
145            let entries: Vec<SessionEntry> = sessions
146                .into_iter()
147                .map(|s| {
148                    let title = if let Some(t) = &s.title {
149                        t.clone()
150                    } else if s.id == current_id {
151                        app.conversation_title
152                            .clone()
153                            .unwrap_or_else(|| "new conversation".to_string())
154                    } else {
155                        "untitled".to_string()
156                    };
157                    SessionEntry {
158                        id: s.id.clone(),
159                        title,
160                        subtitle: format!("{} ยท {}", time_ago(&s.updated_at), s.provider),
161                    }
162                })
163                .collect();
164            app.session_selector.open(entries);
165        }
166        InputAction::ResumeSession { id } => {
167            let mut agent_lock = agent.lock().await;
168            match agent_lock.get_session(&id) {
169                Ok(conv) => {
170                    let title = conv.title.clone();
171                    let conv_model = conv.model.clone();
172                    let messages_for_ui: Vec<(String, String)> = conv
173                        .messages
174                        .iter()
175                        .map(|m| (m.role.clone(), m.content.clone()))
176                        .collect();
177                    match agent_lock.resume_conversation(&conv) {
178                        Ok(()) => {
179                            drop(agent_lock);
180                            app.clear_conversation();
181                            app.conversation_title = title;
182                            for (role, content) in messages_for_ui {
183                                let model = if role == "assistant" {
184                                    Some(conv_model.clone())
185                                } else {
186                                    None
187                                };
188                                app.messages.push(ChatMessage {
189                                    role,
190                                    content,
191                                    tool_calls: Vec::new(),
192                                    thinking: None,
193                                    model,
194                                    segments: None,
195                                });
196                            }
197                            app.scroll_to_bottom();
198                        }
199                        Err(e) => {
200                            drop(agent_lock);
201                            app.status_message = Some(app::StatusMessage::error(format!(
202                                "failed to resume session: {e}"
203                            )));
204                        }
205                    }
206                }
207                Err(e) => {
208                    drop(agent_lock);
209                    app.status_message =
210                        Some(app::StatusMessage::error(format!("session not found: {e}")));
211                }
212            }
213        }
214        InputAction::SelectModel { provider, model } => {
215            let mut agent_lock = agent.lock().await;
216            agent_lock.set_active_provider(&provider, &model);
217            let cw = agent_lock.context_window();
218            if cw > 0 {
219                app.context_window = cw;
220            } else {
221                app.context_window = agent_lock.fetch_context_window().await;
222            }
223        }
224        InputAction::SelectAgent { name } => {
225            let mut agent_lock = agent.lock().await;
226            agent_lock.switch_agent(&name);
227            app.model_name = agent_lock.current_model().to_string();
228            app.provider_name = agent_lock.current_provider_name().to_string();
229            let cw = agent_lock.context_window();
230            if cw > 0 {
231                app.context_window = cw;
232            } else {
233                app.context_window = agent_lock.fetch_context_window().await;
234            }
235        }
236        InputAction::ScrollUp(n) => app.scroll_up(n),
237        InputAction::ScrollDown(n) => app.scroll_down(n),
238        InputAction::ScrollToTop => app.scroll_to_top(),
239        InputAction::ScrollToBottom => app.scroll_to_bottom(),
240        InputAction::ClearConversation => app.clear_conversation(),
241        InputAction::ToggleThinking => {
242            app.thinking_expanded = !app.thinking_expanded;
243            app.mark_dirty();
244        }
245        InputAction::OpenThinkingSelector => {
246            let level = app.thinking_level();
247            app.thinking_selector.open(level);
248        }
249        InputAction::SetThinkingLevel(budget) => {
250            let mut agent_lock = agent.lock().await;
251            agent_lock.set_thinking_budget(budget);
252        }
253        InputAction::CycleThinkingLevel => {
254            let next = app.thinking_level().next();
255            let budget = next.budget_tokens();
256            app.thinking_budget = budget;
257            let mut agent_lock = agent.lock().await;
258            agent_lock.set_thinking_budget(budget);
259        }
260        InputAction::TruncateToMessage(idx) => {
261            app.messages.truncate(idx + 1);
262            app.current_response.clear();
263            app.current_thinking.clear();
264            app.current_tool_calls.clear();
265            app.streaming_segments.clear();
266            app.scroll_to_bottom();
267            let mut agent_lock = agent.lock().await;
268            agent_lock.truncate_messages(idx + 1);
269        }
270        InputAction::RevertToMessage(idx) => {
271            let prompt = if idx < app.messages.len() && app.messages[idx].role == "user" {
272                app.messages[idx].content.clone()
273            } else if idx > 0 && app.messages[idx - 1].role == "user" {
274                app.messages[idx - 1].content.clone()
275            } else {
276                String::new()
277            };
278            app.current_response.clear();
279            app.current_thinking.clear();
280            app.current_tool_calls.clear();
281            app.streaming_segments.clear();
282            let mut agent_lock = agent.lock().await;
283            match agent_lock.revert_to_message(idx) {
284                Ok(restored) => {
285                    drop(agent_lock);
286                    app.messages.truncate(idx);
287                    app.input = prompt;
288                    app.cursor_pos = app.input.len();
289                    app.chips.clear();
290                    app.mark_dirty();
291                    app.scroll_to_bottom();
292                    let count = restored.len();
293                    if count > 0 {
294                        app.status_message = Some(app::StatusMessage::info(format!(
295                            "reverted {count} file{}",
296                            if count == 1 { "" } else { "s" }
297                        )));
298                    }
299                }
300                Err(e) => {
301                    drop(agent_lock);
302                    app.status_message =
303                        Some(app::StatusMessage::error(format!("revert failed: {e}")));
304                }
305            }
306        }
307        InputAction::CopyMessage(idx) => {
308            if idx < app.messages.len() {
309                app::copy_to_clipboard(&app.messages[idx].content);
310                app.status_message = Some(app::StatusMessage::info("copied to clipboard"));
311            }
312        }
313        InputAction::ForkFromMessage(idx) => {
314            let fork_messages: Vec<(String, String, Option<String>)> = app.messages[..=idx]
315                .iter()
316                .map(|m| (m.role.clone(), m.content.clone(), m.model.clone()))
317                .collect();
318            let prompt = fork_messages
319                .iter()
320                .rev()
321                .find(|(role, _, _)| role == "user")
322                .map(|(_, content, _)| content.clone())
323                .unwrap_or_default();
324            let mut agent_lock = agent.lock().await;
325            match agent_lock.fork_conversation(idx + 1) {
326                Ok(()) => {
327                    drop(agent_lock);
328                    app.clear_conversation();
329                    for (role, content, model) in fork_messages {
330                        app.messages.push(ChatMessage {
331                            role,
332                            content,
333                            tool_calls: Vec::new(),
334                            thinking: None,
335                            model,
336                            segments: None,
337                        });
338                    }
339                    app.input = prompt;
340                    app.cursor_pos = app.input.len();
341                    app.chips.clear();
342                    app.scroll_to_bottom();
343                }
344                Err(e) => {
345                    drop(agent_lock);
346                    app.status_message =
347                        Some(app::StatusMessage::error(format!("fork failed: {e}")));
348                }
349            }
350        }
351        InputAction::AnswerQuestion(answer) => {
352            app.messages.push(ChatMessage {
353                role: "user".to_string(),
354                content: answer,
355                tool_calls: Vec::new(),
356                thinking: None,
357                model: None,
358                segments: None,
359            });
360            app.scroll_to_bottom();
361        }
362        InputAction::LoadSkill { name } => {
363            let display = format!("/{}", name);
364            app.messages.push(ChatMessage {
365                role: "user".to_string(),
366                content: display,
367                tool_calls: Vec::new(),
368                thinking: None,
369                model: None,
370                segments: None,
371            });
372            app.scroll_to_bottom();
373            let msg = format!("Load and use the {} skill", name);
374            let (tx, rx) = mpsc::unbounded_channel();
375            *agent_rx = Some(rx);
376            let agent_clone = Arc::clone(agent);
377            *agent_task = Some(tokio::spawn(async move {
378                let mut agent = agent_clone.lock().await;
379                if let Err(e) = agent.send_message(&msg, tx).await {
380                    tracing::error!("Agent send_message error: {}", e);
381                }
382            }));
383        }
384        InputAction::RunCustomCommand { name, args } => {
385            let display = format!("/{} {}", name, args).trim_end().to_string();
386            app.messages.push(ChatMessage {
387                role: "user".to_string(),
388                content: display,
389                tool_calls: Vec::new(),
390                thinking: None,
391                model: None,
392                segments: None,
393            });
394            let agent_lock = agent.lock().await;
395            match agent_lock.execute_command(&name, &args) {
396                Ok(output) => {
397                    app.messages.push(ChatMessage {
398                        role: "assistant".to_string(),
399                        content: output,
400                        tool_calls: Vec::new(),
401                        thinking: None,
402                        model: None,
403                        segments: None,
404                    });
405                }
406                Err(e) => {
407                    app.status_message =
408                        Some(app::StatusMessage::error(format!("command error: {e}")));
409                }
410            }
411            drop(agent_lock);
412            app.scroll_to_bottom();
413        }
414        InputAction::ToggleAgent => {
415            let mut agent_lock = agent.lock().await;
416            let current = agent_lock.current_agent_name().to_string();
417            let names: Vec<String> = agent_lock
418                .agent_profiles()
419                .iter()
420                .map(|p| p.name.clone())
421                .collect();
422            let idx = names.iter().position(|n| n == &current).unwrap_or(0);
423            let next = names[(idx + 1) % names.len()].clone();
424            agent_lock.switch_agent(&next);
425            app.agent_name = agent_lock.current_agent_name().to_string();
426            app.model_name = agent_lock.current_model().to_string();
427            app.provider_name = agent_lock.current_provider_name().to_string();
428        }
429        InputAction::ExportSession(path_opt) => {
430            let agent_lock = agent.lock().await;
431            let cwd = agent_lock.cwd().to_string();
432            drop(agent_lock);
433            let title = app
434                .conversation_title
435                .as_deref()
436                .unwrap_or("session")
437                .to_string();
438            let path = match path_opt {
439                Some(p) => p,
440                None => {
441                    let slug: String = title
442                        .chars()
443                        .map(|c| {
444                            if c.is_alphanumeric() {
445                                c.to_ascii_lowercase()
446                            } else {
447                                '-'
448                            }
449                        })
450                        .collect();
451                    format!("{}/session-{}.md", cwd, slug)
452                }
453            };
454            let mut md = format!("# Session: {}\n\n", title);
455            for msg in &app.messages {
456                match msg.role.as_str() {
457                    "user" => {
458                        md.push_str("---\n\n## User\n\n");
459                        md.push_str(&msg.content);
460                        md.push_str("\n\n");
461                    }
462                    "assistant" => {
463                        md.push_str("---\n\n## Assistant\n\n");
464                        md.push_str(&msg.content);
465                        md.push_str("\n\n");
466                        for tc in &msg.tool_calls {
467                            let status = if tc.is_error { "error" } else { "done" };
468                            md.push_str(&format!("- `{}` ({})\n", tc.name, status));
469                        }
470                    }
471                    _ => {}
472                }
473            }
474            match std::fs::write(&path, &md) {
475                Ok(()) => {
476                    app.status_message =
477                        Some(app::StatusMessage::success(format!("exported to {}", path)))
478                }
479                Err(e) => {
480                    app.status_message =
481                        Some(app::StatusMessage::error(format!("export failed: {e}")))
482                }
483            }
484        }
485        InputAction::OpenExternalEditor => return LoopSignal::OpenEditor,
486        InputAction::AnswerPermission(_) | InputAction::None => {}
487        InputAction::OpenRenamePopup => {
488            app.rename_input = app.conversation_title.clone().unwrap_or_default();
489            app.rename_visible = true;
490        }
491        InputAction::RenameSession(title) => {
492            let agent_lock = agent.lock().await;
493            if let Err(e) = agent_lock.rename_session(&title) {
494                app.status_message = Some(app::StatusMessage::error(format!("rename failed: {e}")));
495            } else {
496                app.conversation_title = Some(title);
497            }
498            app.rename_visible = false;
499        }
500    }
501    LoopSignal::Continue
502}