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::cursor::SetCursorStyle;
18use crossterm::{execute, terminal};
19use tokio::sync::{Mutex, mpsc};
20
21use crate::agent::{Agent, AgentProfile};
22use crate::command::CommandRegistry;
23use crate::config::{Config, CursorShape};
24use crate::db::Db;
25use crate::extension::HookRegistry;
26use crate::memory::MemoryStore;
27use crate::provider::Provider;
28use crate::tools::ToolRegistry;
29
30use app::{App, ChatMessage};
31use event::{AppEvent, EventHandler};
32
33pub struct ExitInfo {
34    pub conversation_id: String,
35    pub title: Option<String>,
36}
37
38fn cursor_style(shape: &CursorShape, blink: bool) -> SetCursorStyle {
39    match (shape, blink) {
40        (CursorShape::Block, true) => SetCursorStyle::BlinkingBlock,
41        (CursorShape::Block, false) => SetCursorStyle::SteadyBlock,
42        (CursorShape::Underline, true) => SetCursorStyle::BlinkingUnderScore,
43        (CursorShape::Underline, false) => SetCursorStyle::SteadyUnderScore,
44        (CursorShape::Line, true) => SetCursorStyle::BlinkingBar,
45        (CursorShape::Line, false) => SetCursorStyle::SteadyBar,
46    }
47}
48
49fn apply_cursor_style(app: &App) -> Result<()> {
50    let (shape, blink) = if app.vim_mode && app.mode == app::AppMode::Normal {
51        let s = app
52            .cursor_shape_normal
53            .as_ref()
54            .unwrap_or(&app.cursor_shape);
55        let b = app.cursor_blink_normal.unwrap_or(app.cursor_blink);
56        (s, b)
57    } else {
58        (&app.cursor_shape, app.cursor_blink)
59    };
60    execute!(std::io::stderr(), cursor_style(shape, blink))?;
61    Ok(())
62}
63
64#[allow(clippy::too_many_arguments)]
65pub async fn run(
66    config: Config,
67    providers: Vec<Box<dyn Provider>>,
68    db: Db,
69    memory: Option<Arc<MemoryStore>>,
70    tools: ToolRegistry,
71    profiles: Vec<AgentProfile>,
72    cwd: String,
73    resume_id: Option<String>,
74    skill_names: Vec<(String, String)>,
75    hooks: HookRegistry,
76    commands: CommandRegistry,
77    first_run: bool,
78) -> Result<()> {
79    terminal::enable_raw_mode()?;
80    let mut stdout = std::io::stderr();
81    execute!(
82        stdout,
83        terminal::EnterAlternateScreen,
84        crossterm::event::EnableMouseCapture,
85        crossterm::event::EnableBracketedPaste
86    )?;
87    let backend = ratatui::backend::CrosstermBackend::new(stdout);
88    let mut terminal = ratatui::Terminal::new(backend)?;
89
90    let result = run_app(
91        &mut terminal,
92        config,
93        providers,
94        db,
95        memory,
96        tools,
97        profiles,
98        cwd,
99        resume_id,
100        skill_names,
101        hooks,
102        commands,
103        first_run,
104    )
105    .await;
106
107    terminal::disable_raw_mode()?;
108    execute!(
109        std::io::stderr(),
110        terminal::LeaveAlternateScreen,
111        crossterm::event::DisableMouseCapture,
112        crossterm::event::DisableBracketedPaste
113    )?;
114    terminal.show_cursor()?;
115    execute!(std::io::stderr(), SetCursorStyle::DefaultUserShape)?;
116
117    if let Ok(ref info) = result {
118        print_exit_screen(info);
119    }
120
121    result.map(|_| ())
122}
123
124fn print_exit_screen(info: &ExitInfo) {
125    let title = info.title.as_deref().unwrap_or("untitled session");
126    let id = &info.conversation_id;
127    println!();
128    println!("  \x1b[2mSession\x1b[0m   {}", title);
129    println!("  \x1b[2mResume\x1b[0m    dot -s {}", id);
130    println!();
131}
132
133#[allow(clippy::too_many_arguments)]
134async fn run_app(
135    terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>,
136    config: Config,
137    providers: Vec<Box<dyn Provider>>,
138    db: Db,
139    memory: Option<Arc<MemoryStore>>,
140    tools: ToolRegistry,
141    profiles: Vec<AgentProfile>,
142    cwd: String,
143    resume_id: Option<String>,
144    skill_names: Vec<(String, String)>,
145    hooks: HookRegistry,
146    commands: CommandRegistry,
147    first_run: bool,
148) -> Result<ExitInfo> {
149    let model_name = providers[0].model().to_string();
150    let provider_name = providers[0].name().to_string();
151    let agent_name = profiles
152        .first()
153        .map(|p| p.name.clone())
154        .unwrap_or_else(|| "dot".to_string());
155
156    let history = db.get_user_message_history(500).unwrap_or_default();
157
158    let agents_context = crate::context::AgentsContext::load(&cwd, &config.context);
159    let (bg_tx, mut bg_rx) = mpsc::unbounded_channel();
160    let mut agent_inner = Agent::new(
161        providers,
162        db,
163        &config,
164        memory,
165        tools,
166        profiles,
167        cwd,
168        agents_context,
169        hooks,
170        commands,
171    )?;
172    agent_inner.set_background_tx(bg_tx);
173    let agent = Arc::new(Mutex::new(agent_inner));
174
175    if let Some(ref id) = resume_id {
176        let mut agent_lock = agent.lock().await;
177        match agent_lock.get_session(id) {
178            Ok(conv) => {
179                let _ = agent_lock.resume_conversation(&conv);
180            }
181            Err(e) => {
182                tracing::warn!("Failed to resume session {}: {}", id, e);
183            }
184        }
185    }
186
187    let mut app = App::new(
188        model_name,
189        provider_name,
190        agent_name,
191        &config.theme.name,
192        config.tui.vim_mode,
193        config.tui.cursor_shape.clone(),
194        config.tui.cursor_blink,
195        config.tui.cursor_shape_normal.clone(),
196        config.tui.cursor_blink_normal,
197    );
198    app.history = history;
199    app.favorite_models = config.tui.favorite_models.clone();
200    app.skill_entries = skill_names;
201
202    if first_run
203        || resume_id.is_none() && {
204            let creds = crate::auth::Credentials::load().unwrap_or_default();
205            let has_creds = !creds.providers.is_empty();
206            let has_env = std::env::var("ANTHROPIC_API_KEY")
207                .ok()
208                .filter(|k| !k.is_empty())
209                .is_some()
210                || std::env::var("OPENAI_API_KEY")
211                    .ok()
212                    .filter(|k| !k.is_empty())
213                    .is_some();
214            !has_creds && !has_env
215        }
216    {
217        app.welcome_screen.open();
218    }
219    {
220        let agent_lock = agent.lock().await;
221        let cmds = agent_lock.list_commands();
222        app.custom_command_names = cmds.iter().map(|(n, _)| n.to_string()).collect();
223        app.command_palette.set_skills(&app.skill_entries);
224        app.command_palette.add_custom_commands(&cmds);
225    }
226
227    if let Some(ref id) = resume_id {
228        let agent_lock = agent.lock().await;
229        if let Ok(conv) = agent_lock.get_session(id) {
230            app.conversation_title = conv.title.clone();
231            for m in &conv.messages {
232                let model = if m.role == "assistant" {
233                    Some(conv.model.clone())
234                } else {
235                    None
236                };
237                app.messages.push(ChatMessage {
238                    role: m.role.clone(),
239                    content: m.content.clone(),
240                    tool_calls: Vec::new(),
241                    thinking: None,
242                    model,
243                    segments: None,
244                    chips: None,
245                });
246            }
247            if !conv.messages.is_empty() {
248                let cw = agent_lock.context_window();
249                app.context_window = if cw > 0 {
250                    cw
251                } else {
252                    agent_lock.fetch_context_window().await
253                };
254                app.last_input_tokens = conv.last_input_tokens;
255            }
256            app.scroll_to_bottom();
257        }
258        drop(agent_lock);
259    }
260
261    {
262        let (tx, rx) = tokio::sync::oneshot::channel();
263        let agent_clone = Arc::clone(&agent);
264        tokio::spawn(async move {
265            let mut lock = agent_clone.lock().await;
266            let result = lock.fetch_all_models().await;
267            let provider = lock.current_provider_name().to_string();
268            let model = lock.current_model().to_string();
269            let _ = tx.send((result, provider, model));
270        });
271        app.model_fetch_rx = Some(rx);
272    }
273
274    let mut events = EventHandler::new();
275    let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
276    let mut agent_task: Option<tokio::task::JoinHandle<()>> = None;
277
278    loop {
279        terminal.draw(|f| ui::draw(f, &mut app))?;
280        apply_cursor_style(&app)?;
281
282        let event = if let Some(ref mut rx) = agent_rx {
283            tokio::select! {
284                biased;
285                agent_event = rx.recv() => {
286                    match agent_event {
287                        Some(ev) => {
288                            app.handle_agent_event(ev);
289                        }
290                        None => {
291                            if app.is_streaming {
292                                app.is_streaming = false;
293                            }
294                            agent_rx = None;
295                            if app.context_window == 0 {
296                                let agent_lock = agent.lock().await;
297                                let cw = agent_lock.context_window();
298                                app.context_window = if cw > 0 {
299                                    cw
300                                } else {
301                                    agent_lock.fetch_context_window().await
302                                };
303                            }
304                            if let Some(queued) = app.message_queue.pop_front() {
305                                let (tx, rx) = mpsc::unbounded_channel();
306                                agent_rx = Some(rx);
307                                app.is_streaming = true;
308                                app.streaming_started = Some(Instant::now());
309                                app.current_response.clear();
310                                app.current_thinking.clear();
311                                app.current_tool_calls.clear();
312                                app.streaming_segments.clear();
313                                app.status_message = None;
314                                let agent_clone = Arc::clone(&agent);
315                                agent_task = Some(tokio::spawn(async move {
316                                    let mut agent = agent_clone.lock().await;
317                                    let result = if queued.images.is_empty() {
318                                        agent.send_message(&queued.text, tx).await
319                                    } else {
320                                        agent.send_message_with_images(&queued.text, queued.images, tx).await
321                                    };
322                                    if let Err(e) = result {
323                                        tracing::error!("Agent send_message error: {}", e);
324                                    }
325                                }));
326                            }
327                        }
328                    }
329                    continue;
330                }
331                bg_event = bg_rx.recv() => {
332                    if let Some(ev) = bg_event {
333                        app.handle_agent_event(ev);
334                    }
335                    continue;
336                }
337                ui_event = events.next() => {
338                    match ui_event {
339                        Some(ev) => ev,
340                        None => break,
341                    }
342                }
343            }
344        } else {
345            tokio::select! {
346                biased;
347                bg_event = bg_rx.recv() => {
348                    if let Some(ev) = bg_event {
349                        app.handle_agent_event(ev);
350                    }
351                    continue;
352                }
353                ui_event = events.next() => {
354                    match ui_event {
355                        Some(ev) => ev,
356                        None => break,
357                    }
358                }
359            }
360        };
361
362        match handle_event(&mut app, &agent, event, &mut agent_rx, &mut agent_task).await {
363            actions::LoopSignal::Quit => break,
364            actions::LoopSignal::OpenEditor => {
365                let editor = std::env::var("VISUAL")
366                    .or_else(|_| std::env::var("EDITOR"))
367                    .unwrap_or_else(|_| "vi".to_string());
368                let tmp = std::env::temp_dir().join("dot_input.md");
369                let _ = std::fs::write(&tmp, &app.input);
370                terminal::disable_raw_mode()?;
371                execute!(
372                    std::io::stderr(),
373                    terminal::LeaveAlternateScreen,
374                    crossterm::event::DisableMouseCapture
375                )?;
376                let status = std::process::Command::new(&editor).arg(&tmp).status();
377                execute!(
378                    std::io::stderr(),
379                    terminal::EnterAlternateScreen,
380                    crossterm::event::EnableMouseCapture
381                )?;
382                terminal::enable_raw_mode()?;
383                terminal.clear()?;
384                if status.is_ok()
385                    && let Ok(contents) = std::fs::read_to_string(&tmp)
386                {
387                    let trimmed = contents.trim_end().to_string();
388                    if !trimmed.is_empty() {
389                        app.cursor_pos = trimmed.len();
390                        app.input = trimmed;
391                    }
392                }
393                let _ = std::fs::remove_file(&tmp);
394            }
395            _ => {}
396        }
397    }
398
399    let mut agent_lock = agent.lock().await;
400    {
401        let event = crate::extension::Event::BeforeExit;
402        let ctx = crate::extension::EventContext {
403            event: event.as_str().to_string(),
404            cwd: agent_lock.cwd().to_string(),
405            session_id: agent_lock.conversation_id().to_string(),
406            ..Default::default()
407        };
408        agent_lock.hooks().emit(&event, &ctx);
409    }
410    let conversation_id = agent_lock.conversation_id().to_string();
411    let title = agent_lock.conversation_title();
412    agent_lock.cleanup_if_empty();
413    drop(agent_lock);
414
415    Ok(ExitInfo {
416        conversation_id,
417        title,
418    })
419}
420
421async fn handle_event(
422    app: &mut App,
423    agent: &Arc<Mutex<Agent>>,
424    event: AppEvent,
425    agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
426    agent_task: &mut Option<tokio::task::JoinHandle<()>>,
427) -> actions::LoopSignal {
428    let action = match event {
429        AppEvent::Key(key) => input::handle_key(app, key),
430        AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
431        AppEvent::Paste(text) => input::handle_paste(app, text),
432        AppEvent::Tick => {
433            app.tick_count = app.tick_count.wrapping_add(1);
434            if app.status_message.as_ref().is_some_and(|s| s.expired()) {
435                app.status_message = None;
436                app.mark_dirty();
437            }
438            if let Some(mut rx) = app.model_fetch_rx.take() {
439                match rx.try_recv() {
440                    Ok((grouped, provider, model)) => {
441                        app.cached_model_groups = Some(grouped.clone());
442                        if app.model_selector.visible {
443                            app.model_selector.favorites = app.favorite_models.clone();
444                            app.model_selector.open(grouped, &provider, &model);
445                        }
446                        app.mark_dirty();
447                    }
448                    Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
449                        app.model_fetch_rx = Some(rx);
450                    }
451                    Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {}
452                }
453            }
454            return actions::LoopSignal::Continue;
455        }
456        AppEvent::Agent(ev) => {
457            app.handle_agent_event(ev);
458            return actions::LoopSignal::Continue;
459        }
460        AppEvent::Resize(_, _) => return actions::LoopSignal::Continue,
461    };
462    actions::dispatch_action(app, agent, action, agent_rx, agent_task).await
463}
464
465pub async fn run_acp(config: crate::config::Config, client: crate::acp::AcpClient) -> Result<()> {
466    terminal::enable_raw_mode()?;
467    let mut stdout = std::io::stderr();
468    execute!(
469        stdout,
470        terminal::EnterAlternateScreen,
471        crossterm::event::EnableMouseCapture,
472        crossterm::event::EnableBracketedPaste
473    )?;
474    let backend = ratatui::backend::CrosstermBackend::new(stdout);
475    let mut terminal = ratatui::Terminal::new(backend)?;
476
477    let agent_name = client
478        .agent_info()
479        .map(|i| i.name.clone())
480        .unwrap_or_else(|| "acp".into());
481    let model_name = client.current_mode().unwrap_or("acp").to_string();
482    let provider_name = agent_name.clone();
483
484    let mut app = app::App::new(
485        model_name,
486        provider_name,
487        agent_name,
488        &config.theme.name,
489        config.tui.vim_mode,
490        config.tui.cursor_shape.clone(),
491        config.tui.cursor_blink,
492        config.tui.cursor_shape_normal.clone(),
493        config.tui.cursor_blink_normal,
494    );
495
496    let acp = Arc::new(Mutex::new(client));
497    let mut events = EventHandler::new();
498    let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
499    let mut agent_task: Option<tokio::task::JoinHandle<()>> = None;
500
501    loop {
502        terminal.draw(|f| ui::draw(f, &mut app))?;
503        apply_cursor_style(&app)?;
504
505        let event = if let Some(ref mut rx) = agent_rx {
506            tokio::select! {
507                biased;
508                agent_event = rx.recv() => {
509                    match agent_event {
510                        Some(ev) => {
511                            app.handle_agent_event(ev);
512                        }
513                        None => {
514                            if app.is_streaming {
515                                app.is_streaming = false;
516                            }
517                            agent_rx = None;
518                        }
519                    }
520                    continue;
521                }
522                ui_event = events.next() => {
523                    match ui_event {
524                        Some(ev) => ev,
525                        None => break,
526                    }
527                }
528            }
529        } else {
530            match events.next().await {
531                Some(ev) => ev,
532                None => break,
533            }
534        };
535
536        match handle_acp_event(&mut app, &acp, event, &mut agent_rx, &mut agent_task).await {
537            actions::LoopSignal::Quit => break,
538            actions::LoopSignal::OpenEditor => {
539                let editor = std::env::var("VISUAL")
540                    .or_else(|_| std::env::var("EDITOR"))
541                    .unwrap_or_else(|_| "vi".to_string());
542                let tmp = std::env::temp_dir().join("dot_input.md");
543                let _ = std::fs::write(&tmp, &app.input);
544                terminal::disable_raw_mode()?;
545                execute!(
546                    std::io::stderr(),
547                    terminal::LeaveAlternateScreen,
548                    crossterm::event::DisableMouseCapture
549                )?;
550                let status = std::process::Command::new(&editor).arg(&tmp).status();
551                execute!(
552                    std::io::stderr(),
553                    terminal::EnterAlternateScreen,
554                    crossterm::event::EnableMouseCapture
555                )?;
556                terminal::enable_raw_mode()?;
557                terminal.clear()?;
558                if status.is_ok()
559                    && let Ok(contents) = std::fs::read_to_string(&tmp)
560                {
561                    let trimmed = contents.trim_end().to_string();
562                    if !trimmed.is_empty() {
563                        app.cursor_pos = trimmed.len();
564                        app.input = trimmed;
565                    }
566                }
567                let _ = std::fs::remove_file(&tmp);
568            }
569            _ => {}
570        }
571    }
572
573    if let Ok(mut c) = acp.try_lock() {
574        let _ = c.kill();
575    }
576
577    terminal::disable_raw_mode()?;
578    execute!(
579        std::io::stderr(),
580        terminal::LeaveAlternateScreen,
581        crossterm::event::DisableMouseCapture,
582        crossterm::event::DisableBracketedPaste
583    )?;
584    terminal.show_cursor()?;
585    execute!(std::io::stderr(), SetCursorStyle::DefaultUserShape)?;
586
587    Ok(())
588}
589
590async fn handle_acp_event(
591    app: &mut app::App,
592    acp: &Arc<Mutex<crate::acp::AcpClient>>,
593    event: AppEvent,
594    agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
595    agent_task: &mut Option<tokio::task::JoinHandle<()>>,
596) -> actions::LoopSignal {
597    let action = match event {
598        AppEvent::Key(key) => input::handle_key(app, key),
599        AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
600        AppEvent::Paste(text) => input::handle_paste(app, text),
601        AppEvent::Tick => {
602            app.tick_count = app.tick_count.wrapping_add(1);
603            if app.status_message.as_ref().is_some_and(|s| s.expired()) {
604                app.status_message = None;
605                app.mark_dirty();
606            }
607            return actions::LoopSignal::Continue;
608        }
609        AppEvent::Agent(ev) => {
610            app.handle_agent_event(ev);
611            return actions::LoopSignal::Continue;
612        }
613        AppEvent::Resize(_, _) => return actions::LoopSignal::Continue,
614    };
615    actions::dispatch_acp_action(app, acp, action, agent_rx, agent_task).await
616}