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