Skip to main content

dot/tui/
mod.rs

1pub mod app;
2pub mod event;
3pub mod input;
4pub mod markdown;
5pub mod theme;
6pub mod tools;
7pub mod ui;
8pub mod ui_popups;
9pub mod ui_tools;
10pub mod widgets;
11
12use std::sync::Arc;
13use std::time::Instant;
14
15use anyhow::Result;
16use crossterm::{execute, terminal};
17use tokio::sync::{Mutex, mpsc};
18
19use crate::agent::{Agent, AgentProfile};
20use crate::config::Config;
21use crate::db::Db;
22use crate::provider::Provider;
23use crate::tools::ToolRegistry;
24
25use app::{App, ChatMessage};
26use event::{AppEvent, EventHandler};
27use input::InputAction;
28use widgets::{AgentEntry, SessionEntry, time_ago};
29
30pub struct ExitInfo {
31    pub conversation_id: String,
32    pub title: Option<String>,
33}
34
35pub async fn run(
36    config: Config,
37    providers: Vec<Box<dyn Provider>>,
38    db: Db,
39    tools: ToolRegistry,
40    profiles: Vec<AgentProfile>,
41    cwd: String,
42    resume_id: Option<String>,
43) -> Result<()> {
44    terminal::enable_raw_mode()?;
45    let mut stdout = std::io::stderr();
46    execute!(
47        stdout,
48        terminal::EnterAlternateScreen,
49        crossterm::event::EnableMouseCapture,
50        crossterm::event::EnableBracketedPaste
51    )?;
52    let backend = ratatui::backend::CrosstermBackend::new(stdout);
53    let mut terminal = ratatui::Terminal::new(backend)?;
54
55    let result = run_app(
56        &mut terminal,
57        config,
58        providers,
59        db,
60        tools,
61        profiles,
62        cwd,
63        resume_id,
64    )
65    .await;
66
67    terminal::disable_raw_mode()?;
68    execute!(
69        std::io::stderr(),
70        terminal::LeaveAlternateScreen,
71        crossterm::event::DisableMouseCapture,
72        crossterm::event::DisableBracketedPaste
73    )?;
74    terminal.show_cursor()?;
75
76    if let Ok(ref info) = result {
77        print_exit_screen(info);
78    }
79
80    result.map(|_| ())
81}
82
83fn print_exit_screen(info: &ExitInfo) {
84    let title = info.title.as_deref().unwrap_or("untitled session");
85    let id = &info.conversation_id;
86    println!();
87    println!("  \x1b[2mSession\x1b[0m   {}", title);
88    println!("  \x1b[2mResume\x1b[0m    dot -s {}", id);
89    println!();
90}
91
92#[allow(clippy::too_many_arguments)]
93async fn run_app(
94    terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>,
95    config: Config,
96    providers: Vec<Box<dyn Provider>>,
97    db: Db,
98    tools: ToolRegistry,
99    profiles: Vec<AgentProfile>,
100    cwd: String,
101    resume_id: Option<String>,
102) -> Result<ExitInfo> {
103    let model_name = providers[0].model().to_string();
104    let provider_name = providers[0].name().to_string();
105    let agent_name = profiles
106        .first()
107        .map(|p| p.name.clone())
108        .unwrap_or_else(|| "dot".to_string());
109
110    let history = db.get_user_message_history(500).unwrap_or_default();
111
112    let agents_context = crate::context::AgentsContext::load(&cwd, &config.context);
113    let agent = Arc::new(Mutex::new(Agent::new(
114        providers,
115        db,
116        &config,
117        tools,
118        profiles,
119        cwd,
120        agents_context,
121    )?));
122
123    let context_window = {
124        let agent_lock = agent.lock().await;
125        let cw = agent_lock.fetch_context_window().await;
126        if cw == 0 {
127            tracing::warn!("Failed to fetch context window from API");
128        }
129        cw
130    };
131
132    if let Some(ref id) = resume_id {
133        let mut agent_lock = agent.lock().await;
134        match agent_lock.get_session(id) {
135            Ok(conv) => {
136                let _ = agent_lock.resume_conversation(&conv);
137            }
138            Err(e) => {
139                tracing::warn!("Failed to resume session {}: {}", id, e);
140            }
141        }
142    }
143
144    let mut app = App::new(
145        model_name,
146        provider_name,
147        agent_name,
148        &config.theme.name,
149        config.tui.vim_mode,
150        context_window,
151    );
152    app.history = history;
153
154    if let Some(ref id) = resume_id {
155        let agent_lock = agent.lock().await;
156        if let Ok(conv) = agent_lock.get_session(id) {
157            app.conversation_title = conv.title.clone();
158            for m in &conv.messages {
159                let model = if m.role == "assistant" {
160                    Some(conv.model.clone())
161                } else {
162                    None
163                };
164                app.messages.push(ChatMessage {
165                    role: m.role.clone(),
166                    content: m.content.clone(),
167                    tool_calls: Vec::new(),
168                    thinking: None,
169                    model,
170                });
171            }
172            app.scroll_to_bottom();
173        }
174        drop(agent_lock);
175    }
176
177    let mut events = EventHandler::new();
178    let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
179
180    loop {
181        terminal.draw(|f| ui::draw(f, &mut app))?;
182
183        let event = if let Some(ref mut rx) = agent_rx {
184            tokio::select! {
185                biased;
186                agent_event = rx.recv() => {
187                    match agent_event {
188                        Some(ev) => {
189                            app.handle_agent_event(ev);
190                        }
191                        None => {
192                            if app.is_streaming {
193                                app.is_streaming = false;
194                            }
195                            agent_rx = None;
196                            if let Some(queued) = app.message_queue.pop_front() {
197                                let (tx, rx) = mpsc::unbounded_channel();
198                                agent_rx = Some(rx);
199                                app.is_streaming = true;
200                                app.streaming_started = Some(Instant::now());
201                                app.current_response.clear();
202                                app.current_thinking.clear();
203                                app.current_tool_calls.clear();
204                                app.error_message = None;
205                                let agent_clone = Arc::clone(&agent);
206                                tokio::spawn(async move {
207                                    let mut agent = agent_clone.lock().await;
208                                    let result = if queued.images.is_empty() {
209                                        agent.send_message(&queued.text, tx).await
210                                    } else {
211                                        agent.send_message_with_images(&queued.text, queued.images, tx).await
212                                    };
213                                    if let Err(e) = result {
214                                        tracing::error!("Agent send_message error: {}", e);
215                                    }
216                                });
217                            }
218                        }
219                    }
220                    continue;
221                }
222                ui_event = events.next() => {
223                    match ui_event {
224                        Some(ev) => ev,
225                        None => break,
226                    }
227                }
228            }
229        } else {
230            match events.next().await {
231                Some(ev) => ev,
232                None => break,
233            }
234        };
235
236        if let LoopSignal::Quit = handle_event(&mut app, &agent, event, &mut agent_rx).await {
237            break;
238        }
239    }
240
241    let mut agent_lock = agent.lock().await;
242    let conversation_id = agent_lock.conversation_id().to_string();
243    let title = agent_lock.conversation_title();
244    agent_lock.cleanup_if_empty();
245    drop(agent_lock);
246
247    Ok(ExitInfo {
248        conversation_id,
249        title,
250    })
251}
252
253enum LoopSignal {
254    Continue,
255    Quit,
256    CancelStream,
257}
258
259async fn dispatch_action(
260    app: &mut App,
261    agent: &Arc<Mutex<Agent>>,
262    action: InputAction,
263    agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
264) -> LoopSignal {
265    match action {
266        InputAction::Quit => return LoopSignal::Quit,
267        InputAction::CancelStream => {
268            *agent_rx = None;
269            app.is_streaming = false;
270            app.streaming_started = None;
271            if !app.current_response.is_empty() || !app.current_tool_calls.is_empty() {
272                let thinking = if app.current_thinking.is_empty() {
273                    None
274                } else {
275                    Some(std::mem::take(&mut app.current_thinking))
276                };
277                app.messages.push(app::ChatMessage {
278                    role: "assistant".to_string(),
279                    content: std::mem::take(&mut app.current_response),
280                    tool_calls: std::mem::take(&mut app.current_tool_calls),
281                    thinking,
282                    model: Some(app.model_name.clone()),
283                });
284            } else {
285                app.current_response.clear();
286                app.current_thinking.clear();
287                app.current_tool_calls.clear();
288            }
289            app.pending_tool_name = None;
290            // Drop pending question/permission to unblock agent
291            app.pending_question = None;
292            app.pending_permission = None;
293            app.error_message = Some("cancelled".to_string());
294            return LoopSignal::CancelStream;
295        }
296        InputAction::SendMessage(msg) => {
297            let images: Vec<(String, String)> = app
298                .take_attachments()
299                .into_iter()
300                .map(|a| (a.media_type, a.data))
301                .collect();
302
303            let (tx, rx) = mpsc::unbounded_channel();
304            *agent_rx = Some(rx);
305
306            let agent_clone = Arc::clone(agent);
307            tokio::spawn(async move {
308                let mut agent = agent_clone.lock().await;
309                let result = if images.is_empty() {
310                    agent.send_message(&msg, tx).await
311                } else {
312                    agent.send_message_with_images(&msg, images, tx).await
313                };
314                if let Err(e) = result {
315                    tracing::error!("Agent send_message error: {}", e);
316                }
317            });
318        }
319        InputAction::NewConversation => {
320            let mut agent_lock = agent.lock().await;
321            match agent_lock.new_conversation() {
322                Ok(()) => app.clear_conversation(),
323                Err(e) => {
324                    app.error_message = Some(format!("failed to start new conversation: {e}"))
325                }
326            }
327        }
328        InputAction::OpenModelSelector => {
329            let agent_lock = agent.lock().await;
330            let grouped = agent_lock.fetch_all_models().await;
331            let current_provider = agent_lock.current_provider_name().to_string();
332            let current_model = agent_lock.current_model().to_string();
333            drop(agent_lock);
334            app.model_selector
335                .open(grouped, &current_provider, &current_model);
336        }
337        InputAction::OpenAgentSelector => {
338            let agent_lock = agent.lock().await;
339            let entries: Vec<AgentEntry> = agent_lock
340                .agent_profiles()
341                .iter()
342                .map(|p| AgentEntry {
343                    name: p.name.clone(),
344                    description: p.description.clone(),
345                })
346                .collect();
347            let current = agent_lock.current_agent_name().to_string();
348            drop(agent_lock);
349            app.agent_selector.open(entries, &current);
350        }
351        InputAction::OpenSessionSelector => {
352            let agent_lock = agent.lock().await;
353            let current_id = agent_lock.conversation_id().to_string();
354            let sessions = agent_lock.list_sessions().unwrap_or_default();
355            drop(agent_lock);
356            let entries: Vec<SessionEntry> = sessions
357                .into_iter()
358                .map(|s| {
359                    let title = if let Some(t) = &s.title {
360                        t.clone()
361                    } else if s.id == current_id {
362                        app.conversation_title
363                            .clone()
364                            .unwrap_or_else(|| "new conversation".to_string())
365                    } else {
366                        "untitled".to_string()
367                    };
368                    SessionEntry {
369                        id: s.id.clone(),
370                        title,
371                        subtitle: format!("{} ยท {}", time_ago(&s.updated_at), s.provider),
372                    }
373                })
374                .collect();
375            app.session_selector.open(entries);
376        }
377        InputAction::ResumeSession { id } => {
378            let mut agent_lock = agent.lock().await;
379            match agent_lock.get_session(&id) {
380                Ok(conv) => {
381                    let title = conv.title.clone();
382                    let conv_model = conv.model.clone();
383                    let messages_for_ui: Vec<(String, String)> = conv
384                        .messages
385                        .iter()
386                        .map(|m| (m.role.clone(), m.content.clone()))
387                        .collect();
388                    match agent_lock.resume_conversation(&conv) {
389                        Ok(()) => {
390                            drop(agent_lock);
391                            app.clear_conversation();
392                            app.conversation_title = title;
393                            for (role, content) in messages_for_ui {
394                                let model = if role == "assistant" {
395                                    Some(conv_model.clone())
396                                } else {
397                                    None
398                                };
399                                app.messages.push(ChatMessage {
400                                    role,
401                                    content,
402                                    tool_calls: Vec::new(),
403                                    thinking: None,
404                                    model,
405                                });
406                            }
407                            app.scroll_to_bottom();
408                        }
409                        Err(e) => {
410                            drop(agent_lock);
411                            app.error_message = Some(format!("failed to resume session: {e}"));
412                        }
413                    }
414                }
415                Err(e) => {
416                    drop(agent_lock);
417                    app.error_message = Some(format!("session not found: {e}"));
418                }
419            }
420        }
421        InputAction::SelectModel { provider, model } => {
422            let mut agent_lock = agent.lock().await;
423            agent_lock.set_active_provider(&provider, &model);
424            let cw = agent_lock.context_window();
425            if cw > 0 {
426                app.context_window = cw;
427            } else {
428                app.context_window = agent_lock.fetch_context_window().await;
429            }
430        }
431        InputAction::SelectAgent { name } => {
432            let mut agent_lock = agent.lock().await;
433            agent_lock.switch_agent(&name);
434            app.model_name = agent_lock.current_model().to_string();
435            app.provider_name = agent_lock.current_provider_name().to_string();
436            let cw = agent_lock.context_window();
437            if cw > 0 {
438                app.context_window = cw;
439            } else {
440                app.context_window = agent_lock.fetch_context_window().await;
441            }
442        }
443        InputAction::ScrollUp(n) => app.scroll_up(n),
444        InputAction::ScrollDown(n) => app.scroll_down(n),
445        InputAction::ScrollToTop => app.scroll_to_top(),
446        InputAction::ScrollToBottom => app.scroll_to_bottom(),
447        InputAction::ClearConversation => app.clear_conversation(),
448        InputAction::ToggleThinking => {
449            app.thinking_expanded = !app.thinking_expanded;
450        }
451        InputAction::OpenThinkingSelector => {
452            let level = app.thinking_level();
453            app.thinking_selector.open(level);
454        }
455        InputAction::SetThinkingLevel(budget) => {
456            let mut agent_lock = agent.lock().await;
457            agent_lock.set_thinking_budget(budget);
458        }
459        InputAction::CycleThinkingLevel => {
460            let next = app.thinking_level().next();
461            let budget = next.budget_tokens();
462            app.thinking_budget = budget;
463            let mut agent_lock = agent.lock().await;
464            agent_lock.set_thinking_budget(budget);
465        }
466        InputAction::TruncateToMessage(idx) => {
467            app.messages.truncate(idx + 1);
468            app.current_response.clear();
469            app.current_thinking.clear();
470            app.current_tool_calls.clear();
471            app.scroll_to_bottom();
472            let mut agent_lock = agent.lock().await;
473            agent_lock.truncate_messages(idx + 1);
474        }
475        InputAction::ForkFromMessage(idx) => {
476            let fork_messages: Vec<(String, String, Option<String>)> = app.messages[..=idx]
477                .iter()
478                .map(|m| (m.role.clone(), m.content.clone(), m.model.clone()))
479                .collect();
480            let mut agent_lock = agent.lock().await;
481            match agent_lock.fork_conversation(idx + 1) {
482                Ok(()) => {
483                    drop(agent_lock);
484                    app.clear_conversation();
485                    for (role, content, model) in fork_messages {
486                        app.messages.push(app::ChatMessage {
487                            role,
488                            content,
489                            tool_calls: Vec::new(),
490                            thinking: None,
491                            model,
492                        });
493                    }
494                    app.scroll_to_bottom();
495                }
496                Err(e) => {
497                    drop(agent_lock);
498                    app.error_message = Some(format!("fork failed: {e}"));
499                }
500            }
501        }
502        InputAction::AnswerQuestion(answer) => {
503            app.messages.push(ChatMessage {
504                role: "user".to_string(),
505                content: answer,
506                tool_calls: Vec::new(),
507                thinking: None,
508                model: None,
509            });
510            app.scroll_to_bottom();
511        }
512        InputAction::AnswerPermission(_) | InputAction::None => {}
513    }
514    LoopSignal::Continue
515}
516
517async fn handle_event(
518    app: &mut App,
519    agent: &Arc<Mutex<Agent>>,
520    event: AppEvent,
521    agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
522) -> LoopSignal {
523    let action = match event {
524        AppEvent::Key(key) => input::handle_key(app, key),
525        AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
526        AppEvent::Paste(text) => input::handle_paste(app, text),
527        AppEvent::Tick => {
528            app.tick_count = app.tick_count.wrapping_add(1);
529            app.animate_scroll();
530            return LoopSignal::Continue;
531        }
532        AppEvent::Agent(ev) => {
533            app.handle_agent_event(ev);
534            return LoopSignal::Continue;
535        }
536        AppEvent::Resize(_, _) => return LoopSignal::Continue,
537    };
538    dispatch_action(app, agent, action, agent_rx).await
539}