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, Stylize},
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, write_to_output_inner},
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    Resize(u16, u16),
31    Tick,
32    Agent(crate::agent::types::AgentEvent),
33    Abort,
34}
35
36#[cfg(windows)]
37extern "system" {
38    fn GetConsoleCP() -> u32;
39    fn GetConsoleOutputCP() -> u32;
40    fn SetConsoleCP(wCodePageID: u32) -> i32;
41    fn SetConsoleOutputCP(wCodePageID: u32) -> i32;
42}
43
44struct TerminalGuard {
45    #[cfg(windows)]
46    orig_cp: Option<(u32, u32)>,
47}
48
49impl TerminalGuard {
50    fn new() -> io::Result<Self> {
51        enable_raw_mode()?;
52
53        #[cfg(windows)]
54        let orig_cp = unsafe {
55            let cp = GetConsoleCP();
56            let ocp = GetConsoleOutputCP();
57            if SetConsoleCP(65001) != 0 && SetConsoleOutputCP(65001) != 0 {
58                Some((cp, ocp))
59            } else {
60                None
61            }
62        };
63
64        Ok(Self {
65            #[cfg(windows)]
66            orig_cp,
67        })
68    }
69}
70
71impl Drop for TerminalGuard {
72    fn drop(&mut self) {
73        let _ = disable_raw_mode();
74        let mut stdout = io::stdout();
75        let _ = execute!(
76            stdout,
77            style::Print("\x1b[r"), // Reset scrolling region to full screen
78            terminal::Clear(terminal::ClearType::All),
79            cursor::MoveTo(0, 0),
80            DisableBracketedPaste,
81            cursor::Show,
82        );
83
84        #[cfg(windows)]
85        if let Some((cp, ocp)) = self.orig_cp {
86            unsafe {
87                let _ = SetConsoleCP(cp);
88                let _ = SetConsoleOutputCP(ocp);
89            }
90        }
91    }
92}
93
94pub struct EventLoop {
95    pub rx: mpsc::Receiver<TuiEvent>,
96    pub app_tx: mpsc::Sender<ApprovalResult>,
97    pub cmd_tx: mpsc::Sender<(usize, String)>,
98    pub agent: Arc<Mutex<DeepSeekAgent>>,
99    /// Shared cancel token — can be cancelled without locking the agent mutex
100    pub cancel_token: Arc<std::sync::Mutex<tokio_util::sync::CancellationToken>>,
101    pub run_id: Arc<std::sync::atomic::AtomicUsize>,
102}
103
104impl EventLoop {
105    pub fn new(
106        rx: mpsc::Receiver<TuiEvent>,
107        _rx_tx: mpsc::Sender<TuiEvent>,
108        app_tx: mpsc::Sender<ApprovalResult>,
109        cmd_tx: mpsc::Sender<(usize, String)>,
110        agent: Arc<Mutex<DeepSeekAgent>>,
111        cancel_token: Arc<std::sync::Mutex<tokio_util::sync::CancellationToken>>,
112        run_id: Arc<std::sync::atomic::AtomicUsize>,
113    ) -> Self {
114        Self {
115            rx,
116            app_tx,
117            cmd_tx,
118            agent,
119            cancel_token,
120            run_id,
121        }
122    }
123
124    fn handle_abort(&self, app: &mut App, stdout: &mut io::Stdout) -> Result<()> {
125        if app.queued_commands.is_empty() {
126            return Ok(());
127        }
128        // Cancel via shared token — no agent lock needed, avoids deadlock
129        if let Ok(token) = self.cancel_token.lock() {
130            token.cancel();
131        } else {
132            tracing::warn!("Cancel token mutex poisoned during abort");
133        }
134        // Increment run_id to discard any queued operations in cmd_rx
135        self.run_id
136            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
137        // Set aborted flag so we ignore any in-flight AgentEvents
138        app.aborted = true;
139        app.current_task = None;
140        app.task_start_time = None;
141        app.job_start_time = None;
142        app.awaiting_approval = false;
143        app.queued_commands.clear();
144        write_to_output(stdout, app, "🛑 Operation aborted by user.\n".to_string())?;
145        Ok(())
146    }
147
148    pub async fn run(mut self) -> Result<String> {
149        let mut full_message = String::new();
150        let mut app = App::new();
151        let mut reasoning_colorizer = StreamColorizer::new();
152        reasoning_colorizer.set_dimmed(true);
153        let mut content_colorizer = StreamColorizer::new();
154
155        {
156            if let Ok(agent) = self.agent.try_lock() {
157                app.model = agent.model.clone();
158                app.token_usage = agent.token_usage.clone();
159            }
160        }
161
162        let _guard = TerminalGuard::new()?;
163        let mut stdout = io::stdout();
164        // Enable bracketed paste so multi-line pastes come as a single event
165        execute!(stdout, EnableBracketedPaste)?;
166
167        // Initial setup: Clear and set scrolling region
168        let (term_width, term_height) = terminal::size().unwrap_or((80, 24));
169        let log_height = term_height.saturating_sub(app.footer_height);
170        execute!(
171            stdout,
172            terminal::Clear(terminal::ClearType::All),
173            // CSI <top>;<bottom>r set scrolling region (1-indexed)
174            style::Print(format!("\x1b[1;{}r", log_height)),
175            cursor::MoveTo(0, 0),
176        )?;
177
178        // Print beautiful startup logo
179        let logo_lines = vec![
180            format!(
181                "  {}   {}   {}",
182                "██████╗ ".cyan().bold(),
183                "  ██████╗".magenta().bold(),
184                "DeepSeek CLI Agent".cyan().bold()
185            ),
186            format!(
187                "  {}   {}   {}",
188                "██╔══██╗".cyan().bold(),
189                " ██╔════╝".magenta().bold(),
190                "Autonomous Terminal System".dim()
191            ),
192            format!(
193                "  {}   {}   {}",
194                "██║  ██║".cyan().bold(),
195                " ██║     ".magenta().bold(),
196                format!("Version {}", crate::version::VERSION).dim()
197            ),
198            format!(
199                "  {}   {}   {}",
200                "██║  ██║".cyan().bold(),
201                " ██║     ".magenta().bold(),
202                "Status: Ready".dim()
203            ),
204            format!(
205                "  {}   {}   {}",
206                "██████╔╝".cyan().bold(),
207                " ╚██████╗".magenta().bold(),
208                "Type /help for command list".dim()
209            ),
210            format!(
211                "  {}   {}   {}",
212                "╚═════╝ ".cyan().bold(),
213                "  ╚═════╝".magenta().bold(),
214                ""
215            ),
216        ];
217
218        for line in logo_lines {
219            write_to_output(&mut stdout, &mut app, format!("{}\n", line))?;
220        }
221        write_to_output(&mut stdout, &mut app, "\n".to_string())?;
222
223        let mut last_size = (term_width, term_height);
224        render_footer(&mut stdout, &app)?;
225
226        while let Some(event) = self.rx.recv().await {
227            match event {
228                TuiEvent::Abort => {
229                    if !app.queued_commands.is_empty() {
230                        self.handle_abort(&mut app, &mut stdout)?;
231                    }
232                }
233                TuiEvent::Paste(text) => {
234                    if !text.is_empty() {
235                        let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
236                        let byte_pos = app.cursor_pos.min(app.input.len());
237                        app.input.insert_str(byte_pos, &normalized);
238                        app.cursor_pos = byte_pos + normalized.len();
239                    }
240                }
241                TuiEvent::Mouse(_) => {}
242                TuiEvent::Input(key) => {
243                    if self.handle_input(&mut app, &mut stdout, key)? {
244                        break;
245                    }
246                }
247                TuiEvent::Agent(agent_event) => {
248                    self.handle_agent_event(
249                        &mut app,
250                        &mut stdout,
251                        agent_event,
252                        &mut full_message,
253                        &mut reasoning_colorizer,
254                        &mut content_colorizer,
255                    )?;
256                }
257                TuiEvent::Resize(w, h) => {
258                    if (w, h) != last_size {
259                        if let Ok(mut size) = app.terminal_size.write() {
260                            size.width = w;
261                            size.height = h;
262                        }
263                        last_size = (w, h);
264
265                        execute!(stdout, terminal::Clear(terminal::ClearType::All))?;
266                        let log_height = h.saturating_sub(app.footer_height);
267                        execute!(
268                            stdout,
269                            style::Print(format!("\x1b[1;{}r", log_height)),
270                            cursor::MoveTo(0, 0)
271                        )?;
272                        app.log_x = 0;
273                        app.log_y = 0;
274                        let history = app.output_buffer.clone();
275                        write_to_output_inner(&mut stdout, &mut app, &history, false)?;
276                    }
277                }
278                TuiEvent::Tick => {
279                    app.tick();
280                    if let Ok(p) = std::env::current_dir() {
281                        app.cwd = p.display().to_string();
282                    }
283                    let (w, h) = terminal::size().unwrap_or((80, 24));
284                    if (w, h) != last_size {
285                        if let Ok(mut size) = app.terminal_size.write() {
286                            size.width = w;
287                            size.height = h;
288                        }
289                        last_size = (w, h);
290
291                        execute!(stdout, terminal::Clear(terminal::ClearType::All))?;
292                        let log_height = h.saturating_sub(app.footer_height);
293                        execute!(
294                            stdout,
295                            style::Print(format!("\x1b[1;{}r", log_height)),
296                            cursor::MoveTo(0, 0)
297                        )?;
298                        app.log_x = 0;
299                        app.log_y = 0;
300                        let history = app.output_buffer.clone();
301                        write_to_output_inner(&mut stdout, &mut app, &history, false)?;
302                    }
303                }
304            }
305            render_footer(&mut stdout, &app)?;
306        }
307
308        // Cleanup: Reset scrolling region and clear
309        let (_, _h) = terminal::size().unwrap_or((80, 24));
310        execute!(
311            stdout,
312            style::Print("\x1b[r"), // Reset scrolling region to full screen
313            terminal::Clear(terminal::ClearType::All),
314            cursor::MoveTo(0, 0),
315            DisableBracketedPaste,
316        )?;
317
318        disable_raw_mode()?;
319        println!();
320        Ok(full_message)
321    }
322}