Build TUI Pane Plugins

A [[panes]] entry turns your binary into a real terminal pane inside herdr. The pane runs your program (typically a ratatui/crossterm app) and afterwards behaves like any other pane: movable, resizable, zoomable. herdr-file-viewer and herdr-agent-usage are the reference Rust implementations.

The stack

CrateVersion used in the wildRole
ratatui0.30Widget rendering
crossterm0.29Terminal I/O, events, raw mode
ansi-to-tui8.0Convert ANSI text (e.g. agent output) into styled Text
ignore0.4Fast file traversal (file-viewer)
[dependencies]
ratatui = "0.30"
crossterm = "0.29"
ansi-to-tui = "8"
anyhow = "1"

Declare the pane in the manifest:

[[panes]]
id = "viewer"
title = "Files"
placement = "split"
command = ["./target/release/my-viewer"]

And the usual Rust [[build]] so GitHub installs compile it:

[[build]]
command = ["cargo", "build", "--release"]

The pane command starts inside the pane’s pty with herdr’s plugin environment already set (HERDR_PLUGIN_ENTRYPOINT_ID, HERDR_SOCKET_PATH, and friends). Your app just does normal ratatui initialization.

Placement options

PlacementBehaviorUse for
overlayTemporary zoomed pane; restores previous focus and zoom on closePeeks (zoetrope’s graph view)
popupSession-modal singleton above the UIQuick settings, dashboards
splitSplits beside the current paneSide-by-side work (file-viewer)
tabOwn full tabDedicated workspace surfaces
zoomedOpens maximized and takes focusMain-event apps
[[panes]]
id = "graph"
title = "zoetrope"
placement = "overlay"
command = ["bash", "herdr/open.sh"]

[[panes]]
id = "dashboard"
title = "Agent quota"
placement = "popup"
width = 78
height = 37

popup accepts width/height (herdr clamps to the terminal). Size it deliberately: agent-usage’s comment records that the default half-size popup is 24 rows and their 33-row UI opened below the fold until they set height = 37.

You can also open panes imperatively from an action:

herdr plugin pane open --plugin my-plugin --entrypoint viewer --placement split --focus

The popup is not a normal pane:

  • It is a session-modal singleton: one popup at a time, floating above the UI.
  • It has no pane id and emits no pane lifecycle events (pane.closed does not fire when it closes).
  • Opening fails with ui_busy (“a popup pane is already open”) when another modal is up: Settings, copy mode, or an existing popup.

Handle the failure rather than crashing on it:

let out = herdr(&["plugin", "pane", "open", "--plugin", env!("CARGO_PKG_NAME"),
                  "--entrypoint", "dashboard", "--placement", "popup"])?;
if out.contains("ui_busy") {
    eprintln!("popup blocked: another modal is open");
    return Ok(()); // silent, retry on the next event
}

Gotcha: because popups have no pane id, you cannot pane read them or address them from the CLI. If your content must be scriptable, use overlay or split instead.

Opened panes are normal panes

A split, tab, overlay, or zoomed pane is a first-class pane once open:

  • The user can move, resize, close, and zoom it.
  • It has a pane id, appears in pane list, and participates in focus and lifecycle events.
  • Ownership follows moves: if the user drags it to another tab, herdr keeps the pane-to-plugin mapping.

This differs sharply from the popup’s modal world. Design for it: persist nothing in the pane process that you cannot rebuild after a restart, because the user can kill the pane at any moment.

Rendering agent output

To show agent text inside your TUI, read it with the normal read commands and convert ANSI:

let out = std::process::Command::new(herdr_bin())
    .args(["agent", "read", target, "--source", "recent", "--lines", "40", "--ansi"])
    .output()?;
let text = ansi_to_tui::ansi_to_text(&out.stdout)?;

For the read sources and their limits (including agent_not_idle on alternate-screen agents), see read-agent-state.md.

A minimal pane binary

use anyhow::Result;
use crossterm::{
    event::{self, Event, KeyCode},
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
    ExecutableCommand,
};
use ratatui::{prelude::*, widgets::Paragraph};

fn main() -> Result<()> {
    enable_raw_mode()?;
    std::io::stdout().execute(EnterAlternateScreen)?;
    let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;

    loop {
        terminal.draw(|f| {
            f.render_widget(Paragraph::new("my pane. q to quit."), f.area());
        })?;
        if event::poll(std::time::Duration::from_millis(100))? {
            if let Event::Key(k) = event::read()? {
                if k.kind == event::KeyEventKind::Press && k.code == KeyCode::Char('q') {
                    break;
                }
            }
        }
    }

    disable_raw_mode()?;
    std::io::stdout().execute(LeaveAlternateScreen)?;
    Ok(())
}

Gotcha: plugin panes inherit herdr’s environment, including HERDR_SOCKET_PATH. If you launch a debug build of your pane manually against a different server, clear herdr’s socket override first or you will talk to the wrong instance.

Keep the render loop tight: redraw only on input or state change, and never do blocking I/O between draw calls. Herdr’s own performance rules apply to pane apps in spirit.