Skip to main content

rig_tui/
terminal.rs

1//! Terminal ownership: raw mode, the alternate screen, keeping stray
2//! output off the screen, handing the terminal to a program for a while
3//! ([`RunInTerminal`]), whether the terminal has the user's focus
4//! ([`TerminalFocus`]), and a plugin's own escape codes, such as a desktop
5//! notification ([`TerminalWrite`]) or a copy to the clipboard
6//! ([`CopyToClipboard`]), written after each frame, the last one included.
7
8use std::fs::File;
9use std::io::{self, BufWriter, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Command, ExitStatus, Stdio};
12
13use base64::Engine;
14use base64::engine::general_purpose::STANDARD;
15use bevy_ecs::prelude::*;
16use bevy_log::error;
17use crossterm::cursor::{Hide, MoveTo, Show};
18use crossterm::event::{
19    DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange,
20    KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
21};
22use crossterm::terminal::{
23    BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate, EnterAlternateScreen,
24    LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
25};
26use crossterm::{execute, queue};
27use ratatui::backend::{CrosstermBackend, TestBackend};
28use ratatui::buffer::Buffer;
29use ratatui::layout::{Rect, Size};
30use ratatui::{Frame, Terminal};
31
32use super::input::TerminalInput;
33use super::keys::KeyBinding;
34use super::panel::{RequestRedraw, TerminalSize};
35use super::theme::Theme;
36use rig_ecs::tasks::Wake;
37use rig_harness::harness_protocol::secret_variables;
38use rig_harness::prelude::{Exit, ExitReason, Scratch};
39
40/// Runs a program with the terminal, such as `$EDITOR` on a draft: the
41/// view leaves the screen, stops reading keys and lets the program have
42/// the terminal, its output included, until it exits; then it takes the
43/// terminal back, draws again, and inserts [`TerminalExit`] on the entity,
44/// which the plugin despawns once it read it. The agent's loop waits
45/// while the program runs. Only the terminal view runs it, in the frame
46/// it is spawned.
47///
48/// Ctrl+C in a program that leaves the terminal as it is (not an editor,
49/// which takes the keys itself) also stops the agent. The program gets
50/// none of the session's secrets.
51#[derive(Component, Debug)]
52pub struct RunInTerminal(pub Command);
53
54/// How a [`RunInTerminal`] program ended: its exit status, or why it did
55/// not start.
56#[derive(Component, Clone, Debug, PartialEq, Eq)]
57pub struct TerminalExit(pub Result<ExitStatus, String>);
58
59impl TerminalExit {
60    /// Whether the program ran and exited with success.
61    pub fn success(&self) -> bool {
62        self.0.as_ref().is_ok_and(ExitStatus::success)
63    }
64}
65
66/// Whether the terminal has the user's focus, as the terminal reports it
67/// where it can: `None` until its first report, and in a run without the
68/// terminal view.
69#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub struct TerminalFocus(pub Option<bool>);
71
72/// Bytes for the terminal itself, such as an escape code that shows a
73/// desktop notification (OSC 9 or 777): the view writes them after its
74/// frame, through its own writer, so they never land inside a frame. Bytes
75/// sent in Bevy's `OnAppExitSystems` are written before the terminal is
76/// given back. Nothing writes them in a run without the terminal view.
77#[derive(Message, Clone, Debug, PartialEq, Eq)]
78pub struct TerminalWrite(pub Vec<u8>);
79
80/// Puts the text on the system clipboard through the terminal (OSC 52),
81/// which works over SSH too, where the terminal allows it, and on this
82/// machine's clipboard through [`rig_tools::clipboard`], for a terminal
83/// that ignores OSC 52.
84#[derive(Message, Clone, Debug, PartialEq, Eq)]
85pub struct CopyToClipboard(pub String);
86
87impl CopyToClipboard {
88    /// The escape code that copies it.
89    fn escape(&self) -> Vec<u8> {
90        format!("\x1b]52;c;{}\x07", STANDARD.encode(&self.0)).into_bytes()
91    }
92}
93
94/// Bytes buffered before a write to the terminal: a full frame of a large
95/// screen.
96const FRAME_BUFFER: usize = 1 << 16;
97
98/// The terminal, drawn to through a buffered private copy of stdout, so a
99/// frame goes out in a few writes. Dropping it restores the terminal,
100/// keeping the alternate screen for a reload so the next build draws over
101/// the same screen.
102#[derive(Resource)]
103pub(crate) struct Tui {
104    pub(super) terminal: Terminal<CrosstermBackend<BufWriter<File>>>,
105    keep_screen: bool,
106    /// Whether the terminal is asked to report key releases.
107    releases: bool,
108    /// A copy of the screen's file, for a program's output.
109    screen: File,
110}
111
112/// Where stray output goes when the session keeps no text log.
113const NOWHERE: &str = "/dev/null";
114
115/// What the terminal is always asked: keys sent as escape codes, so a
116/// modifier it would drop, such as Shift on Enter, comes through.
117const KEYS: KeyboardEnhancementFlags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
118
119/// What the terminal is asked to report while a key binding wants key
120/// releases, on top of [`KEYS`].
121const RELEASES: KeyboardEnhancementFlags = KEYS.union(KeyboardEnhancementFlags::REPORT_EVENT_TYPES);
122
123impl Tui {
124    /// Take over the terminal. On unix, stdout and stderr are then pointed
125    /// at `log`, so output from plugins, dependencies or panics lands there
126    /// instead of on the screen.
127    fn open(log: Option<&Path>) -> io::Result<Self> {
128        let screen = screen()?;
129        if let Some(log) = log {
130            redirect_output(log)?;
131        }
132        let copy = screen.try_clone()?;
133        let backend = CrosstermBackend::new(BufWriter::with_capacity(FRAME_BUFFER, screen));
134        let mut tui = Self {
135            terminal: Terminal::new(backend)?,
136            keep_screen: false,
137            releases: false,
138            screen: copy,
139        };
140        tui.enter()?;
141        Ok(tui)
142    }
143
144    /// Takes the screen: raw mode, then [`take_screen`].
145    fn enter(&mut self) -> io::Result<()> {
146        enable_raw_mode()?;
147        take_screen(&mut self.terminal, self.releases)
148    }
149
150    /// Gives the screen back as the shell had it, leaving the alternate
151    /// screen unless `keep_screen`.
152    fn leave(&mut self, keep_screen: bool) -> io::Result<()> {
153        let backend = self.terminal.backend_mut();
154        if self.releases {
155            execute!(backend, PopKeyboardEnhancementFlags).ok();
156        }
157        execute!(backend, PopKeyboardEnhancementFlags).ok();
158        // Every step is tried, so a failed one leaves no other undone.
159        let pastes = execute!(backend, DisableBracketedPaste, DisableFocusChange);
160        let alternate = match keep_screen {
161            true => Ok(()),
162            false => execute!(backend, LeaveAlternateScreen),
163        };
164        let cursor = self.terminal.show_cursor();
165        pastes.and(alternate).and(cursor).and(disable_raw_mode())
166    }
167
168    /// Starts a frame: one synchronized update with the cursor hidden, so
169    /// the cursor never shows travelling across the screen.
170    pub(crate) fn begin_frame(&mut self) -> io::Result<()> {
171        queue!(self.terminal.backend_mut(), BeginSynchronizedUpdate, Hide)
172    }
173
174    /// Ends the frame, showing the cursor at `cursor`, if any.
175    pub(crate) fn end_frame(&mut self, cursor: Option<MoveTo>) -> io::Result<()> {
176        let backend = self.terminal.backend_mut();
177        if let Some(cursor) = cursor {
178            queue!(backend, cursor, Show)?;
179        }
180        execute!(backend, EndSynchronizedUpdate)
181    }
182
183    /// Asks the terminal to report key releases, or no longer.
184    fn report_releases(&mut self, releases: bool) {
185        if self.releases == releases {
186            return;
187        }
188        self.releases = releases;
189        let backend = self.terminal.backend_mut();
190        let asked = if releases {
191            execute!(backend, PushKeyboardEnhancementFlags(RELEASES))
192        } else {
193            execute!(backend, PopKeyboardEnhancementFlags)
194        };
195        if let Err(failure) = asked {
196            error!("could not ask the terminal about key releases: {failure}");
197        }
198    }
199}
200
201/// The alternate screen cleared (after a reload it still shows the previous
202/// build's frame), pastes reported whole, focus changes reported, keys sent
203/// as escape codes, no cursor, and a whole frame drawn next. The terminal is asked nothing: its answer
204/// would come on stdin, but stdout may be the session's log by now
205/// (`Terminal::clear` asks for the cursor position).
206fn take_screen<B>(terminal: &mut Terminal<B>, releases: bool) -> io::Result<()>
207where
208    B: ratatui::backend::Backend<Error = io::Error> + Write,
209{
210    let backend = terminal.backend_mut();
211    execute!(
212        backend,
213        EnterAlternateScreen,
214        EnableBracketedPaste,
215        EnableFocusChange,
216        Clear(ClearType::All)
217    )?;
218    // A terminal without the keyboard protocol ignores these.
219    execute!(backend, PushKeyboardEnhancementFlags(KEYS)).ok();
220    if releases {
221        execute!(backend, PushKeyboardEnhancementFlags(RELEASES)).ok();
222    }
223    terminal.hide_cursor()?;
224    // Each swap resets the buffer it makes current: both are blank, as the
225    // screen is, so the next draw writes every cell.
226    terminal.swap_buffers();
227    terminal.swap_buffers();
228    Ok(())
229}
230
231/// A screen in memory that the view draws to, for a test of what a plugin
232/// shows: inserted in a run with the view's systems but no terminal (the
233/// plugin, then [`add_view`](crate::add_view)), each frame due is drawn
234/// to it as it would be to the terminal, and the bytes sent for the
235/// terminal ([`TerminalWrite`], [`CopyToClipboard`]) are kept. A test
236/// reads what is shown, never a whole screen to compare.
237#[derive(Resource)]
238pub struct TuiTestScreen {
239    terminal: Terminal<TestBackend>,
240    written: Vec<u8>,
241    repainted: Vec<Rect>,
242}
243
244impl TuiTestScreen {
245    /// A screen `width` columns wide and `height` rows high.
246    pub fn new(width: u16, height: u16) -> Self {
247        let Ok(terminal) = Terminal::new(TestBackend::new(width, height));
248        Self {
249            terminal,
250            written: Vec::new(),
251            repainted: Vec::new(),
252        }
253    }
254
255    /// Makes it `width` columns wide and `height` rows high, as a terminal
256    /// resized; the next frame is drawn at that size.
257    pub fn resize(&mut self, width: u16, height: u16) {
258        self.terminal.backend_mut().resize(width, height);
259    }
260
261    /// Its size.
262    pub fn size(&self) -> Size {
263        let area = self.buffer().area;
264        Size::new(area.width, area.height)
265    }
266
267    /// What the last frame drew.
268    pub fn buffer(&self) -> &Buffer {
269        self.terminal.backend().buffer()
270    }
271
272    /// Each row's text as the last frame drew it.
273    pub fn rows(&self) -> Vec<String> {
274        let buffer = self.buffer();
275        let area = buffer.area;
276        (area.top()..area.bottom())
277            .map(|y| {
278                let cells = (area.left()..area.right()).filter_map(|x| buffer.cell((x, y)));
279                cells.map(|cell| cell.symbol()).collect()
280            })
281            .collect()
282    }
283
284    /// The bytes sent for the terminal so far, in order.
285    pub fn written(&self) -> &[u8] {
286        &self.written
287    }
288
289    /// The areas written again after a frame ([`RedrawArea`](crate::RedrawArea)),
290    /// clipped to the screen, in order.
291    pub fn repainted(&self) -> &[Rect] {
292        &self.repainted
293    }
294
295    /// Draws a frame with `draw`, then writes `repaint`'s cells again.
296    pub(crate) fn draw(&mut self, draw: impl FnOnce(&mut Frame), repaint: &[Rect]) {
297        let Ok(completed) = self.terminal.draw(draw);
298        let screen = completed.area;
299        let areas = repaint.iter().map(|area| area.intersection(screen));
300        self.repainted.extend(areas.filter(|area| !area.is_empty()));
301    }
302}
303
304/// Whether the view has a screen to draw to: the terminal, or a test's.
305pub(crate) fn has_screen(tui: Option<Res<Tui>>, test: Option<Res<TuiTestScreen>>) -> bool {
306    tui.is_some() || test.is_some()
307}
308
309impl Drop for Tui {
310    fn drop(&mut self) {
311        self.leave(self.keep_screen).ok();
312    }
313}
314
315/// Opens the terminal and starts reading it at startup, or asks to exit
316/// with code 1 when there is none.
317pub(crate) fn open_terminal(
318    scratch: Option<Res<Scratch>>,
319    wake: Res<Wake>,
320    mut screen: ResMut<TerminalSize>,
321    mut commands: Commands,
322) {
323    // Asked before the terminal is read, which would take its answer.
324    commands.insert_resource(Theme::of_terminal());
325    super::highlight::warm_up();
326    // Stray output goes to the text log, or nowhere without a place for it.
327    let log = scratch.map(|scratch| scratch.log().unwrap_or_else(|| PathBuf::from(NOWHERE)));
328    let opened =
329        Tui::open(log.as_deref()).and_then(|tui| Ok((tui, TerminalInput::start(wake.clone())?)));
330    match opened {
331        Ok((tui, input)) => {
332            if let Ok(size) = tui.terminal.size() {
333                screen.0 = Rect::new(0, 0, size.width, size.height);
334            }
335            commands.insert_resource(tui);
336            commands.insert_resource(input);
337        }
338        Err(failure) => {
339            error!("could not open the terminal: {failure}");
340            commands.trigger(Exit(ExitReason::Failed(1)));
341        }
342    }
343}
344
345/// Keeps the alternate screen when the app exits for the launcher to start
346/// it again: to reload, or to run another session.
347pub(crate) fn keep_screen_on_reload(reason: Option<Res<ExitReason>>, tui: Option<ResMut<Tui>>) {
348    if let (Some(mut tui), Some(reason)) = (tui, reason)
349        && matches!(*reason, ExitReason::Reload | ExitReason::Switch(_))
350    {
351        tui.keep_screen = true;
352    }
353}
354
355/// Writes the [`TerminalWrite`]s and [`CopyToClipboard`]s sent, in order
356/// each, after the frame and after Bevy's `OnAppExitSystems`, so bytes
357/// sent on the way out are written too.
358pub(crate) fn write_to_terminal(
359    (tui, test): (Option<ResMut<Tui>>, Option<ResMut<TuiTestScreen>>),
360    mut writes: MessageReader<TerminalWrite>,
361    mut copies: MessageReader<CopyToClipboard>,
362) {
363    let bytes: Vec<u8> = writes
364        .read()
365        .flat_map(|write| write.0.iter().copied())
366        .chain(copies.read().flat_map(CopyToClipboard::escape))
367        .collect();
368    if bytes.is_empty() {
369        return;
370    }
371    let mut tui = match (tui, test) {
372        (Some(tui), _) => tui,
373        (None, Some(mut test)) => return test.written.extend(bytes),
374        (None, None) => return,
375    };
376    let backend = tui.terminal.backend_mut();
377    if let Err(failure) = backend.write_all(&bytes).and_then(|()| backend.flush()) {
378        error!("could not write to the terminal: {failure}");
379    }
380}
381
382/// Asks the terminal to report key releases while a binding wants them.
383pub(crate) fn report_releases(mut tui: ResMut<Tui>, bindings: Query<&KeyBinding>) {
384    let releases = bindings.iter().any(|binding| binding.releases);
385    tui.report_releases(releases);
386}
387
388/// Runs each [`RunInTerminal`] program spawned, with the terminal: the
389/// input thread stops, the screen is given back, and both are taken again
390/// once the programs exited. Without the terminal (a test), the programs
391/// run as they are.
392pub(crate) fn run_in_terminal(world: &mut World) {
393    let mut runs = world.query_filtered::<Entity, (With<RunInTerminal>, Without<TerminalExit>)>();
394    let runs: Vec<Entity> = runs.iter(world).collect();
395    let input = world.remove_resource::<TerminalInput>();
396    let reading = input.is_some();
397    if let Some(input) = input {
398        input.stop();
399    }
400    let mut screen = None;
401    if let Some(mut tui) = world.get_resource_mut::<Tui>() {
402        match tui.leave(false) {
403            Ok(()) => screen = tui.screen.try_clone().ok(),
404            Err(failure) => error!("could not hand the terminal over: {failure}"),
405        }
406    }
407    for entity in runs {
408        let Some(mut run) = world.get_mut::<RunInTerminal>(entity) else {
409            continue;
410        };
411        let exit = run_with(&mut run.0, screen.as_ref());
412        world.entity_mut(entity).insert(TerminalExit(exit));
413    }
414    let size = world.get_resource_mut::<Tui>().and_then(|mut tui| {
415        if let Err(failure) = tui.enter() {
416            error!("could not take the terminal back: {failure}");
417        }
418        tui.terminal.size().ok()
419    });
420    // The terminal may have been resized meanwhile, unseen.
421    if let Some(size) = size
422        && let Some(mut screen) = world.get_resource_mut::<TerminalSize>()
423    {
424        screen.set_if_neq(TerminalSize(Rect::new(0, 0, size.width, size.height)));
425    }
426    let wake = world.get_resource::<Wake>().cloned();
427    if reading && let Some(wake) = &wake {
428        match TerminalInput::start(wake.clone()) {
429            Ok(input) => world.insert_resource(input),
430            Err(failure) => error!("could not read the terminal again: {failure}"),
431        }
432    }
433    world.write_message(RequestRedraw);
434    // The plugin reads the exit in the next frame.
435    if let Some(wake) = wake {
436        wake.wake();
437    }
438}
439
440/// Runs `command` until it exits, its output on `screen` when the view
441/// sends its own output elsewhere, without the session's secrets.
442fn run_with(command: &mut Command, screen: Option<&File>) -> Result<ExitStatus, String> {
443    for secret in secret_variables() {
444        command.env_remove(secret);
445    }
446    if let Some(screen) = screen {
447        let output = screen.try_clone().map_err(|failure| failure.to_string())?;
448        let errors = screen.try_clone().map_err(|failure| failure.to_string())?;
449        command
450            .stdout(Stdio::from(output))
451            .stderr(Stdio::from(errors));
452    }
453    let program = command.get_program().to_string_lossy().into_owned();
454    command
455        .status()
456        .map_err(|failure| format!("could not run {program}: {failure}"))
457}
458
459#[cfg(unix)]
460fn screen() -> io::Result<File> {
461    use std::os::fd::AsFd;
462    Ok(File::from(io::stdout().as_fd().try_clone_to_owned()?))
463}
464
465#[cfg(windows)]
466fn screen() -> io::Result<File> {
467    use std::os::windows::io::AsHandle;
468    Ok(File::from(io::stdout().as_handle().try_clone_to_owned()?))
469}
470
471#[cfg(unix)]
472fn redirect_output(log: &Path) -> io::Result<()> {
473    use std::os::fd::AsRawFd;
474    if let Some(directory) = log.parent() {
475        std::fs::create_dir_all(directory)?;
476    }
477    let file = std::fs::OpenOptions::new()
478        .create(true)
479        .append(true)
480        .open(log)?;
481    for target in [libc::STDOUT_FILENO, libc::STDERR_FILENO] {
482        // SAFETY: both descriptors are open for the duration of the call;
483        // `dup2` only replaces `target` with a copy of the log's descriptor.
484        if unsafe { libc::dup2(file.as_raw_fd(), target) } < 0 {
485            return Err(io::Error::last_os_error());
486        }
487    }
488    Ok(())
489}
490
491/// Other platforms keep their standard streams; only the screen copy is
492/// drawn to.
493#[cfg(not(unix))]
494fn redirect_output(_log: &Path) -> io::Result<()> {
495    Ok(())
496}
497
498#[cfg(test)]
499mod tests;