1pub mod app;
2pub mod event;
3pub mod input;
4pub mod markdown;
5pub mod theme;
6pub mod tools;
7pub mod ui;
8pub mod ui_popups;
9pub mod ui_tools;
10pub mod widgets;
11
12use std::sync::Arc;
13use std::time::Instant;
14
15use anyhow::Result;
16use crossterm::{execute, terminal};
17use tokio::sync::{Mutex, mpsc};
18
19use crate::agent::{Agent, AgentProfile};
20use crate::config::Config;
21use crate::db::Db;
22use crate::provider::Provider;
23use crate::tools::ToolRegistry;
24
25use app::{App, ChatMessage};
26use event::{AppEvent, EventHandler};
27use input::InputAction;
28use widgets::{AgentEntry, SessionEntry, time_ago};
29
30pub struct ExitInfo {
31 pub conversation_id: String,
32 pub title: Option<String>,
33}
34
35pub async fn run(
36 config: Config,
37 providers: Vec<Box<dyn Provider>>,
38 db: Db,
39 tools: ToolRegistry,
40 profiles: Vec<AgentProfile>,
41 cwd: String,
42 resume_id: Option<String>,
43) -> Result<()> {
44 terminal::enable_raw_mode()?;
45 let mut stdout = std::io::stderr();
46 execute!(
47 stdout,
48 terminal::EnterAlternateScreen,
49 crossterm::event::EnableMouseCapture,
50 crossterm::event::EnableBracketedPaste
51 )?;
52 let backend = ratatui::backend::CrosstermBackend::new(stdout);
53 let mut terminal = ratatui::Terminal::new(backend)?;
54
55 let result = run_app(
56 &mut terminal,
57 config,
58 providers,
59 db,
60 tools,
61 profiles,
62 cwd,
63 resume_id,
64 )
65 .await;
66
67 terminal::disable_raw_mode()?;
68 execute!(
69 std::io::stderr(),
70 terminal::LeaveAlternateScreen,
71 crossterm::event::DisableMouseCapture,
72 crossterm::event::DisableBracketedPaste
73 )?;
74 terminal.show_cursor()?;
75
76 if let Ok(ref info) = result {
77 print_exit_screen(info);
78 }
79
80 result.map(|_| ())
81}
82
83fn print_exit_screen(info: &ExitInfo) {
84 let title = info.title.as_deref().unwrap_or("untitled session");
85 let id = &info.conversation_id;
86 println!();
87 println!(" \x1b[2mSession\x1b[0m {}", title);
88 println!(" \x1b[2mResume\x1b[0m dot -s {}", id);
89 println!();
90}
91
92#[allow(clippy::too_many_arguments)]
93async fn run_app(
94 terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>,
95 config: Config,
96 providers: Vec<Box<dyn Provider>>,
97 db: Db,
98 tools: ToolRegistry,
99 profiles: Vec<AgentProfile>,
100 cwd: String,
101 resume_id: Option<String>,
102) -> Result<ExitInfo> {
103 let model_name = providers[0].model().to_string();
104 let provider_name = providers[0].name().to_string();
105 let agent_name = profiles
106 .first()
107 .map(|p| p.name.clone())
108 .unwrap_or_else(|| "dot".to_string());
109
110 let history = db.get_user_message_history(500).unwrap_or_default();
111
112 let agents_context = crate::context::AgentsContext::load(&cwd, &config.context);
113 let agent = Arc::new(Mutex::new(Agent::new(
114 providers,
115 db,
116 &config,
117 tools,
118 profiles,
119 cwd,
120 agents_context,
121 )?));
122
123 let context_window = {
124 let agent_lock = agent.lock().await;
125 let cw = agent_lock.fetch_context_window().await;
126 if cw == 0 {
127 tracing::warn!("Failed to fetch context window from API");
128 }
129 cw
130 };
131
132 if let Some(ref id) = resume_id {
133 let mut agent_lock = agent.lock().await;
134 match agent_lock.get_session(id) {
135 Ok(conv) => {
136 let _ = agent_lock.resume_conversation(&conv);
137 }
138 Err(e) => {
139 tracing::warn!("Failed to resume session {}: {}", id, e);
140 }
141 }
142 }
143
144 let mut app = App::new(
145 model_name,
146 provider_name,
147 agent_name,
148 &config.theme.name,
149 config.tui.vim_mode,
150 context_window,
151 );
152 app.history = history;
153
154 if let Some(ref id) = resume_id {
155 let agent_lock = agent.lock().await;
156 if let Ok(conv) = agent_lock.get_session(id) {
157 app.conversation_title = conv.title.clone();
158 for m in &conv.messages {
159 let model = if m.role == "assistant" {
160 Some(conv.model.clone())
161 } else {
162 None
163 };
164 app.messages.push(ChatMessage {
165 role: m.role.clone(),
166 content: m.content.clone(),
167 tool_calls: Vec::new(),
168 thinking: None,
169 model,
170 });
171 }
172 app.scroll_to_bottom();
173 }
174 drop(agent_lock);
175 }
176
177 let mut events = EventHandler::new();
178 let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
179
180 loop {
181 terminal.draw(|f| ui::draw(f, &mut app))?;
182
183 let event = if let Some(ref mut rx) = agent_rx {
184 tokio::select! {
185 biased;
186 agent_event = rx.recv() => {
187 match agent_event {
188 Some(ev) => {
189 app.handle_agent_event(ev);
190 }
191 None => {
192 if app.is_streaming {
193 app.is_streaming = false;
194 }
195 agent_rx = None;
196 if let Some(queued) = app.message_queue.pop_front() {
197 let (tx, rx) = mpsc::unbounded_channel();
198 agent_rx = Some(rx);
199 app.is_streaming = true;
200 app.streaming_started = Some(Instant::now());
201 app.current_response.clear();
202 app.current_thinking.clear();
203 app.current_tool_calls.clear();
204 app.error_message = None;
205 let agent_clone = Arc::clone(&agent);
206 tokio::spawn(async move {
207 let mut agent = agent_clone.lock().await;
208 let result = if queued.images.is_empty() {
209 agent.send_message(&queued.text, tx).await
210 } else {
211 agent.send_message_with_images(&queued.text, queued.images, tx).await
212 };
213 if let Err(e) = result {
214 tracing::error!("Agent send_message error: {}", e);
215 }
216 });
217 }
218 }
219 }
220 continue;
221 }
222 ui_event = events.next() => {
223 match ui_event {
224 Some(ev) => ev,
225 None => break,
226 }
227 }
228 }
229 } else {
230 match events.next().await {
231 Some(ev) => ev,
232 None => break,
233 }
234 };
235
236 if let LoopSignal::Quit = handle_event(&mut app, &agent, event, &mut agent_rx).await {
237 break;
238 }
239 }
240
241 let mut agent_lock = agent.lock().await;
242 let conversation_id = agent_lock.conversation_id().to_string();
243 let title = agent_lock.conversation_title();
244 agent_lock.cleanup_if_empty();
245 drop(agent_lock);
246
247 Ok(ExitInfo {
248 conversation_id,
249 title,
250 })
251}
252
253enum LoopSignal {
254 Continue,
255 Quit,
256 CancelStream,
257}
258
259async fn dispatch_action(
260 app: &mut App,
261 agent: &Arc<Mutex<Agent>>,
262 action: InputAction,
263 agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
264) -> LoopSignal {
265 match action {
266 InputAction::Quit => return LoopSignal::Quit,
267 InputAction::CancelStream => {
268 *agent_rx = None;
269 app.is_streaming = false;
270 app.streaming_started = None;
271 if !app.current_response.is_empty() || !app.current_tool_calls.is_empty() {
272 let thinking = if app.current_thinking.is_empty() {
273 None
274 } else {
275 Some(std::mem::take(&mut app.current_thinking))
276 };
277 app.messages.push(app::ChatMessage {
278 role: "assistant".to_string(),
279 content: std::mem::take(&mut app.current_response),
280 tool_calls: std::mem::take(&mut app.current_tool_calls),
281 thinking,
282 model: Some(app.model_name.clone()),
283 });
284 } else {
285 app.current_response.clear();
286 app.current_thinking.clear();
287 app.current_tool_calls.clear();
288 }
289 app.pending_tool_name = None;
290 app.pending_question = None;
292 app.pending_permission = None;
293 app.error_message = Some("cancelled".to_string());
294 return LoopSignal::CancelStream;
295 }
296 InputAction::SendMessage(msg) => {
297 let images: Vec<(String, String)> = app
298 .take_attachments()
299 .into_iter()
300 .map(|a| (a.media_type, a.data))
301 .collect();
302
303 let (tx, rx) = mpsc::unbounded_channel();
304 *agent_rx = Some(rx);
305
306 let agent_clone = Arc::clone(agent);
307 tokio::spawn(async move {
308 let mut agent = agent_clone.lock().await;
309 let result = if images.is_empty() {
310 agent.send_message(&msg, tx).await
311 } else {
312 agent.send_message_with_images(&msg, images, tx).await
313 };
314 if let Err(e) = result {
315 tracing::error!("Agent send_message error: {}", e);
316 }
317 });
318 }
319 InputAction::NewConversation => {
320 let mut agent_lock = agent.lock().await;
321 match agent_lock.new_conversation() {
322 Ok(()) => app.clear_conversation(),
323 Err(e) => {
324 app.error_message = Some(format!("failed to start new conversation: {e}"))
325 }
326 }
327 }
328 InputAction::OpenModelSelector => {
329 let agent_lock = agent.lock().await;
330 let grouped = agent_lock.fetch_all_models().await;
331 let current_provider = agent_lock.current_provider_name().to_string();
332 let current_model = agent_lock.current_model().to_string();
333 drop(agent_lock);
334 app.model_selector
335 .open(grouped, ¤t_provider, ¤t_model);
336 }
337 InputAction::OpenAgentSelector => {
338 let agent_lock = agent.lock().await;
339 let entries: Vec<AgentEntry> = agent_lock
340 .agent_profiles()
341 .iter()
342 .map(|p| AgentEntry {
343 name: p.name.clone(),
344 description: p.description.clone(),
345 })
346 .collect();
347 let current = agent_lock.current_agent_name().to_string();
348 drop(agent_lock);
349 app.agent_selector.open(entries, ¤t);
350 }
351 InputAction::OpenSessionSelector => {
352 let agent_lock = agent.lock().await;
353 let current_id = agent_lock.conversation_id().to_string();
354 let sessions = agent_lock.list_sessions().unwrap_or_default();
355 drop(agent_lock);
356 let entries: Vec<SessionEntry> = sessions
357 .into_iter()
358 .map(|s| {
359 let title = if let Some(t) = &s.title {
360 t.clone()
361 } else if s.id == current_id {
362 app.conversation_title
363 .clone()
364 .unwrap_or_else(|| "new conversation".to_string())
365 } else {
366 "untitled".to_string()
367 };
368 SessionEntry {
369 id: s.id.clone(),
370 title,
371 subtitle: format!("{} ยท {}", time_ago(&s.updated_at), s.provider),
372 }
373 })
374 .collect();
375 app.session_selector.open(entries);
376 }
377 InputAction::ResumeSession { id } => {
378 let mut agent_lock = agent.lock().await;
379 match agent_lock.get_session(&id) {
380 Ok(conv) => {
381 let title = conv.title.clone();
382 let conv_model = conv.model.clone();
383 let messages_for_ui: Vec<(String, String)> = conv
384 .messages
385 .iter()
386 .map(|m| (m.role.clone(), m.content.clone()))
387 .collect();
388 match agent_lock.resume_conversation(&conv) {
389 Ok(()) => {
390 drop(agent_lock);
391 app.clear_conversation();
392 app.conversation_title = title;
393 for (role, content) in messages_for_ui {
394 let model = if role == "assistant" {
395 Some(conv_model.clone())
396 } else {
397 None
398 };
399 app.messages.push(ChatMessage {
400 role,
401 content,
402 tool_calls: Vec::new(),
403 thinking: None,
404 model,
405 });
406 }
407 app.scroll_to_bottom();
408 }
409 Err(e) => {
410 drop(agent_lock);
411 app.error_message = Some(format!("failed to resume session: {e}"));
412 }
413 }
414 }
415 Err(e) => {
416 drop(agent_lock);
417 app.error_message = Some(format!("session not found: {e}"));
418 }
419 }
420 }
421 InputAction::SelectModel { provider, model } => {
422 let mut agent_lock = agent.lock().await;
423 agent_lock.set_active_provider(&provider, &model);
424 let cw = agent_lock.context_window();
425 if cw > 0 {
426 app.context_window = cw;
427 } else {
428 app.context_window = agent_lock.fetch_context_window().await;
429 }
430 }
431 InputAction::SelectAgent { name } => {
432 let mut agent_lock = agent.lock().await;
433 agent_lock.switch_agent(&name);
434 app.model_name = agent_lock.current_model().to_string();
435 app.provider_name = agent_lock.current_provider_name().to_string();
436 let cw = agent_lock.context_window();
437 if cw > 0 {
438 app.context_window = cw;
439 } else {
440 app.context_window = agent_lock.fetch_context_window().await;
441 }
442 }
443 InputAction::ScrollUp(n) => app.scroll_up(n),
444 InputAction::ScrollDown(n) => app.scroll_down(n),
445 InputAction::ScrollToTop => app.scroll_to_top(),
446 InputAction::ScrollToBottom => app.scroll_to_bottom(),
447 InputAction::ClearConversation => app.clear_conversation(),
448 InputAction::ToggleThinking => {
449 app.thinking_expanded = !app.thinking_expanded;
450 }
451 InputAction::OpenThinkingSelector => {
452 let level = app.thinking_level();
453 app.thinking_selector.open(level);
454 }
455 InputAction::SetThinkingLevel(budget) => {
456 let mut agent_lock = agent.lock().await;
457 agent_lock.set_thinking_budget(budget);
458 }
459 InputAction::CycleThinkingLevel => {
460 let next = app.thinking_level().next();
461 let budget = next.budget_tokens();
462 app.thinking_budget = budget;
463 let mut agent_lock = agent.lock().await;
464 agent_lock.set_thinking_budget(budget);
465 }
466 InputAction::TruncateToMessage(idx) => {
467 app.messages.truncate(idx + 1);
468 app.current_response.clear();
469 app.current_thinking.clear();
470 app.current_tool_calls.clear();
471 app.scroll_to_bottom();
472 let mut agent_lock = agent.lock().await;
473 agent_lock.truncate_messages(idx + 1);
474 }
475 InputAction::ForkFromMessage(idx) => {
476 let fork_messages: Vec<(String, String, Option<String>)> = app.messages[..=idx]
477 .iter()
478 .map(|m| (m.role.clone(), m.content.clone(), m.model.clone()))
479 .collect();
480 let mut agent_lock = agent.lock().await;
481 match agent_lock.fork_conversation(idx + 1) {
482 Ok(()) => {
483 drop(agent_lock);
484 app.clear_conversation();
485 for (role, content, model) in fork_messages {
486 app.messages.push(app::ChatMessage {
487 role,
488 content,
489 tool_calls: Vec::new(),
490 thinking: None,
491 model,
492 });
493 }
494 app.scroll_to_bottom();
495 }
496 Err(e) => {
497 drop(agent_lock);
498 app.error_message = Some(format!("fork failed: {e}"));
499 }
500 }
501 }
502 InputAction::AnswerQuestion(answer) => {
503 app.messages.push(ChatMessage {
504 role: "user".to_string(),
505 content: answer,
506 tool_calls: Vec::new(),
507 thinking: None,
508 model: None,
509 });
510 app.scroll_to_bottom();
511 }
512 InputAction::AnswerPermission(_) | InputAction::None => {}
513 }
514 LoopSignal::Continue
515}
516
517async fn handle_event(
518 app: &mut App,
519 agent: &Arc<Mutex<Agent>>,
520 event: AppEvent,
521 agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
522) -> LoopSignal {
523 let action = match event {
524 AppEvent::Key(key) => input::handle_key(app, key),
525 AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
526 AppEvent::Paste(text) => input::handle_paste(app, text),
527 AppEvent::Tick => {
528 app.tick_count = app.tick_count.wrapping_add(1);
529 app.animate_scroll();
530 return LoopSignal::Continue;
531 }
532 AppEvent::Agent(ev) => {
533 app.handle_agent_event(ev);
534 return LoopSignal::Continue;
535 }
536 AppEvent::Resize(_, _) => return LoopSignal::Continue,
537 };
538 dispatch_action(app, agent, action, agent_rx).await
539}