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, ¤t_provider, ¤t_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, ¤t);
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 == ¤t).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}