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::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}