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 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 == ¤t).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}