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        }
244        InputAction::OpenThinkingSelector => {
245            let level = app.thinking_level();
246            app.thinking_selector.open(level);
247        }
248        InputAction::SetThinkingLevel(budget) => {
249            let mut agent_lock = agent.lock().await;
250            agent_lock.set_thinking_budget(budget);
251        }
252        InputAction::CycleThinkingLevel => {
253            let next = app.thinking_level().next();
254            let budget = next.budget_tokens();
255            app.thinking_budget = budget;
256            let mut agent_lock = agent.lock().await;
257            agent_lock.set_thinking_budget(budget);
258        }
259        InputAction::TruncateToMessage(idx) => {
260            app.messages.truncate(idx + 1);
261            app.current_response.clear();
262            app.current_thinking.clear();
263            app.current_tool_calls.clear();
264            app.streaming_segments.clear();
265            app.scroll_to_bottom();
266            let mut agent_lock = agent.lock().await;
267            agent_lock.truncate_messages(idx + 1);
268        }
269        InputAction::ForkFromMessage(idx) => {
270            let fork_messages: Vec<(String, String, Option<String>)> = app.messages[..=idx]
271                .iter()
272                .map(|m| (m.role.clone(), m.content.clone(), m.model.clone()))
273                .collect();
274            let mut agent_lock = agent.lock().await;
275            match agent_lock.fork_conversation(idx + 1) {
276                Ok(()) => {
277                    drop(agent_lock);
278                    app.clear_conversation();
279                    for (role, content, model) in fork_messages {
280                        app.messages.push(ChatMessage {
281                            role,
282                            content,
283                            tool_calls: Vec::new(),
284                            thinking: None,
285                            model,
286                            segments: None,
287                        });
288                    }
289                    app.scroll_to_bottom();
290                }
291                Err(e) => {
292                    drop(agent_lock);
293                    app.status_message =
294                        Some(app::StatusMessage::error(format!("fork failed: {e}")));
295                }
296            }
297        }
298        InputAction::AnswerQuestion(answer) => {
299            app.messages.push(ChatMessage {
300                role: "user".to_string(),
301                content: answer,
302                tool_calls: Vec::new(),
303                thinking: None,
304                model: None,
305                segments: None,
306            });
307            app.scroll_to_bottom();
308        }
309        InputAction::LoadSkill { name } => {
310            let display = format!("/{}", name);
311            app.messages.push(ChatMessage {
312                role: "user".to_string(),
313                content: display,
314                tool_calls: Vec::new(),
315                thinking: None,
316                model: None,
317                segments: None,
318            });
319            app.scroll_to_bottom();
320            let msg = format!("Load and use the {} skill", name);
321            let (tx, rx) = mpsc::unbounded_channel();
322            *agent_rx = Some(rx);
323            let agent_clone = Arc::clone(agent);
324            *agent_task = Some(tokio::spawn(async move {
325                let mut agent = agent_clone.lock().await;
326                if let Err(e) = agent.send_message(&msg, tx).await {
327                    tracing::error!("Agent send_message error: {}", e);
328                }
329            }));
330        }
331        InputAction::RunCustomCommand { name, args } => {
332            let display = format!("/{} {}", name, args).trim_end().to_string();
333            app.messages.push(ChatMessage {
334                role: "user".to_string(),
335                content: display,
336                tool_calls: Vec::new(),
337                thinking: None,
338                model: None,
339                segments: None,
340            });
341            let agent_lock = agent.lock().await;
342            match agent_lock.execute_command(&name, &args) {
343                Ok(output) => {
344                    app.messages.push(ChatMessage {
345                        role: "assistant".to_string(),
346                        content: output,
347                        tool_calls: Vec::new(),
348                        thinking: None,
349                        model: None,
350                        segments: None,
351                    });
352                }
353                Err(e) => {
354                    app.status_message =
355                        Some(app::StatusMessage::error(format!("command error: {e}")));
356                }
357            }
358            drop(agent_lock);
359            app.scroll_to_bottom();
360        }
361        InputAction::ToggleAgent => {
362            let mut agent_lock = agent.lock().await;
363            let current = agent_lock.current_agent_name().to_string();
364            let names: Vec<String> = agent_lock
365                .agent_profiles()
366                .iter()
367                .map(|p| p.name.clone())
368                .collect();
369            let idx = names.iter().position(|n| n == &current).unwrap_or(0);
370            let next = names[(idx + 1) % names.len()].clone();
371            agent_lock.switch_agent(&next);
372            app.agent_name = agent_lock.current_agent_name().to_string();
373            app.model_name = agent_lock.current_model().to_string();
374            app.provider_name = agent_lock.current_provider_name().to_string();
375        }
376        InputAction::ExportSession(path_opt) => {
377            let agent_lock = agent.lock().await;
378            let cwd = agent_lock.cwd().to_string();
379            drop(agent_lock);
380            let title = app
381                .conversation_title
382                .as_deref()
383                .unwrap_or("session")
384                .to_string();
385            let path = match path_opt {
386                Some(p) => p,
387                None => {
388                    let slug: String = title
389                        .chars()
390                        .map(|c| {
391                            if c.is_alphanumeric() {
392                                c.to_ascii_lowercase()
393                            } else {
394                                '-'
395                            }
396                        })
397                        .collect();
398                    format!("{}/session-{}.md", cwd, slug)
399                }
400            };
401            let mut md = format!("# Session: {}\n\n", title);
402            for msg in &app.messages {
403                match msg.role.as_str() {
404                    "user" => {
405                        md.push_str("---\n\n## User\n\n");
406                        md.push_str(&msg.content);
407                        md.push_str("\n\n");
408                    }
409                    "assistant" => {
410                        md.push_str("---\n\n## Assistant\n\n");
411                        md.push_str(&msg.content);
412                        md.push_str("\n\n");
413                        for tc in &msg.tool_calls {
414                            let status = if tc.is_error { "error" } else { "done" };
415                            md.push_str(&format!("- `{}` ({})\n", tc.name, status));
416                        }
417                    }
418                    _ => {}
419                }
420            }
421            match std::fs::write(&path, &md) {
422                Ok(()) => {
423                    app.status_message =
424                        Some(app::StatusMessage::success(format!("exported to {}", path)))
425                }
426                Err(e) => {
427                    app.status_message =
428                        Some(app::StatusMessage::error(format!("export failed: {e}")))
429                }
430            }
431        }
432        InputAction::OpenExternalEditor => return LoopSignal::OpenEditor,
433        InputAction::AnswerPermission(_) | InputAction::None => {}
434        InputAction::OpenRenamePopup => {
435            app.rename_input = app.conversation_title.clone().unwrap_or_default();
436            app.rename_visible = true;
437        }
438        InputAction::RenameSession(title) => {
439            let agent_lock = agent.lock().await;
440            if let Err(e) = agent_lock.rename_session(&title) {
441                app.status_message = Some(app::StatusMessage::error(format!("rename failed: {e}")));
442            } else {
443                app.conversation_title = Some(title);
444            }
445            app.rename_visible = false;
446        }
447    }
448    LoopSignal::Continue
449}