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 let db_tool_calls = agent_lock.get_tool_calls(&m.id).unwrap_or_default();
238 let tool_calls: Vec<crate::tui::tools::ToolCallDisplay> = db_tool_calls
239 .into_iter()
240 .map(|tc| {
241 let category = crate::tui::tools::ToolCategory::from_name(&tc.name);
242 let detail = crate::tui::tools::extract_tool_detail(&tc.name, &tc.input);
243 crate::tui::tools::ToolCallDisplay {
244 name: tc.name,
245 input: tc.input,
246 output: tc.output,
247 is_error: tc.is_error,
248 category,
249 detail,
250 }
251 })
252 .collect();
253 let has_tools = !tool_calls.is_empty();
254 let clean_content = if has_tools {
255 let trimmed = m.content.replace("[tool use]", "").trim().to_string();
256 trimmed
257 } else {
258 m.content.clone()
259 };
260 let segments = if has_tools {
261 let mut segs = Vec::new();
262 if !clean_content.is_empty() {
263 segs.push(crate::tui::tools::StreamSegment::Text(
264 clean_content.clone(),
265 ));
266 }
267 for tc in &tool_calls {
268 segs.push(crate::tui::tools::StreamSegment::ToolCall(tc.clone()));
269 }
270 Some(segs)
271 } else {
272 None
273 };
274 app.messages.push(ChatMessage {
275 role: m.role.clone(),
276 content: clean_content,
277 tool_calls,
278 thinking: None,
279 model,
280 segments,
281 chips: None,
282 });
283 }
284 if !conv.messages.is_empty() {
285 let cw = agent_lock.context_window();
286 app.context_window = if cw > 0 {
287 cw
288 } else {
289 agent_lock.fetch_context_window().await
290 };
291 app.last_input_tokens = conv.last_input_tokens;
292 }
293 app.scroll_to_bottom();
294 }
295 drop(agent_lock);
296 }
297
298 {
299 let (tx, rx) = tokio::sync::oneshot::channel();
300 let agent_clone = Arc::clone(&agent);
301 tokio::spawn(async move {
302 let mut lock = agent_clone.lock().await;
303 let result = lock.fetch_all_models().await;
304 let provider = lock.current_provider_name().to_string();
305 let model = lock.current_model().to_string();
306 let _ = tx.send((result, provider, model));
307 });
308 app.model_fetch_rx = Some(rx);
309 }
310
311 let mut events = EventHandler::new();
312 let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
313 let mut agent_task: Option<tokio::task::JoinHandle<()>> = None;
314
315 loop {
316 terminal.draw(|f| ui::draw(f, &mut app))?;
317 apply_cursor_style(&app)?;
318
319 let event = if let Some(ref mut rx) = agent_rx {
320 tokio::select! {
321 biased;
322 agent_event = rx.recv() => {
323 match agent_event {
324 Some(ev) => {
325 app.handle_agent_event(ev);
326 }
327 None => {
328 if app.is_streaming {
329 app.is_streaming = false;
330 }
331 agent_rx = None;
332 if app.context_window == 0 {
333 let agent_lock = agent.lock().await;
334 let cw = agent_lock.context_window();
335 app.context_window = if cw > 0 {
336 cw
337 } else {
338 agent_lock.fetch_context_window().await
339 };
340 }
341 if let Some(queued) = app.message_queue.pop_front() {
342 let (tx, rx) = mpsc::unbounded_channel();
343 agent_rx = Some(rx);
344 app.is_streaming = true;
345 app.streaming_started = Some(Instant::now());
346 app.current_response.clear();
347 app.current_thinking.clear();
348 app.current_tool_calls.clear();
349 app.streaming_segments.clear();
350 app.status_message = None;
351 let agent_clone = Arc::clone(&agent);
352 agent_task = Some(tokio::spawn(async move {
353 let mut agent = agent_clone.lock().await;
354 let result = if queued.images.is_empty() {
355 agent.send_message(&queued.text, tx).await
356 } else {
357 agent.send_message_with_images(&queued.text, queued.images, tx).await
358 };
359 if let Err(e) = result {
360 tracing::error!("Agent send_message error: {}", e);
361 }
362 }));
363 }
364 }
365 }
366 continue;
367 }
368 bg_event = bg_rx.recv() => {
369 if let Some(ev) = bg_event {
370 app.handle_agent_event(ev);
371 }
372 continue;
373 }
374 ui_event = events.next() => {
375 match ui_event {
376 Some(ev) => ev,
377 None => break,
378 }
379 }
380 }
381 } else {
382 tokio::select! {
383 biased;
384 bg_event = bg_rx.recv() => {
385 if let Some(ev) = bg_event {
386 app.handle_agent_event(ev);
387 }
388 continue;
389 }
390 ui_event = events.next() => {
391 match ui_event {
392 Some(ev) => ev,
393 None => break,
394 }
395 }
396 }
397 };
398
399 match handle_event(&mut app, &agent, event, &mut agent_rx, &mut agent_task).await {
400 actions::LoopSignal::Quit => break,
401 actions::LoopSignal::OpenEditor => {
402 let editor = std::env::var("VISUAL")
403 .or_else(|_| std::env::var("EDITOR"))
404 .unwrap_or_else(|_| "vi".to_string());
405 let tmp = std::env::temp_dir().join("dot_input.md");
406 let _ = std::fs::write(&tmp, &app.input);
407 terminal::disable_raw_mode()?;
408 execute!(
409 std::io::stderr(),
410 terminal::LeaveAlternateScreen,
411 crossterm::event::DisableMouseCapture
412 )?;
413 let status = std::process::Command::new(&editor).arg(&tmp).status();
414 execute!(
415 std::io::stderr(),
416 terminal::EnterAlternateScreen,
417 crossterm::event::EnableMouseCapture
418 )?;
419 terminal::enable_raw_mode()?;
420 terminal.clear()?;
421 if status.is_ok()
422 && let Ok(contents) = std::fs::read_to_string(&tmp)
423 {
424 let trimmed = contents.trim_end().to_string();
425 if !trimmed.is_empty() {
426 app.cursor_pos = trimmed.len();
427 app.input = trimmed;
428 }
429 }
430 let _ = std::fs::remove_file(&tmp);
431 }
432 _ => {}
433 }
434 }
435
436 let mut agent_lock = agent.lock().await;
437 {
438 let event = crate::extension::Event::BeforeExit;
439 let ctx = crate::extension::EventContext {
440 event: event.as_str().to_string(),
441 cwd: agent_lock.cwd().to_string(),
442 session_id: agent_lock.conversation_id().to_string(),
443 ..Default::default()
444 };
445 agent_lock.hooks().emit(&event, &ctx);
446 }
447 let conversation_id = agent_lock.conversation_id().to_string();
448 let title = agent_lock.conversation_title();
449 agent_lock.cleanup_if_empty();
450 drop(agent_lock);
451
452 Ok(ExitInfo {
453 conversation_id,
454 title,
455 })
456}
457
458async fn handle_event(
459 app: &mut App,
460 agent: &Arc<Mutex<Agent>>,
461 event: AppEvent,
462 agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
463 agent_task: &mut Option<tokio::task::JoinHandle<()>>,
464) -> actions::LoopSignal {
465 let action = match event {
466 AppEvent::Key(key) => input::handle_key(app, key),
467 AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
468 AppEvent::Paste(text) => input::handle_paste(app, text),
469 AppEvent::Tick => {
470 app.tick_count = app.tick_count.wrapping_add(1);
471 if let Some(at) = app.thinking_collapse_at {
472 if std::time::Instant::now() >= at {
473 app.thinking_expanded = false;
474 app.auto_opened_thinking = false;
475 app.thinking_collapse_at = None;
476 app.mark_dirty();
477 }
478 }
479 if app.status_message.as_ref().is_some_and(|s| s.expired()) {
480 app.status_message = None;
481 app.mark_dirty();
482 }
483 if let Some(mut rx) = app.model_fetch_rx.take() {
484 match rx.try_recv() {
485 Ok((grouped, provider, model)) => {
486 app.cached_model_groups = Some(grouped.clone());
487 if app.model_selector.visible {
488 app.model_selector.favorites = app.favorite_models.clone();
489 app.model_selector.open(grouped, &provider, &model);
490 }
491 app.mark_dirty();
492 }
493 Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
494 app.model_fetch_rx = Some(rx);
495 }
496 Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {}
497 }
498 }
499 return actions::LoopSignal::Continue;
500 }
501 AppEvent::Agent(ev) => {
502 app.handle_agent_event(ev);
503 return actions::LoopSignal::Continue;
504 }
505 AppEvent::Resize(_, _) => return actions::LoopSignal::Continue,
506 };
507 actions::dispatch_action(app, agent, action, agent_rx, agent_task).await
508}
509
510pub async fn run_acp(config: crate::config::Config, client: crate::acp::AcpClient) -> Result<()> {
511 terminal::enable_raw_mode()?;
512 let mut stdout = std::io::stderr();
513 execute!(
514 stdout,
515 terminal::EnterAlternateScreen,
516 crossterm::event::EnableMouseCapture,
517 crossterm::event::EnableBracketedPaste
518 )?;
519 let backend = ratatui::backend::CrosstermBackend::new(stdout);
520 let mut terminal = ratatui::Terminal::new(backend)?;
521
522 let agent_name = client
523 .agent_info()
524 .map(|i| i.name.clone())
525 .unwrap_or_else(|| "acp".into());
526 let model_name = client.current_mode().unwrap_or("acp").to_string();
527 let provider_name = agent_name.clone();
528
529 let mut app = app::App::new(
530 model_name,
531 provider_name,
532 agent_name,
533 &config.theme.name,
534 config.tui.vim_mode,
535 config.tui.cursor_shape.clone(),
536 config.tui.cursor_blink,
537 config.tui.cursor_shape_normal.clone(),
538 config.tui.cursor_blink_normal,
539 );
540
541 let acp = Arc::new(Mutex::new(client));
542 let mut events = EventHandler::new();
543 let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
544 let mut agent_task: Option<tokio::task::JoinHandle<()>> = None;
545
546 loop {
547 terminal.draw(|f| ui::draw(f, &mut app))?;
548 apply_cursor_style(&app)?;
549
550 let event = if let Some(ref mut rx) = agent_rx {
551 tokio::select! {
552 biased;
553 agent_event = rx.recv() => {
554 match agent_event {
555 Some(ev) => {
556 app.handle_agent_event(ev);
557 }
558 None => {
559 if app.is_streaming {
560 app.is_streaming = false;
561 }
562 agent_rx = None;
563 }
564 }
565 continue;
566 }
567 ui_event = events.next() => {
568 match ui_event {
569 Some(ev) => ev,
570 None => break,
571 }
572 }
573 }
574 } else {
575 match events.next().await {
576 Some(ev) => ev,
577 None => break,
578 }
579 };
580
581 match handle_acp_event(&mut app, &acp, event, &mut agent_rx, &mut agent_task).await {
582 actions::LoopSignal::Quit => break,
583 actions::LoopSignal::OpenEditor => {
584 let editor = std::env::var("VISUAL")
585 .or_else(|_| std::env::var("EDITOR"))
586 .unwrap_or_else(|_| "vi".to_string());
587 let tmp = std::env::temp_dir().join("dot_input.md");
588 let _ = std::fs::write(&tmp, &app.input);
589 terminal::disable_raw_mode()?;
590 execute!(
591 std::io::stderr(),
592 terminal::LeaveAlternateScreen,
593 crossterm::event::DisableMouseCapture
594 )?;
595 let status = std::process::Command::new(&editor).arg(&tmp).status();
596 execute!(
597 std::io::stderr(),
598 terminal::EnterAlternateScreen,
599 crossterm::event::EnableMouseCapture
600 )?;
601 terminal::enable_raw_mode()?;
602 terminal.clear()?;
603 if status.is_ok()
604 && let Ok(contents) = std::fs::read_to_string(&tmp)
605 {
606 let trimmed = contents.trim_end().to_string();
607 if !trimmed.is_empty() {
608 app.cursor_pos = trimmed.len();
609 app.input = trimmed;
610 }
611 }
612 let _ = std::fs::remove_file(&tmp);
613 }
614 _ => {}
615 }
616 }
617
618 if let Ok(mut c) = acp.try_lock() {
619 let _ = c.kill();
620 }
621
622 terminal::disable_raw_mode()?;
623 execute!(
624 std::io::stderr(),
625 terminal::LeaveAlternateScreen,
626 crossterm::event::DisableMouseCapture,
627 crossterm::event::DisableBracketedPaste
628 )?;
629 terminal.show_cursor()?;
630 execute!(std::io::stderr(), SetCursorStyle::DefaultUserShape)?;
631
632 Ok(())
633}
634
635async fn handle_acp_event(
636 app: &mut app::App,
637 acp: &Arc<Mutex<crate::acp::AcpClient>>,
638 event: AppEvent,
639 agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
640 agent_task: &mut Option<tokio::task::JoinHandle<()>>,
641) -> actions::LoopSignal {
642 let action = match event {
643 AppEvent::Key(key) => input::handle_key(app, key),
644 AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
645 AppEvent::Paste(text) => input::handle_paste(app, text),
646 AppEvent::Tick => {
647 app.tick_count = app.tick_count.wrapping_add(1);
648 if app.status_message.as_ref().is_some_and(|s| s.expired()) {
649 app.status_message = None;
650 app.mark_dirty();
651 }
652 return actions::LoopSignal::Continue;
653 }
654 AppEvent::Agent(ev) => {
655 app.handle_agent_event(ev);
656 return actions::LoopSignal::Continue;
657 }
658 AppEvent::Resize(_, _) => return actions::LoopSignal::Continue,
659 };
660 actions::dispatch_acp_action(app, acp, action, agent_rx, agent_task).await
661}