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::{execute, terminal};
18use tokio::sync::{Mutex, mpsc};
19
20use crate::agent::{Agent, AgentProfile};
21use crate::command::CommandRegistry;
22use crate::config::Config;
23use crate::db::Db;
24use crate::extension::HookRegistry;
25use crate::memory::MemoryStore;
26use crate::provider::Provider;
27use crate::tools::ToolRegistry;
28
29use app::{App, ChatMessage};
30use event::{AppEvent, EventHandler};
31
32pub struct ExitInfo {
33 pub conversation_id: String,
34 pub title: Option<String>,
35}
36
37#[allow(clippy::too_many_arguments)]
38pub async fn run(
39 config: Config,
40 providers: Vec<Box<dyn Provider>>,
41 db: Db,
42 memory: Option<Arc<MemoryStore>>,
43 tools: ToolRegistry,
44 profiles: Vec<AgentProfile>,
45 cwd: String,
46 resume_id: Option<String>,
47 skill_names: Vec<(String, String)>,
48 hooks: HookRegistry,
49 commands: CommandRegistry,
50) -> Result<()> {
51 terminal::enable_raw_mode()?;
52 let mut stdout = std::io::stderr();
53 execute!(
54 stdout,
55 terminal::EnterAlternateScreen,
56 crossterm::event::EnableMouseCapture,
57 crossterm::event::EnableBracketedPaste
58 )?;
59 let backend = ratatui::backend::CrosstermBackend::new(stdout);
60 let mut terminal = ratatui::Terminal::new(backend)?;
61
62 let result = run_app(
63 &mut terminal,
64 config,
65 providers,
66 db,
67 memory,
68 tools,
69 profiles,
70 cwd,
71 resume_id,
72 skill_names,
73 hooks,
74 commands,
75 )
76 .await;
77
78 terminal::disable_raw_mode()?;
79 execute!(
80 std::io::stderr(),
81 terminal::LeaveAlternateScreen,
82 crossterm::event::DisableMouseCapture,
83 crossterm::event::DisableBracketedPaste
84 )?;
85 terminal.show_cursor()?;
86
87 if let Ok(ref info) = result {
88 print_exit_screen(info);
89 }
90
91 result.map(|_| ())
92}
93
94fn print_exit_screen(info: &ExitInfo) {
95 let title = info.title.as_deref().unwrap_or("untitled session");
96 let id = &info.conversation_id;
97 println!();
98 println!(" \x1b[2mSession\x1b[0m {}", title);
99 println!(" \x1b[2mResume\x1b[0m dot -s {}", id);
100 println!();
101}
102
103#[allow(clippy::too_many_arguments)]
104async fn run_app(
105 terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>,
106 config: Config,
107 providers: Vec<Box<dyn Provider>>,
108 db: Db,
109 memory: Option<Arc<MemoryStore>>,
110 tools: ToolRegistry,
111 profiles: Vec<AgentProfile>,
112 cwd: String,
113 resume_id: Option<String>,
114 skill_names: Vec<(String, String)>,
115 hooks: HookRegistry,
116 commands: CommandRegistry,
117) -> Result<ExitInfo> {
118 let model_name = providers[0].model().to_string();
119 let provider_name = providers[0].name().to_string();
120 let agent_name = profiles
121 .first()
122 .map(|p| p.name.clone())
123 .unwrap_or_else(|| "dot".to_string());
124
125 let history = db.get_user_message_history(500).unwrap_or_default();
126
127 let agents_context = crate::context::AgentsContext::load(&cwd, &config.context);
128 let (bg_tx, mut bg_rx) = mpsc::unbounded_channel();
129 let mut agent_inner = Agent::new(
130 providers,
131 db,
132 &config,
133 memory,
134 tools,
135 profiles,
136 cwd,
137 agents_context,
138 hooks,
139 commands,
140 )?;
141 agent_inner.set_background_tx(bg_tx);
142 let agent = Arc::new(Mutex::new(agent_inner));
143
144 if let Some(ref id) = resume_id {
145 let mut agent_lock = agent.lock().await;
146 match agent_lock.get_session(id) {
147 Ok(conv) => {
148 let _ = agent_lock.resume_conversation(&conv);
149 }
150 Err(e) => {
151 tracing::warn!("Failed to resume session {}: {}", id, e);
152 }
153 }
154 }
155
156 let mut app = App::new(
157 model_name,
158 provider_name,
159 agent_name,
160 &config.theme.name,
161 config.tui.vim_mode,
162 );
163 app.history = history;
164 app.favorite_models = config.tui.favorite_models.clone();
165 app.skill_entries = skill_names;
166 {
167 let agent_lock = agent.lock().await;
168 let cmds = agent_lock.list_commands();
169 app.custom_command_names = cmds.iter().map(|(n, _)| n.to_string()).collect();
170 app.command_palette.set_skills(&app.skill_entries);
171 app.command_palette.add_custom_commands(&cmds);
172 }
173
174 if let Some(ref id) = resume_id {
175 let agent_lock = agent.lock().await;
176 if let Ok(conv) = agent_lock.get_session(id) {
177 app.conversation_title = conv.title.clone();
178 for m in &conv.messages {
179 let model = if m.role == "assistant" {
180 Some(conv.model.clone())
181 } else {
182 None
183 };
184 app.messages.push(ChatMessage {
185 role: m.role.clone(),
186 content: m.content.clone(),
187 tool_calls: Vec::new(),
188 thinking: None,
189 model,
190 segments: None,
191 });
192 }
193 if !conv.messages.is_empty() {
194 let cw = agent_lock.context_window();
195 app.context_window = if cw > 0 {
196 cw
197 } else {
198 agent_lock.fetch_context_window().await
199 };
200 app.last_input_tokens = conv.last_input_tokens;
201 }
202 app.scroll_to_bottom();
203 }
204 drop(agent_lock);
205 }
206
207 let mut events = EventHandler::new();
208 let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
209 let mut agent_task: Option<tokio::task::JoinHandle<()>> = None;
210
211 loop {
212 terminal.draw(|f| ui::draw(f, &mut app))?;
213
214 let event = if let Some(ref mut rx) = agent_rx {
215 tokio::select! {
216 biased;
217 agent_event = rx.recv() => {
218 match agent_event {
219 Some(ev) => {
220 app.handle_agent_event(ev);
221 }
222 None => {
223 if app.is_streaming {
224 app.is_streaming = false;
225 }
226 agent_rx = None;
227 if app.context_window == 0 {
228 let agent_lock = agent.lock().await;
229 let cw = agent_lock.context_window();
230 app.context_window = if cw > 0 {
231 cw
232 } else {
233 agent_lock.fetch_context_window().await
234 };
235 }
236 if let Some(queued) = app.message_queue.pop_front() {
237 let (tx, rx) = mpsc::unbounded_channel();
238 agent_rx = Some(rx);
239 app.is_streaming = true;
240 app.streaming_started = Some(Instant::now());
241 app.current_response.clear();
242 app.current_thinking.clear();
243 app.current_tool_calls.clear();
244 app.streaming_segments.clear();
245 app.status_message = None;
246 let agent_clone = Arc::clone(&agent);
247 agent_task = Some(tokio::spawn(async move {
248 let mut agent = agent_clone.lock().await;
249 let result = if queued.images.is_empty() {
250 agent.send_message(&queued.text, tx).await
251 } else {
252 agent.send_message_with_images(&queued.text, queued.images, tx).await
253 };
254 if let Err(e) = result {
255 tracing::error!("Agent send_message error: {}", e);
256 }
257 }));
258 }
259 }
260 }
261 continue;
262 }
263 bg_event = bg_rx.recv() => {
264 if let Some(ev) = bg_event {
265 app.handle_agent_event(ev);
266 }
267 continue;
268 }
269 ui_event = events.next() => {
270 match ui_event {
271 Some(ev) => ev,
272 None => break,
273 }
274 }
275 }
276 } else {
277 tokio::select! {
278 biased;
279 bg_event = bg_rx.recv() => {
280 if let Some(ev) = bg_event {
281 app.handle_agent_event(ev);
282 }
283 continue;
284 }
285 ui_event = events.next() => {
286 match ui_event {
287 Some(ev) => ev,
288 None => break,
289 }
290 }
291 }
292 };
293
294 match handle_event(&mut app, &agent, event, &mut agent_rx, &mut agent_task).await {
295 actions::LoopSignal::Quit => break,
296 actions::LoopSignal::OpenEditor => {
297 let editor = std::env::var("VISUAL")
298 .or_else(|_| std::env::var("EDITOR"))
299 .unwrap_or_else(|_| "vi".to_string());
300 let tmp = std::env::temp_dir().join("dot_input.md");
301 let _ = std::fs::write(&tmp, &app.input);
302 terminal::disable_raw_mode()?;
303 execute!(
304 std::io::stderr(),
305 terminal::LeaveAlternateScreen,
306 crossterm::event::DisableMouseCapture
307 )?;
308 let status = std::process::Command::new(&editor).arg(&tmp).status();
309 execute!(
310 std::io::stderr(),
311 terminal::EnterAlternateScreen,
312 crossterm::event::EnableMouseCapture
313 )?;
314 terminal::enable_raw_mode()?;
315 terminal.clear()?;
316 if status.is_ok()
317 && let Ok(contents) = std::fs::read_to_string(&tmp)
318 {
319 let trimmed = contents.trim_end().to_string();
320 if !trimmed.is_empty() {
321 app.cursor_pos = trimmed.len();
322 app.input = trimmed;
323 }
324 }
325 let _ = std::fs::remove_file(&tmp);
326 }
327 _ => {}
328 }
329 }
330
331 let mut agent_lock = agent.lock().await;
332 {
333 let event = crate::extension::Event::BeforeExit;
334 let ctx = crate::extension::EventContext {
335 event: event.as_str().to_string(),
336 cwd: agent_lock.cwd().to_string(),
337 session_id: agent_lock.conversation_id().to_string(),
338 ..Default::default()
339 };
340 agent_lock.hooks().emit(&event, &ctx);
341 }
342 let conversation_id = agent_lock.conversation_id().to_string();
343 let title = agent_lock.conversation_title();
344 agent_lock.cleanup_if_empty();
345 drop(agent_lock);
346
347 Ok(ExitInfo {
348 conversation_id,
349 title,
350 })
351}
352
353async fn handle_event(
354 app: &mut App,
355 agent: &Arc<Mutex<Agent>>,
356 event: AppEvent,
357 agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
358 agent_task: &mut Option<tokio::task::JoinHandle<()>>,
359) -> actions::LoopSignal {
360 let action = match event {
361 AppEvent::Key(key) => input::handle_key(app, key),
362 AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
363 AppEvent::Paste(text) => input::handle_paste(app, text),
364 AppEvent::Tick => {
365 app.tick_count = app.tick_count.wrapping_add(1);
366 if app.status_message.as_ref().is_some_and(|s| s.expired()) {
367 app.status_message = None;
368 app.mark_dirty();
369 }
370 return actions::LoopSignal::Continue;
371 }
372 AppEvent::Agent(ev) => {
373 app.handle_agent_event(ev);
374 return actions::LoopSignal::Continue;
375 }
376 AppEvent::Resize(_, _) => return actions::LoopSignal::Continue,
377 };
378 actions::dispatch_action(app, agent, action, agent_rx, agent_task).await
379}