Skip to main content

dot/tui/
mod.rs

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}