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