Skip to content

External programs (editors, pagers, subprocesses)

When your app spawns an interactive program that needs the real TTY (Neovim, less, a password prompt, etc.), the framework must temporarily give up:

  • raw mode and the alternate screen (fullscreen apps),
  • mouse and focus-tracking sequences,
  • and, in fullscreen mode, the background thread that reads stdin for crossterm events.

Otherwise the subprocess and the TUI will fight over stdin and the display (garbled input, cursor blink on top of the editor, incomplete redraw after exit).

This page describes the supported APIs and the follow-up repaint behavior for nested components.

For non-interactive commands where the TUI should keep running and consume stdout/stderr as data, use the native-only process helpers instead of terminal handoff.


terminal_handoff (crate root)

rust
use std::io;
use tui_lipan::terminal_handoff::{
    resume_after_external_process,
    suspend_for_external_process,
};
FunctionRole
suspend_for_external_process(surface_mode)Pause the fullscreen stdin reader (when applicable), leave interactive terminal state so the child can use the TTY.
resume_after_external_process(surface_mode, mouse_enabled)Restore raw mode, alternate screen (if not inline), mouse capture if it was enabled, resume the reader, and request a host redraw on the next frame.

The framework consumes that request on the next tick: it promotes the frame to a full render, invalidates Ratatui's previous frame in memory, and drops incremental scroll snapshots so the UI matches the TTY again. The next complete frame is emitted through the normal draw path without first flushing a cleared terminal. You can still call Context::request_full_repaint() for other cases where the host display may be stale.

Stale stdin: Before the event reader is unpaused, resume_after_external_process drains the crossterm event queue and, on Unix, tcflush(TCIFLUSH) on stdin so CSI/OSC/DA tails and other mode-switch bytes are not delivered as fake key input to the focused widget.

Parameters must match the running app:

  • surface_mode - the SurfaceMode configured on the App (Fullscreen, InlineEphemeral, or InlineTranscript).
  • mouse_enabled - same as Context::mouse_capture_enabled at the time you suspend (pass through to resume_after_external_process so mouse state is restored correctly).

Keyboard enhancement (Kitty protocol): suspend/resume does not push or pop keyboard-enhancement flags; the long-lived TerminalGuard still owns that. Only terminal modes needed for a typical full-screen subprocess are toggled.

Always pair suspend and resume. Prefer an RAII guard in your own code so resume runs on panic or early return:

rust
struct Handoff {
    surface_mode: SurfaceMode,
    mouse_enabled: bool,
}

impl Drop for Handoff {
    fn drop(&mut self) {
        let _ = resume_after_external_process(
            self.surface_mode,
            self.mouse_enabled,
        );
    }
}

fn run_editor(
    surface_mode: SurfaceMode,
    mouse_enabled: bool,
) -> io::Result<()> {
    suspend_for_external_process(surface_mode)?;
    let _guard = Handoff {
        surface_mode,
        mouse_enabled,
    };
    // spawn / wait on editor...
    Ok(())
}

Suspending to the shell (ctrl+z)

Handing the terminal to the user's shell is the same problem with a different subprocess, so the framework owns it end to end. Call Context::suspend_to_shell() from whatever key your app uses for suspend:

rust
Msg::Suspend => {
    ctx.suspend_to_shell();
    Update::none()
}

Raw mode clears the tty's ISIG flag, so the terminal driver never turns ctrl+z into SIGTSTP while your app runs - nothing happens unless the app asks for it. At the next frame boundary the runner releases the terminal, stops the process group with SIGTSTP, and restores raw mode, the alternate screen, and mouse capture with a full repaint once the job is foregrounded again.

Do not raise SIGTSTP yourself. Stopping with the terminal still in raw mode leaves the shell prompt drawing over the frozen UI, with mouse motion printing escape sequences into it.

