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},
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        // Print beautiful startup logo
178        let logo_lines = vec![
179            format!(
180                "  {}   {}   {}",
181                "██████╗ ".cyan().bold(),
182                "  ██████╗".magenta().bold(),
183                "DeepSeek CLI Agent".cyan().bold()
184            ),
185            format!(
186                "  {}   {}   {}",
187                "██╔══██╗".cyan().bold(),
188                " ██╔════╝".magenta().bold(),
189                "Autonomous Terminal System".dim()
190            ),
191            format!(
192                "  {}   {}   {}",
193                "██║  ██║".cyan().bold(),
194                " ██║     ".magenta().bold(),
195                format!("Version {}", crate::version::VERSION).dim()
196            ),
197            format!(
198                "  {}   {}   {}",
199                "██║  ██║".cyan().bold(),
200                " ██║     ".magenta().bold(),
201                "Status: Ready".dim()
202            ),
203            format!(
204                "  {}   {}   {}",
205                "██████╔╝".cyan().bold(),
206                " ╚██████╗".magenta().bold(),
207                "Type /help for command list".dim()
208            ),
209            format!(
210                "  {}   {}   {}",
211                "╚═════╝ ".cyan().bold(),
212                "  ╚═════╝".magenta().bold(),
213                ""
214            ),
215        ];
216
217        for line in logo_lines {
218            write_to_output(&mut stdout, &mut app, format!("{}\n", line))?;
219        }
220        write_to_output(&mut stdout, &mut app, "\n".to_string())?;
221
222        let mut last_size = (term_width, term_height);
223        let mut last_footer_height = app.footer_height; // always 4
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 byte_pos = app.cursor_pos.min(app.input.len());
236                        app.input.insert_str(byte_pos, &text);
237                        app.cursor_pos = byte_pos + text.len();
238                    }
239                }
240                TuiEvent::Mouse(_) => {}
241                TuiEvent::Input(key) => {
242                    if self.handle_input(&mut app, &mut stdout, key)? {
243                        break;
244                    }
245                }
246                TuiEvent::Agent(agent_event) => {
247                    self.handle_agent_event(
248                        &mut app,
249                        &mut stdout,
250                        agent_event,
251                        &mut full_message,
252                        &mut reasoning_colorizer,
253                        &mut content_colorizer,
254                    )?;
255                }
256                TuiEvent::Tick => {
257                    app.tick();
258                    if let Ok(p) = std::env::current_dir() {
259                        app.cwd = p.display().to_string();
260                    }
261                    let (w, h) = terminal::size().unwrap_or((80, 24));
262                    if (w, h) != last_size {
263                        last_size = (w, h);
264                        last_footer_height = 0;
265                    }
266                }
267            }
268            // Footer is always 4 lines; update scrolling region once if terminal resized
269            let new_fh = 4u16;
270            if new_fh != last_footer_height {
271                let (_w, h) = terminal::size().unwrap_or((80, 24));
272                let log_h = h.saturating_sub(new_fh);
273                execute!(stdout, style::Print(format!("\x1b[1;{}r", log_h)))?;
274                last_footer_height = new_fh;
275                if app.log_y >= log_h {
276                    app.log_y = log_h.saturating_sub(1);
277                }
278            }
279            render_footer(&mut stdout, &app)?;
280        }
281
282        // Cleanup: Reset scrolling region and clear
283        let (_, _h) = terminal::size().unwrap_or((80, 24));
284        execute!(
285            stdout,
286            style::Print("\x1b[r"), // Reset scrolling region to full screen
287            terminal::Clear(terminal::ClearType::All),
288            cursor::MoveTo(0, 0),
289            DisableBracketedPaste,
290        )?;
291
292        disable_raw_mode()?;
293        println!();
294        Ok(full_message)
295    }
296}