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