A SIGTSTP that arrives from anywhere else - kill -TSTP, a parent shell - takes the same path while the runner owns the terminal, so those stops are clean too. The signal is sent to the whole process group, matching what a ctrl+z at the tty does; children that must keep running while the TUI sleeps belong in their own process group (std::process::Command::process_group(0)).

No-op on targets without POSIX job control (Windows, wasm), so the keybinding can be wired unconditionally.


Streaming non-interactive processes (process)

tui_lipan::process is available only on native targets (#[cfg(not(target_arch = "wasm32"))]). It does not expose crossterm, ratatui, or PTY types, and it is separate from the terminal feature/LSP integrations.

Use it for commands such as rg, formatters, compilers, or small shell helpers whose stdout/stderr should become component messages:

rust
use tui_lipan::prelude::*;

enum Msg {
    Proc(ProcessEvent),
}

// Inside update():
let command = ProcessSpec::new("sh")
    .args(["-c", "printf out; printf err >&2"])
    .command(Msg::Proc);

Update::command_only(command)

ProcessEvent::Stdout(Vec<u8>) and ProcessEvent::Stderr(Vec<u8>) may arrive in chunks. ProcessEvent::Exited(ProcessExitStatus) is sent after both output streams are drained. ProcessEvent::Error(Arc<str>) reports spawn/pipe/wait errors without exposing std::process::ExitStatus in the public API.

Stdout and stderr are drained concurrently, so a child that writes heavily to both streams will not deadlock on a full pipe. The helper is for streaming data, not for programs that need terminal control; use terminal_handoff for editors, pagers, shells, password prompts, and other interactive programs.

Cancellation: unkeyed .command(...) tasks normally run to completion. Use .command_keyed(key, TaskPolicy::LatestOnly, ...) or process_command_keyed when newer work should cancel a running process. Keyed process commands observe the background cancellation token; on cancellation they kill the child, drain stdout/stderr, and suppress stale component messages. For manual use, stream_process_until accepts a cancellation predicate with the same kill-and-drain behavior.


Run blocking work on the UI thread

Command::spawn and Link::command(...) run closures on a worker thread. That is wrong for terminal_handoff: the main thread still holds the ratatui terminal and keeps drawing.

Use Command::new so suspend → subprocess → resume runs on the same thread as the event loop:

rust
use tui_lipan::prelude::*;

// Inside update():
let link = ctx.link().clone();
let surface_mode = ctx.surface_mode();
let mouse_enabled = ctx.mouse_capture_enabled();
let initial = ctx.state.draft.clone();

Update {
    dirty: false,
    command: Some(Command::new(move || {
        match run_my_editor(&initial, surface_mode, mouse_enabled) {
            Ok(text) => link.send(Msg::EditorDone(text)),
            Err(e) => link.send(Msg::EditorFailed(e)),
        }
    })),
}

Use link.send(...) inside the closure to push follow-up messages; they are processed in the same message drain as other updates.


Force a full redraw after handoff

Update::layout() rebuilds only the emitting component scope, while Update::full() rebuilds from the root. Neither alone tells Ratatui that another process changed the physical terminal, so unchanged cells may still be omitted from its next buffer diff.

Call Context::request_full_repaint() from the message handler that runs after the external program exits (success or failure), before or alongside your usual state updates:

rust
Msg::EditorDone(text) => {
    ctx.request_full_repaint();
    ctx.state.draft = text;
    Update::full()
}

On the next loop iteration the runner promotes the frame to a full render, invalidates Ratatui's previous-frame cache in memory, and emits the complete drawable frame through the normal draw path.


Summary checklist

  1. Use suspend_for_external_process / resume_after_external_process with correct surface_mode and mouse_enabled.
  2. Run that sequence on the UI thread via Command::new, not Command::spawn / link.command.
  3. After returning to the TUI, call ctx.request_full_repaint() when a full frame repaint is required (especially for nested components).
  4. For ctrl+z, call ctx.suspend_to_shell() instead of doing any of the above yourself.

MPL-2.0