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.


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 (ratatui buffer clear + full draw).

The framework consumes that request on the next tick: it promotes the frame to full render, runs Terminal::clear to reset the back buffer, and drops incremental scroll snapshots so the UI matches the TTY again. 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(())
}

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

When a nested child returns Update::full(), the runtime may schedule a layout-only reconcile for that scope. After the host terminal was repainted by another process, that is often not enough to refresh the entire frame.

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 (full reconcile + draw), not only a nested layout pass.


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).

MIT OR Apache-2.0