1pub mod actions;
2pub mod app;
3pub mod event;
4pub mod input;
5pub mod markdown;
6pub mod theme;
7pub mod tools;
8pub mod ui;
9pub mod ui_popups;
10pub mod ui_tools;
11pub mod widgets;
12
13use std::sync::Arc;
14use std::time::Instant;
15
16use anyhow::Result;
17use crossterm::cursor::SetCursorStyle;
18use crossterm::{execute, terminal};
19use tokio::sync::{Mutex, mpsc};
20
21use crate::agent::{Agent, AgentProfile};
22use crate::command::CommandRegistry;
23use crate::config::{Config, CursorShape};
24use crate::db::Db;
25use crate::extension::HookRegistry;
26use crate::memory::MemoryStore;
27use crate::provider::Provider;
28use crate::tools::ToolRegistry;
29
30use app::{App, ChatMessage};
31use event::{AppEvent, EventHandler};
32
33pub struct ExitInfo {
34 pub conversation_id: String,
35 pub title: Option<String>,
36}
37
38fn cursor_style(shape: &CursorShape, blink: bool) -> SetCursorStyle {
39 match (shape, blink) {
40 (CursorShape::Block, true) => SetCursorStyle::BlinkingBlock,
41 (CursorShape::Block, false) => SetCursorStyle::SteadyBlock,
42 (CursorShape::Underline, true) => SetCursorStyle::BlinkingUnderScore,
43 (CursorShape::Underline, false) => SetCursorStyle::SteadyUnderScore,
44 (CursorShape::Line, true) => SetCursorStyle::BlinkingBar,
45 (CursorShape::Line, false) => SetCursorStyle::SteadyBar,
46 }
47}
48
49fn apply_cursor_style(app: &App) -> Result<()> {
50 let (shape, blink) = if app.vim_mode && app.mode == app::AppMode::Normal {
51 let s = app
52 .cursor_shape_normal
53 .as_ref()
54 .unwrap_or(&app.cursor_shape);
55 let b = app.cursor_blink_normal.unwrap_or(app.cursor_blink);
56 (s, b)
57 } else {
58 (&app.cursor_shape, app.cursor_blink)
59 };
60 execute!(std::io::stderr(), cursor_style(shape, blink))?;
61 Ok(())
62}
63
64#[allow(clippy::too_many_arguments)]
65pub async fn run(
66 config: Config,
67 providers: Vec<Box<dyn Provider>>,
68 db: Db,
69 memory: Option<Arc<MemoryStore>>,
70 tools: ToolRegistry,
71 profiles: Vec<AgentProfile>,
72 cwd: String,
73 resume_id: Option<String>,
74 skill_names: Vec<(String, String)>,
75 hooks: HookRegistry,
76 commands: CommandRegistry,
77 first_run: bool,
78) -> Result<()> {
79 terminal::enable_raw_mode()?;
80 let mut stdout = std::io::stderr();
81 execute!(
82 stdout,
83 terminal::EnterAlternateScreen,
84 crossterm::event::EnableMouseCapture,
85 crossterm::event::EnableBracketedPaste
86 )?;
87 let backend = ratatui::backend::CrosstermBackend::new(stdout);
88 let mut terminal = ratatui::Terminal::new(backend)?;
89
90 let result = run_app(
91 &mut terminal,
92 config,
93 providers,
94 db,
95 memory,
96 tools,
97 profiles,
98 cwd,
99 resume_id,
100 skill_names,
101 hooks,
102 commands,
103 first_run,
104 )
105 .await;
106
107 terminal::disable_raw_mode()?;
108 execute!(
109 std::io::stderr(),
110 terminal::LeaveAlternateScreen,
111 crossterm::event::DisableMouseCapture,
112 crossterm::event::DisableBracketedPaste
113 )?;
114 terminal.show_cursor()?;
115 execute!(std::io::stderr(), SetCursorStyle::DefaultUserShape)?;
116
117 if let Ok(ref info) = result {
118 print_exit_screen(info);
119 }
120
121 result.map(|_| ())
122}
123
124fn print_exit_screen(info: &ExitInfo) {
125 let title = info.title.as_deref().unwrap_or("untitled session");
126 let id = &info.conversation_id;
127 println!();
128 println!(" \x1b[2mSession\x1b[0m {}", title);
129 println!(" \x1b[2mResume\x1b[0m dot -s {}", id);
130 println!();
131}
132
133#[allow(clippy::too_many_arguments)]
134async fn run_app(
135 terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>,
136 config: Config,
137 providers: Vec<Box<dyn Provider>>,
138 db: Db,
139 memory: Option<Arc<MemoryStore>>,
140 tools: ToolRegistry,
141 profiles: Vec<AgentProfile>,
142 cwd: String,
143 resume_id: Option<String>,
144 skill_names: Vec<(String, String)>,
145 hooks: HookRegistry,
146 commands: CommandRegistry,
147 first_run: bool,
148) -> Result<ExitInfo> {
149 let model_name = providers[0].model().to_string();
150 let provider_name = providers[0].name().to_string();
151 let agent_name = profiles
152 .first()
153 .map(|p| p.name.clone())
154 .unwrap_or_else(|| "dot".to_string());
155
156 let history = db.get_user_message_history(500).unwrap_or_default();
157
158 let agents_context = crate::context::AgentsContext::load(&cwd, &config.context);
159 let (bg_tx, mut bg_rx) = mpsc::unbounded_channel();
160 let mut agent_inner = Agent::new(
161 providers,
162 db,
163 &config,
164 memory,
165 tools,
166 profiles,
167 cwd,
168 agents_context,
169 hooks,
170 commands,
171 )?;
172 agent_inner.set_background_tx(bg_tx);
173 let agent = Arc::new(Mutex::new(agent_inner));
174
175 if let Some(ref id) = resume_id {
176 let mut agent_lock = agent.lock().await;
177 match agent_lock.get_session(id) {
178 Ok(conv) => {
179 let _ = agent_lock.resume_conversation(&conv);
180 }
181 Err(e) => {
182 tracing::warn!("Failed to resume session {}: {}", id, e);
183 }
184 }
185 }
186
187 let mut app = App::new(
188 model_name,
189 provider_name,
190 agent_name,
191 &config.theme.name,
192 config.tui.vim_mode,
193 config.tui.cursor_shape.clone(),
194 config.tui.cursor_blink,
195 config.tui.cursor_shape_normal.clone(),
196 config.tui.cursor_blink_normal,
197 );
198 app.history = history;
199 app.favorite_models = config.tui.favorite_models.clone();
200 app.skill_entries = skill_names;
201
202 if first_run
203 || resume_id.is_none() && {
204 let creds = crate::auth::Credentials::load().unwrap_or_default();
205 let has_creds = !creds.providers.is_empty();
206 let has_env = std::env::var("ANTHROPIC_API_KEY")
207 .ok()
208 .filter(|k| !k.is_empty())
209 .is_some()
210 || std::env::var("OPENAI_API_KEY")
211 .ok()
212 .filter(|k| !k.is_empty())
213 .is_some();
214 !has_creds && !has_env
215 }
216 {
217 app.welcome_screen.open();
218 }
219 {
220 let agent_lock = agent.lock().await;
221 let cmds = agent_lock.list_commands();
222 app.custom_command_names = cmds.iter().map(|(n, _)| n.to_string()).collect();
223 app.command_palette.set_skills(&app.skill_entries);
224 app.command_palette.add_custom_commands(&cmds);
225 }
226
227 if let Some(ref id) = resume_id {
228 let agent_lock = agent.lock().await;
229 if let Ok(conv) = agent_lock.get_session(id) {
230 app.conversation_title = conv.title.clone();
231 for m in &conv.messages {
232 let model = if m.role == "assistant" {
233 Some(conv.model.clone())
234 } else {
235 None
236 };
237 app.messages.push(ChatMessage {
238 role: m.role.clone(),
239 content: m.content.clone(),
240 tool_calls: Vec::new(),
241 thinking: None,
242 model,
243 segments: None,
244 chips: None,
245 });
246 }
247 if !conv.messages.is_empty() {
248 let cw = agent_lock.context_window();
249 app.context_window = if cw > 0 {
250 cw
251 } else {
252 agent_lock.fetch_context_window().await
253 };
254 app.last_input_tokens = conv.last_input_tokens;
255 }
256 app.scroll_to_bottom();
257 }
258 drop(agent_lock);
259 }
260
261 {
262 let (tx, rx) = tokio::sync::oneshot::channel();
263 let agent_clone = Arc::clone(&agent);
264 tokio::spawn(async move {
265 let mut lock = agent_clone.lock().await;
266 let result = lock.fetch_all_models().await;
267 let provider = lock.current_provider_name().to_string();
268 let model = lock.current_model().to_string();
269 let _ = tx.send((result, provider, model));
270 });
271 app.model_fetch_rx = Some(rx);
272 }
273
274 let mut events = EventHandler::new();
275 let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
276 let mut agent_task: Option<tokio::task::JoinHandle<()>> = None;
277
278 loop {
279 terminal.draw(|f| ui::draw(f, &mut app))?;
280 apply_cursor_style(&app)?;
281
282 let event = if let Some(ref mut rx) = agent_rx {
283 tokio::select! {
284 biased;
285 agent_event = rx.recv() => {
286 match agent_event {
287 Some(ev) => {
288 app.handle_agent_event(ev);
289 }
290 None => {
291 if app.is_streaming {
292 app.is_streaming = false;
293 }
294 agent_rx = None;
295 if app.context_window == 0 {
296 let agent_lock = agent.lock().await;
297 let cw = agent_lock.context_window();
298 app.context_window = if cw > 0 {
299 cw
300 } else {
301 agent_lock.fetch_context_window().await
302 };
303 }
304 if let Some(queued) = app.message_queue.pop_front() {
305 let (tx, rx) = mpsc::unbounded_channel();
306 agent_rx = Some(rx);
307 app.is_streaming = true;
308 app.streaming_started = Some(Instant::now());
309 app.current_response.clear();
310 app.current_thinking.clear();
311 app.current_tool_calls.clear();
312 app.streaming_segments.clear();
313 app.status_message = None;
314 let agent_clone = Arc::clone(&agent);
315 agent_task = Some(tokio::spawn(async move {
316 let mut agent = agent_clone.lock().await;
317 let result = if queued.images.is_empty() {
318 agent.send_message(&queued.text, tx).await
319 } else {
320 agent.send_message_with_images(&queued.text, queued.images, tx).await
321 };
322 if let Err(e) = result {
323 tracing::error!("Agent send_message error: {}", e);
324 }
325 }));
326 }
327 }
328 }
329 continue;
330 }
331 bg_event = bg_rx.recv() => {
332 if let Some(ev) = bg_event {
333 app.handle_agent_event(ev);
334 }
335 continue;
336 }
337 ui_event = events.next() => {
338 match ui_event {
339 Some(ev) => ev,
340 None => break,
341 }
342 }
343 }
344 } else {
345 tokio::select! {
346 biased;
347 bg_event = bg_rx.recv() => {
348 if let Some(ev) = bg_event {
349 app.handle_agent_event(ev);
350 }
351 continue;
352 }
353 ui_event = events.next() => {
354 match ui_event {
355 Some(ev) => ev,
356 None => break,
357 }
358 }
359 }
360 };
361
362 match handle_event(&mut app, &agent, event, &mut agent_rx, &mut agent_task).await {
363 actions::LoopSignal::Quit => break,
364 actions::LoopSignal::OpenEditor => {
365 let editor = std::env::var("VISUAL")
366 .or_else(|_| std::env::var("EDITOR"))
367 .unwrap_or_else(|_| "vi".to_string());
368 let tmp = std::env::temp_dir().join("dot_input.md");
369 let _ = std::fs::write(&tmp, &app.input);
370 terminal::disable_raw_mode()?;
371 execute!(
372 std::io::stderr(),
373 terminal::LeaveAlternateScreen,
374 crossterm::event::DisableMouseCapture
375 )?;
376 let status = std::process::Command::new(&editor).arg(&tmp).status();
377 execute!(
378 std::io::stderr(),
379 terminal::EnterAlternateScreen,
380 crossterm::event::EnableMouseCapture
381 )?;
382 terminal::enable_raw_mode()?;
383 terminal.clear()?;
384 if status.is_ok()
385 && let Ok(contents) = std::fs::read_to_string(&tmp)
386 {
387 let trimmed = contents.trim_end().to_string();
388 if !trimmed.is_empty() {
389 app.cursor_pos = trimmed.len();
390 app.input = trimmed;
391 }
392 }
393 let _ = std::fs::remove_file(&tmp);
394 }
395 _ => {}
396 }
397 }
398
399 let mut agent_lock = agent.lock().await;
400 {
401 let event = crate::extension::Event::BeforeExit;
402 let ctx = crate::extension::EventContext {
403 event: event.as_str().to_string(),
404 cwd: agent_lock.cwd().to_string(),
405 session_id: agent_lock.conversation_id().to_string(),
406 ..Default::default()
407 };
408 agent_lock.hooks().emit(&event, &ctx);
409 }
410 let conversation_id = agent_lock.conversation_id().to_string();
411 let title = agent_lock.conversation_title();
412 agent_lock.cleanup_if_empty();
413 drop(agent_lock);
414
415 Ok(ExitInfo {
416 conversation_id,
417 title,
418 })
419}
420
421async fn handle_event(
422 app: &mut App,
423 agent: &Arc<Mutex<Agent>>,
424 event: AppEvent,
425 agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
426 agent_task: &mut Option<tokio::task::JoinHandle<()>>,
427) -> actions::LoopSignal {
428 let action = match event {
429 AppEvent::Key(key) => input::handle_key(app, key),
430 AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
431 AppEvent::Paste(text) => input::handle_paste(app, text),
432 AppEvent::Tick => {
433 app.tick_count = app.tick_count.wrapping_add(1);
434 if app.status_message.as_ref().is_some_and(|s| s.expired()) {
435 app.status_message = None;
436 app.mark_dirty();
437 }
438 if let Some(mut rx) = app.model_fetch_rx.take() {
439 match rx.try_recv() {
440 Ok((grouped, provider, model)) => {
441 app.cached_model_groups = Some(grouped.clone());
442 if app.model_selector.visible {
443 app.model_selector.favorites = app.favorite_models.clone();
444 app.model_selector.open(grouped, &provider, &model);
445 }
446 app.mark_dirty();
447 }
448 Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
449 app.model_fetch_rx = Some(rx);
450 }
451 Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {}
452 }
453 }
454 return actions::LoopSignal::Continue;
455 }
456 AppEvent::Agent(ev) => {
457 app.handle_agent_event(ev);
458 return actions::LoopSignal::Continue;
459 }
460 AppEvent::Resize(_, _) => return actions::LoopSignal::Continue,
461 };
462 actions::dispatch_action(app, agent, action, agent_rx, agent_task).await
463}
464
465pub async fn run_acp(config: crate::config::Config, client: crate::acp::AcpClient) -> Result<()> {
466 terminal::enable_raw_mode()?;
467 let mut stdout = std::io::stderr();
468 execute!(
469 stdout,
470 terminal::EnterAlternateScreen,
471 crossterm::event::EnableMouseCapture,
472 crossterm::event::EnableBracketedPaste
473 )?;
474 let backend = ratatui::backend::CrosstermBackend::new(stdout);
475 let mut terminal = ratatui::Terminal::new(backend)?;
476
477 let agent_name = client
478 .agent_info()
479 .map(|i| i.name.clone())
480 .unwrap_or_else(|| "acp".into());
481 let model_name = client.current_mode().unwrap_or("acp").to_string();
482 let provider_name = agent_name.clone();
483
484 let mut app = app::App::new(
485 model_name,
486 provider_name,
487 agent_name,
488 &config.theme.name,
489 config.tui.vim_mode,
490 config.tui.cursor_shape.clone(),
491 config.tui.cursor_blink,
492 config.tui.cursor_shape_normal.clone(),
493 config.tui.cursor_blink_normal,
494 );
495
496 let acp = Arc::new(Mutex::new(client));
497 let mut events = EventHandler::new();
498 let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
499 let mut agent_task: Option<tokio::task::JoinHandle<()>> = None;
500
501 loop {
502 terminal.draw(|f| ui::draw(f, &mut app))?;
503 apply_cursor_style(&app)?;
504
505 let event = if let Some(ref mut rx) = agent_rx {
506 tokio::select! {
507 biased;
508 agent_event = rx.recv() => {
509 match agent_event {
510 Some(ev) => {
511 app.handle_agent_event(ev);
512 }
513 None => {
514 if app.is_streaming {
515 app.is_streaming = false;
516 }
517 agent_rx = None;
518 }
519 }
520 continue;
521 }
522 ui_event = events.next() => {
523 match ui_event {
524 Some(ev) => ev,
525 None => break,
526 }
527 }
528 }
529 } else {
530 match events.next().await {
531 Some(ev) => ev,
532 None => break,
533 }
534 };
535
536 match handle_acp_event(&mut app, &acp, event, &mut agent_rx, &mut agent_task).await {
537 actions::LoopSignal::Quit => break,
538 actions::LoopSignal::OpenEditor => {
539 let editor = std::env::var("VISUAL")
540 .or_else(|_| std::env::var("EDITOR"))
541 .unwrap_or_else(|_| "vi".to_string());
542 let tmp = std::env::temp_dir().join("dot_input.md");
543 let _ = std::fs::write(&tmp, &app.input);
544 terminal::disable_raw_mode()?;
545 execute!(
546 std::io::stderr(),
547 terminal::LeaveAlternateScreen,
548 crossterm::event::DisableMouseCapture
549 )?;
550 let status = std::process::Command::new(&editor).arg(&tmp).status();
551 execute!(
552 std::io::stderr(),
553 terminal::EnterAlternateScreen,
554 crossterm::event::EnableMouseCapture
555 )?;
556 terminal::enable_raw_mode()?;
557 terminal.clear()?;
558 if status.is_ok()
559 && let Ok(contents) = std::fs::read_to_string(&tmp)
560 {
561 let trimmed = contents.trim_end().to_string();
562 if !trimmed.is_empty() {
563 app.cursor_pos = trimmed.len();
564 app.input = trimmed;
565 }
566 }
567 let _ = std::fs::remove_file(&tmp);
568 }
569 _ => {}
570 }
571 }
572
573 if let Ok(mut c) = acp.try_lock() {
574 let _ = c.kill();
575 }
576
577 terminal::disable_raw_mode()?;
578 execute!(
579 std::io::stderr(),
580 terminal::LeaveAlternateScreen,
581 crossterm::event::DisableMouseCapture,
582 crossterm::event::DisableBracketedPaste
583 )?;
584 terminal.show_cursor()?;
585 execute!(std::io::stderr(), SetCursorStyle::DefaultUserShape)?;
586
587 Ok(())
588}
589
590async fn handle_acp_event(
591 app: &mut app::App,
592 acp: &Arc<Mutex<crate::acp::AcpClient>>,
593 event: AppEvent,
594 agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
595 agent_task: &mut Option<tokio::task::JoinHandle<()>>,
596) -> actions::LoopSignal {
597 let action = match event {
598 AppEvent::Key(key) => input::handle_key(app, key),
599 AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
600 AppEvent::Paste(text) => input::handle_paste(app, text),
601 AppEvent::Tick => {
602 app.tick_count = app.tick_count.wrapping_add(1);
603 if app.status_message.as_ref().is_some_and(|s| s.expired()) {
604 app.status_message = None;
605 app.mark_dirty();
606 }
607 return actions::LoopSignal::Continue;
608 }
609 AppEvent::Agent(ev) => {
610 app.handle_agent_event(ev);
611 return actions::LoopSignal::Continue;
612 }
613 AppEvent::Resize(_, _) => return actions::LoopSignal::Continue,
614 };
615 actions::dispatch_acp_action(app, acp, action, agent_rx, agent_task).await
616}