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
| Crate | Version used in the wild | Role |
|---|---|---|
ratatui | 0.30 | Widget rendering |
crossterm | 0.29 | Terminal I/O, events, raw mode |
ansi-to-tui | 8.0 | Convert ANSI text (e.g. agent output) into styled Text |
ignore | 0.4 | Fast 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
| Placement | Behavior | Use for |
|---|---|---|
overlay | Temporary zoomed pane; restores previous focus and zoom on close | Peeks (zoetrope’s graph view) |
popup | Session-modal singleton above the UI | Quick settings, dashboards |
split | Splits beside the current pane | Side-by-side work (file-viewer) |
tab | Own full tab | Dedicated workspace surfaces |
zoomed | Opens maximized and takes focus | Main-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
Popup semantics
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.closeddoes 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 readthem or address them from the CLI. If your content must be scriptable, useoverlayorsplitinstead.
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.