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                let db_tool_calls = agent_lock.get_tool_calls(&m.id).unwrap_or_default();
238                let tool_calls: Vec<crate::tui::tools::ToolCallDisplay> = db_tool_calls
239                    .into_iter()
240                    .map(|tc| {
241                        let category = crate::tui::tools::ToolCategory::from_name(&tc.name);
242                        let detail = crate::tui::tools::extract_tool_detail(&tc.name, &tc.input);
243                        crate::tui::tools::ToolCallDisplay {
244                            name: tc.name,
245                            input: tc.input,
246                            output: tc.output,
247                            is_error: tc.is_error,
248                            category,
249                            detail,
250                        }
251                    })
252                    .collect();
253                let has_tools = !tool_calls.is_empty();
254                let clean_content = if has_tools {
255                    m.content.replace("[tool use]", "").trim().to_string()
256                } else {
257                    m.content.clone()
258                };
259                let segments = if has_tools {
260                    let mut segs = Vec::new();
261                    if !clean_content.is_empty() {
262                        segs.push(crate::tui::tools::StreamSegment::Text(
263                            clean_content.clone(),
264                        ));
265                    }
266                    for tc in &tool_calls {
267                        segs.push(crate::tui::tools::StreamSegment::ToolCall(tc.clone()));
268                    }
269                    Some(segs)
270                } else {
271                    None
272                };
273                app.messages.push(ChatMessage {
274                    role: m.role.clone(),
275                    content: clean_content,
276                    tool_calls,
277                    thinking: None,
278                    model,
279                    segments,
280                    chips: None,
281                });
282            }
283            if !conv.messages.is_empty() {
284                let cw = agent_lock.context_window();
285                app.context_window = if cw > 0 {
286                    cw
287                } else {
288                    agent_lock.fetch_context_window().await
289                };
290                app.last_input_tokens = conv.last_input_tokens;
291            }
292            app.scroll_to_bottom();
293        }
294        drop(agent_lock);
295    }
296
297    {
298        let (tx, rx) = tokio::sync::oneshot::channel();
299        let agent_clone = Arc::clone(&agent);
300        tokio::spawn(async move {
301            let mut lock = agent_clone.lock().await;
302            let result = lock.fetch_all_models().await;
303            let provider = lock.current_provider_name().to_string();
304            let model = lock.current_model().to_string();
305            let _ = tx.send((result, provider, model));
306        });
307        app.model_fetch_rx = Some(rx);
308    }
309
310    let mut events = EventHandler::new();
311    let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
312    let mut agent_task: Option<tokio::task::JoinHandle<()>> = None;
313
314    loop {
315        terminal.draw(|f| ui::draw(f, &mut app))?;
316        apply_cursor_style(&app)?;
317
318        let event = if let Some(ref mut rx) = agent_rx {
319            tokio::select! {
320                biased;
321                agent_event = rx.recv() => {
322                    match agent_event {
323                        Some(ev) => {
324                            app.handle_agent_event(ev);
325                        }
326                        None => {
327                            if app.is_streaming {
328                                app.is_streaming = false;
329                            }
330                            agent_rx = None;
331                            if app.context_window == 0 {
332                                let agent_lock = agent.lock().await;
333                                let cw = agent_lock.context_window();
334                                app.context_window = if cw > 0 {
335                                    cw
336                                } else {
337                                    agent_lock.fetch_context_window().await
338                                };
339                            }
340                            if let Some(queued) = app.message_queue.pop_front() {
341                                let (tx, rx) = mpsc::unbounded_channel();
342                                agent_rx = Some(rx);
343                                app.is_streaming = true;
344                                app.streaming_started = Some(Instant::now());
345                                app.current_response.clear();
346                                app.current_thinking.clear();
347                                app.current_tool_calls.clear();
348                                app.streaming_segments.clear();
349                                app.status_message = None;
350                                let agent_clone = Arc::clone(&agent);
351                                agent_task = Some(tokio::spawn(async move {
352                                    let mut agent = agent_clone.lock().await;
353                                    let result = if queued.images.is_empty() {
354                                        agent.send_message(&queued.text, tx).await
355                                    } else {
356                                        agent.send_message_with_images(&queued.text, queued.images, tx).await
357                                    };
358                                    if let Err(e) = result {
359                                        tracing::error!("Agent send_message error: {}", e);
360                                    }
361                                }));
362                            }
363                        }
364                    }
365                    continue;
366                }
367                bg_event = bg_rx.recv() => {
368                    if let Some(ev) = bg_event {
369                        app.handle_agent_event(ev);
370                    }
371                    continue;
372                }
373                ui_event = events.next() => {
374                    match ui_event {
375                        Some(ev) => ev,
376                        None => break,
377                    }
378                }
379            }
380        } else {
381            tokio::select! {
382                biased;
383                bg_event = bg_rx.recv() => {
384                    if let Some(ev) = bg_event {
385                        app.handle_agent_event(ev);
386                    }
387                    continue;
388                }
389                ui_event = events.next() => {
390                    match ui_event {
391                        Some(ev) => ev,
392                        None => break,
393                    }
394                }
395            }
396        };
397
398        match handle_event(&mut app, &agent, event, &mut agent_rx, &mut agent_task).await {
399            actions::LoopSignal::Quit => break,
400            actions::LoopSignal::OpenEditor => {
401                let editor = std::env::var("VISUAL")
402                    .or_else(|_| std::env::var("EDITOR"))
403                    .unwrap_or_else(|_| "vi".to_string());
404                let tmp = std::env::temp_dir().join("dot_input.md");
405                let _ = std::fs::write(&tmp, &app.input);
406                terminal::disable_raw_mode()?;
407                execute!(
408                    std::io::stderr(),
409                    terminal::LeaveAlternateScreen,
410                    crossterm::event::DisableMouseCapture
411                )?;
412                let status = std::process::Command::new(&editor).arg(&tmp).status();
413                execute!(
414                    std::io::stderr(),
415                    terminal::EnterAlternateScreen,
416                    crossterm::event::EnableMouseCapture
417                )?;
418                terminal::enable_raw_mode()?;
419                terminal.clear()?;
420                if status.is_ok()
421                    && let Ok(contents) = std::fs::read_to_string(&tmp)
422                {
423                    let trimmed = contents.trim_end().to_string();
424                    if !trimmed.is_empty() {
425                        app.cursor_pos = trimmed.len();
426                        app.input = trimmed;
427                    }
428                }
429                let _ = std::fs::remove_file(&tmp);
430            }
431            _ => {}
432        }
433    }
434
435    let mut agent_lock = agent.lock().await;
436    {
437        let event = crate::extension::Event::BeforeExit;
438        let ctx = crate::extension::EventContext {
439            event: event.as_str().to_string(),
440            cwd: agent_lock.cwd().to_string(),
441            session_id: agent_lock.conversation_id().to_string(),
442            ..Default::default()
443        };
444        agent_lock.hooks().emit(&event, &ctx);
445    }
446    let conversation_id = agent_lock.conversation_id().to_string();
447    let title = agent_lock.conversation_title();
448    agent_lock.cleanup_if_empty();
449    drop(agent_lock);
450
451    Ok(ExitInfo {
452        conversation_id,
453        title,
454    })
455}
456
457async fn handle_event(
458    app: &mut App,
459    agent: &Arc<Mutex<Agent>>,
460    event: AppEvent,
461    agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
462    agent_task: &mut Option<tokio::task::JoinHandle<()>>,
463) -> actions::LoopSignal {
464    let action = match event {
465        AppEvent::Key(key) => input::handle_key(app, key),
466        AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
467        AppEvent::Paste(text) => input::handle_paste(app, text),
468        AppEvent::Tick => {
469            app.tick_count = app.tick_count.wrapping_add(1);
470            if let Some(at) = app.thinking_collapse_at
471                && std::time::Instant::now() >= at
472            {
473                app.thinking_expanded = false;
474                app.auto_opened_thinking = false;
475                app.thinking_collapse_at = None;
476                app.mark_dirty();
477            }
478            if app.status_message.as_ref().is_some_and(|s| s.expired()) {
479                app.status_message = None;
480                app.mark_dirty();
481            }
482            if let Some(mut rx) = app.model_fetch_rx.take() {
483                match rx.try_recv() {
484                    Ok((grouped, provider, model)) => {
485                        app.cached_model_groups = Some(grouped.clone());
486                        if app.model_selector.visible {
487                            app.model_selector.favorites = app.favorite_models.clone();
488                            app.model_selector.open(grouped, &provider, &model);
489                        }
490                        app.mark_dirty();
491                    }
492                    Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
493                        app.model_fetch_rx = Some(rx);
494                    }
495                    Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {}
496                }
497            }
498            return actions::LoopSignal::Continue;
499        }
500        AppEvent::Agent(ev) => {
501            app.handle_agent_event(ev);
502            return actions::LoopSignal::Continue;
503        }
504        AppEvent::Resize(_, _) => return actions::LoopSignal::Continue,
505    };
506    actions::dispatch_action(app, agent, action, agent_rx, agent_task).await
507}
508
509pub async fn run_acp(config: crate::config::Config, client: crate::acp::AcpClient) -> Result<()> {
510    terminal::enable_raw_mode()?;
511    let mut stdout = std::io::stderr();
512    execute!(
513        stdout,
514        terminal::EnterAlternateScreen,
515        crossterm::event::EnableMouseCapture,
516        crossterm::event::EnableBracketedPaste
517    )?;
518    let backend = ratatui::backend::CrosstermBackend::new(stdout);
519    let mut terminal = ratatui::Terminal::new(backend)?;
520
521    let agent_name = client
522        .agent_info()
523        .map(|i| i.name.clone())
524        .unwrap_or_else(|| "acp".into());
525    let model_name = client.current_mode().unwrap_or("acp").to_string();
526    let provider_name = agent_name.clone();
527
528    let mut app = app::App::new(
529        model_name,
530        provider_name,
531        agent_name,
532        &config.theme.name,
533        config.tui.vim_mode,
534        config.tui.cursor_shape.clone(),
535        config.tui.cursor_blink,
536        config.tui.cursor_shape_normal.clone(),
537        config.tui.cursor_blink_normal,
538    );
539
540    let acp = Arc::new(Mutex::new(client));
541    let mut events = EventHandler::new();
542    let mut agent_rx: Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>> = None;
543    let mut agent_task: Option<tokio::task::JoinHandle<()>> = None;
544
545    loop {
546        terminal.draw(|f| ui::draw(f, &mut app))?;
547        apply_cursor_style(&app)?;
548
549        let event = if let Some(ref mut rx) = agent_rx {
550            tokio::select! {
551                biased;
552                agent_event = rx.recv() => {
553                    match agent_event {
554                        Some(ev) => {
555                            app.handle_agent_event(ev);
556                        }
557                        None => {
558                            if app.is_streaming {
559                                app.is_streaming = false;
560                            }
561                            agent_rx = None;
562                        }
563                    }
564                    continue;
565                }
566                ui_event = events.next() => {
567                    match ui_event {
568                        Some(ev) => ev,
569                        None => break,
570                    }
571                }
572            }
573        } else {
574            match events.next().await {
575                Some(ev) => ev,
576                None => break,
577            }
578        };
579
580        match handle_acp_event(&mut app, &acp, event, &mut agent_rx, &mut agent_task).await {
581            actions::LoopSignal::Quit => break,
582            actions::LoopSignal::OpenEditor => {
583                let editor = std::env::var("VISUAL")
584                    .or_else(|_| std::env::var("EDITOR"))
585                    .unwrap_or_else(|_| "vi".to_string());
586                let tmp = std::env::temp_dir().join("dot_input.md");
587                let _ = std::fs::write(&tmp, &app.input);
588                terminal::disable_raw_mode()?;
589                execute!(
590                    std::io::stderr(),
591                    terminal::LeaveAlternateScreen,
592                    crossterm::event::DisableMouseCapture
593                )?;
594                let status = std::process::Command::new(&editor).arg(&tmp).status();
595                execute!(
596                    std::io::stderr(),
597                    terminal::EnterAlternateScreen,
598                    crossterm::event::EnableMouseCapture
599                )?;
600                terminal::enable_raw_mode()?;
601                terminal.clear()?;
602                if status.is_ok()
603                    && let Ok(contents) = std::fs::read_to_string(&tmp)
604                {
605                    let trimmed = contents.trim_end().to_string();
606                    if !trimmed.is_empty() {
607                        app.cursor_pos = trimmed.len();
608                        app.input = trimmed;
609                    }
610                }
611                let _ = std::fs::remove_file(&tmp);
612            }
613            _ => {}
614        }
615    }
616
617    if let Ok(mut c) = acp.try_lock() {
618        let _ = c.kill();
619    }
620
621    terminal::disable_raw_mode()?;
622    execute!(
623        std::io::stderr(),
624        terminal::LeaveAlternateScreen,
625        crossterm::event::DisableMouseCapture,
626        crossterm::event::DisableBracketedPaste
627    )?;
628    terminal.show_cursor()?;
629    execute!(std::io::stderr(), SetCursorStyle::DefaultUserShape)?;
630
631    Ok(())
632}
633
634async fn handle_acp_event(
635    app: &mut app::App,
636    acp: &Arc<Mutex<crate::acp::AcpClient>>,
637    event: AppEvent,
638    agent_rx: &mut Option<mpsc::UnboundedReceiver<crate::agent::AgentEvent>>,
639    agent_task: &mut Option<tokio::task::JoinHandle<()>>,
640) -> actions::LoopSignal {
641    let action = match event {
642        AppEvent::Key(key) => input::handle_key(app, key),
643        AppEvent::Mouse(mouse) => input::handle_mouse(app, mouse),
644        AppEvent::Paste(text) => input::handle_paste(app, text),
645        AppEvent::Tick => {
646            app.tick_count = app.tick_count.wrapping_add(1);
647            if app.status_message.as_ref().is_some_and(|s| s.expired()) {
648                app.status_message = None;
649                app.mark_dirty();
650            }
651            return actions::LoopSignal::Continue;
652        }
653        AppEvent::Agent(ev) => {
654            app.handle_agent_event(ev);
655            return actions::LoopSignal::Continue;
656        }
657        AppEvent::Resize(_, _) => return actions::LoopSignal::Continue,
658    };
659    actions::dispatch_acp_action(app, acp, action, agent_rx, agent_task).await
660}