Skip to main content

deepseek_rust_cli/tui/
event_loop.rs

1use std::{
2    io::{self},
3    sync::Arc,
4};
5
6use anyhow::Result;
7use crossterm::{
8    cursor,
9    event::{self, DisableBracketedPaste, EnableBracketedPaste},
10    execute,
11    style::{self},
12    terminal::{self, disable_raw_mode, enable_raw_mode},
13};
14use tokio::sync::{mpsc, Mutex};
15
16use crate::{
17    agent::{agent::DeepSeekAgent, types::ApprovalResult},
18    tui::{
19        app::App,
20        colorizer::StreamColorizer,
21        render::{render_footer, write_to_output},
22    },
23};
24
25pub enum TuiEvent {
26    Input(event::KeyEvent),
27    Mouse(event::MouseEvent),
28    /// Bracketed paste content (multi-line preserved)
29    Paste(String),
30    Tick,
31    Agent(crate::agent::types::AgentEvent),
32    Abort,
33}
34
35#[cfg(windows)]
36extern "system" {
37    fn GetConsoleCP() -> u32;
38    fn GetConsoleOutputCP() -> u32;
39    fn SetConsoleCP(wCodePageID: u32) -> i32;
40    fn SetConsoleOutputCP(wCodePageID: u32) -> i32;
41}
42
43struct TerminalGuard {
44    #[cfg(windows)]
45    orig_cp: Option<(u32, u32)>,
46}
47
48impl TerminalGuard {
49    fn new() -> io::Result<Self> {
50        enable_raw_mode()?;
51
52        #[cfg(windows)]
53        let orig_cp = unsafe {
54            let cp = GetConsoleCP();
55            let ocp = GetConsoleOutputCP();
56            if SetConsoleCP(65001) != 0 && SetConsoleOutputCP(65001) != 0 {
57                Some((cp, ocp))
58            } else {
59                None
60            }
61        };
62
63        Ok(Self {
64            #[cfg(windows)]
65            orig_cp,
66        })
67    }
68}
69
70impl Drop for TerminalGuard {
71    fn drop(&mut self) {
72        let _ = disable_raw_mode();
73        let mut stdout = io::stdout();
74        let _ = execute!(
75            stdout,
76            style::Print("\x1b[r"), // Reset scrolling region to full screen
77            terminal::Clear(terminal::ClearType::All),
78            cursor::MoveTo(0, 0),
79            DisableBracketedPaste,
80            cursor::Show,
81        );
82
83        #[cfg(windows)]
84        if let Some((cp, ocp)) = self.orig_cp {
85            unsafe {
86                let _ = SetConsoleCP(cp);
87                let _ = SetConsoleOutputCP(ocp);
88            }
89        }
90    }
91}
92
93pub struct EventLoop {
94    pub rx: mpsc::Receiver<TuiEvent>,
95    pub app_tx: mpsc::Sender<ApprovalResult>,
96    pub cmd_tx: mpsc::Sender<(usize, String)>,
97    pub agent: Arc<Mutex<DeepSeekAgent>>,
98    /// Shared cancel token — can be cancelled without locking the agent mutex
99    pub cancel_token: Arc<std::sync::Mutex<tokio_util::sync::CancellationToken>>,
100    pub run_id: Arc<std::sync::atomic::AtomicUsize>,
101}
102
103impl EventLoop {
104    pub fn new(
105        rx: mpsc::Receiver<TuiEvent>,
106        _rx_tx: mpsc::Sender<TuiEvent>,
107        app_tx: mpsc::Sender<ApprovalResult>,
108        cmd_tx: mpsc::Sender<(usize, String)>,
109        agent: Arc<Mutex<DeepSeekAgent>>,
110        cancel_token: Arc<std::sync::Mutex<tokio_util::sync::CancellationToken>>,
111        run_id: Arc<std::sync::atomic::AtomicUsize>,
112    ) -> Self {
113        Self {
114            rx,
115            app_tx,
116            cmd_tx,
117            agent,
118            cancel_token,
119            run_id,
120        }
121    }
122
123    fn handle_abort(&self, app: &mut App, stdout: &mut io::Stdout) -> Result<()> {
124        if app.queued_commands.is_empty() {
125            return Ok(());
126        }
127        // Cancel via shared token — no agent lock needed, avoids deadlock
128        if let Ok(token) = self.cancel_token.lock() {
129            token.cancel();
130        } else {
131            tracing::warn!("Cancel token mutex poisoned during abort");
132        }
133        // Increment run_id to discard any queued operations in cmd_rx
134        self.run_id
135            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
136        // Set aborted flag so we ignore any in-flight AgentEvents
137        app.aborted = true;
138        app.current_task = None;
139        app.task_start_time = None;
140        app.job_start_time = None;
141        app.awaiting_approval = false;
142        app.queued_commands.clear();
143        write_to_output(stdout, app, "🛑 Operation aborted by user.\n".to_string())?;
144        Ok(())
145    }
146
147    pub async fn run(mut self) -> Result<String> {
148        let mut full_message = String::new();
149        let mut app = App::new();
150        let mut reasoning_colorizer = StreamColorizer::new();
151        reasoning_colorizer.set_dimmed(true);
152        let mut content_colorizer = StreamColorizer::new();
153
154        {
155            if let Ok(agent) = self.agent.try_lock() {
156                app.model = agent.model.clone();
157                app.token_usage = agent.token_usage.clone();
158            }
159        }
160
161        let _guard = TerminalGuard::new()?;
162        let mut stdout = io::stdout();
163        // Enable bracketed paste so multi-line pastes come as a single event
164        execute!(stdout, EnableBracketedPaste)?;
165
166        // Initial setup: Clear and set scrolling region
167        let (term_width, term_height) = terminal::size().unwrap_or((80, 24));
168        let log_height = term_height.saturating_sub(app.footer_height);
169        execute!(
170            stdout,
171            terminal::Clear(terminal::ClearType::All),
172            // CSI <top>;<bottom>r set scrolling region (1-indexed)
173            style::Print(format!("\x1b[1;{}r", log_height)),
174            cursor::MoveTo(0, 0),
175        )?;
176
177        let mut last_size = (term_width, term_height);
178        let mut last_footer_height = app.footer_height; // always 4
179        render_footer(&mut stdout, &app)?;
180
181        while let Some(event) = self.rx.recv().await {
182            match event {
183                TuiEvent::Abort => {
184                    if !app.queued_commands.is_empty() {
185                        self.handle_abort(&mut app, &mut stdout)?;
186                    }
187                }
188                TuiEvent::Paste(text) => {
189                    if !text.is_empty() {
190                        let byte_pos = app.cursor_pos.min(app.input.len());
191                        app.input.insert_str(byte_pos, &text);
192                        app.cursor_pos = byte_pos + text.len();
193                    }
194                }
195                TuiEvent::Mouse(_) => {}
196                TuiEvent::Input(key) => {
197                    if self.handle_input(&mut app, &mut stdout, key)? {
198                        break;
199                    }
200                }
201                TuiEvent::Agent(agent_event) => {
202                    self.handle_agent_event(
203                        &mut app,
204                        &mut stdout,
205                        agent_event,
206                        &mut full_message,
207                        &mut reasoning_colorizer,
208                        &mut content_colorizer,
209                    )?;
210                }
211                TuiEvent::Tick => {
212                    app.tick();
213                    if let Ok(p) = std::env::current_dir() {
214                        app.cwd = p.display().to_string();
215                    }
216                    let (w, h) = terminal::size().unwrap_or((80, 24));
217                    if (w, h) != last_size {
218                        last_size = (w, h);
219                        last_footer_height = 0;
220                    }
221                }
222            }
223            // Footer is always 4 lines; update scrolling region once if terminal resized
224            let new_fh = 4u16;
225            if new_fh != last_footer_height {
226                let (_w, h) = terminal::size().unwrap_or((80, 24));
227                let log_h = h.saturating_sub(new_fh);
228                execute!(stdout, style::Print(format!("\x1b[1;{}r", log_h)))?;
229                last_footer_height = new_fh;
230                if app.log_y >= log_h {
231                    app.log_y = log_h.saturating_sub(1);
232                }
233            }
234            render_footer(&mut stdout, &app)?;
235        }
236
237        // Cleanup: Reset scrolling region and clear
238        let (_, _h) = terminal::size().unwrap_or((80, 24));
239        execute!(
240            stdout,
241            style::Print("\x1b[r"), // Reset scrolling region to full screen
242            terminal::Clear(terminal::ClearType::All),
243            cursor::MoveTo(0, 0),
244            DisableBracketedPaste,
245        )?;
246
247        disable_raw_mode()?;
248        println!();
249        Ok(full_message)
250    }
251}