Capture pane output
The other half of Attach and send keys: reading what a pane printed, and — since tmux accepts a command before the shell running it has necessarily finished (see Sending keys) — waiting for the right moment to read rather than reading immediately or sleeping a guessed amount. Capturing output is the guide-level discussion of why each pattern below exists; this page is the sourced code behind it — see the table at the end for exactly which file each block came from and how it’s checked.
Read what’s on screenLink to section
>>> pane = window.split(shell='sh')>>> pane.capture_pane()['$']
>>> pane.send_keys('echo "Hello world"', enter=True)
>>> pane.capture_pane()['$ echo "Hello world"', 'Hello world', '$']import type { Server } from "libtmux";
/** * Move text through a tmux buffer, and read what a pane is showing. * * The half of orchestration that is not making things happen. A named buffer * is tmux's own clipboard: anything in it can be pasted into any pane on the * server, by this process or by a person at the keyboard, without the text * passing through your program a second time. */export async function moveTextThroughABuffer( server: Server, text: string,): Promise<{ named: readonly string[]; roundTripped: readonly string[] }> { // tmux stores nothing for empty text and reports success, so a buffer whose // content is computed has to be checked before it is written — otherwise the // name is absent and the next call to read it is what fails. if (text === "") throw new Error("tmux holds no empty buffer");
await server.setBuffer("report", text); const roundTripped = await server.showBuffer("report"); const named = await server.listBuffers();
// Buffers outlive the program that made them. await server.deleteBuffer("report"); return { named, roundTripped };}
/** * What a pane is showing, scrollback included. * * `start` counts back from the visible top, so -100 asks for the last hundred * lines or as many as exist. A pane that has printed nothing answers with * nothing rather than with blank lines. */export async function readPane(server: Server): Promise<readonly string[]> { const session = await server.newSession({ name: "capture" }); const pane = session.activePane; if (pane === undefined) throw new Error("a new session always has one pane"); return pane.capture({ start: -100 });}// From examples/quickstart/main.go, shown in full on Attach and send keys.lines, err := pane.Capture(ctx, tmux.CapturePaneRequest{ Start: tmux.CaptureBoundary, End: tmux.CaptureBoundary,}) let lines = pane.capture().await?; for line in lines.iter().filter(|line| !line.as_bytes().is_empty()) { println!(" | {}", line.to_string_lossy()); }Pane pane = server.sessions().get(0).windows().get(0).panes().get(0);
pane.sendLine("echo hello from libtmux");
pane.capture().isEmpty(); // → falseNo checked .NET example calls the ordinary Pane.CaptureAsync by itself
outside a wait — the README’s own read is the block under “Wait for text
instead of guessing a delay” below, and the separate Psmux surface has its
own CaptureAsync for that different transport
(examples/LibTmux.Examples/Snippets/Psmux.cs).
const auto visible = pane.capture();if (visible.has_value()) { std::printf("%zu bytes on screen\n", visible->size());}
const auto history = pane.capture({.whole_history = true});if (history.has_value()) { std::printf("%zu bytes of scrollback\n", history->size());}// From Examples/Sources/ExampleCode/Changing.swift, readBackWhatAPanePrinted — shown in full on Attach and send keys.let lines = try await server.capture(pane)Wait for text instead of guessing a delayLink to section
Python’s checked wait is wait_for, tmux’s own signal channel, rather than
a helper that scrapes pane text for a pattern — no checked helper that
waits on pane text was found in the source for this page.
>>> server.new_session(session_name='wait_test')Session(...)>>> server.wait_for('test_channel', set_flag=True)import type { Server } from "libtmux/server";
/** * Drive tmux the way an agent does: act, then wait for the result. * * One control observer carries notifications while commands use the server's * engine. Keeping the observer open makes event-driven waits persistent; it * does not turn command output into a control-mode protocol. */export async function runAndWait(server: Server, command: string, marker: string): Promise<string> { // A connection attaches, so the session has to exist first. On a server with // none, `connect()` fails saying exactly that. const session = await server.newSession({ name: "agent" });
await using live = await server.connect({ target: session.id });
const pane = (await live.snapshot()).sessions.one({ id: session.id }).panes.one();
// Subscribe before acting. A marker printed between the command and the // subscription is one nobody is listening for, and the wait never ends. const printed = live .subscribe() .find( (event) => event.kind === "output" && event.paneId === pane.id && event.data.includes(marker), { timeoutMs: 30_000 }, );
await pane.sendKeys(command);
const event = await printed; if (event === undefined) throw new Error(`${command} never printed ${marker}`); return event.kind === "output" ? event.data : "";}
/** * Wait for the server to reach a shape, rather than for one event. * * `waitFor` reads the server, then re-reads on each notification, so it returns * at once when the condition already holds and does not miss a change that * lands while it is subscribing. */export async function buildAndSettle(server: Server, windows: readonly string[]): Promise<number> { const session = await server.newSession({ name: "settling" }); await using live = await server.connect({ target: session.id });
const bound = (await live.snapshot()).sessions.one({ id: session.id }); for (const name of windows) { // eslint-disable-next-line no-await-in-loop -- window order is observable. await bound.newWindow({ name }); }
const settled = await live.waitFor( (snapshot) => windows.every((name) => snapshot.windows.exists({ name })), { timeoutMs: 30_000 }, );
return settled.windows.count({ session: { is: { id: session.id } } });}// Command control-mode-subscribe watches a persistent tmux control client// receive changes without polling.package main
import ( "context" "errors" "fmt" "log" "time"
"github.com/libtmux/libtmux-go/tmux")
func main() { if err := start(); err != nil { log.Fatal(err) }}
// start owns cleanup because log.Fatal skips deferred calls in main.func start() error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel()
server, err := tmux.NewServer(tmux.ServerOptions{}) if err != nil { return fmt.Errorf("configure tmux server: %w", err) } return run(ctx, server)}
// run accepts injected server state so tests can isolate the example.func run(ctx context.Context, server tmux.Server) (err error) { session, err := server.NewSession(ctx, tmux.NewSessionRequest{Name: "libtmux-control"}) if err != nil { return fmt.Errorf("create session: %w", err) } defer func() { cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), time.Second) defer cleanupCancel() err = errors.Join(err, session.Kill(cleanupCtx)) }()
// docs:watching stream, err := session.OpenNotifications(ctx, tmux.NotificationOptions{}) if err != nil { return fmt.Errorf("open notification stream: %w", err) } defer func() { err = errors.Join(err, stream.Close()) }()
// Rename after subscribing; notifications do not include earlier changes. if _, err := session.Rename(ctx, "control-example"); err != nil { return fmt.Errorf("rename session: %w", err) }
for { notification, err := stream.Next(ctx) if err != nil { return fmt.Errorf("read notification: %w", err) } fmt.Printf("notification: %s\n", notification.Kind()) if notification.Kind() == tmux.ControlNotificationSessionRenamed { fmt.Println("heard the rename") return nil } } // docs:end}Session.OpenNotifications streams what tmux does as it happens rather than
polling — tmux pushes each change instead of a poll guessing how often to
ask. tmuxtest.WaitForText (see
Testing with libtmux) is the equivalent
built specifically for tests.
// From crates/libtmux/examples/scratch.rs, the wait_for_text call — shown in full on Attach and send keys.match pane.wait_for_text("hello", Duration::from_secs(5)).await? { PaneWait::Arrived => println!(" the pane printed it"), other => println!(" gave up: {other:?}"),}Rust’s wait_for_text looks before it sleeps, joins wrapped lines so a
needle spanning a wrap still matches, and returns PaneWait::Dead rather
than hanging forever if the pane’s process ends first.
package io.github.libtmux.examples;
import io.github.libtmux.Server;import io.github.libtmux.ServerConfig;import io.github.libtmux.ServerEndpoint;import io.github.libtmux.Session;import io.github.libtmux.control.ControlClient;import io.github.libtmux.control.EventSubscription;import io.github.libtmux.control.PaneOutput;import java.nio.file.Path;import java.time.Duration;import java.util.ArrayList;import java.util.List;import java.util.function.Consumer;
/** * Watches what a pane prints, as tmux pushes it, rather than polling for it. * * <pre>{@code * java WatchPaneOutput.java /tmp/libtmux-java-dev/demo/s * }</pre> */public final class WatchPaneOutput {
private WatchPaneOutput() {}
public static void main(String[] args) { Path socket = Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s"); run(socket, Duration.ofSeconds(10), output -> System.out.print(output.data())); }
/** * Separated from {@code main} so the suite can run exactly what a reader runs. * * @return everything seen before the deadline */ public static List<PaneOutput> run(Path socket, Duration watchFor, Consumer<PaneOutput> onOutput) { ServerConfig config = ServerConfig.builder() .endpoint(ServerEndpoint.socketPath(socket)) .build();
List<PaneOutput> seen = new ArrayList<>(); try (Server server = Server.open(config)) { Session session = server.sessions().get(0);
// Attaching is what makes tmux push %output at all. A client that never attaches hears // about command replies and nothing else. try (ControlClient client = ControlClient.attach(server.config(), session.id()); EventSubscription<PaneOutput> output = client.subscribeOutput(32)) { client.send("send-keys", "-t", session.name(), "echo watched", "Enter");
long deadline = System.nanoTime() + watchFor.toNanos(); while (System.nanoTime() < deadline && seen.isEmpty()) { try { var next = output.next(Duration.ofNanos(Math.max(0L, deadline - System.nanoTime()))); if (next.isEmpty()) { break; } PaneOutput arrived = next.orElseThrow(); seen.add(arrived); onOutput.accept(arrived); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } } return List.copyOf(seen); }}Attaching a ControlClient is what makes tmux push %output at all — a
client that never attaches only ever hears command replies.
await pane.SendTextAsync("echo hello-from-libtmux", cancellationToken: ct);await pane.EnterAsync(ct);
string output = await TmuxWait.UntilAsync( async token => string.Join('\n', await pane.CaptureAsync(cancellationToken: token)), text => text.Contains("hello-from-libtmux", StringComparison.Ordinal), TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(20));TmuxWait.UntilAsync polls a read against a predicate rather than sleeping
a fixed amount.
C++ has no checked snippet that waits on pane text. Server::wait_for(channel, timeout), in include/libtmux/server.hpp, uses tmux’s own wait-for signal
instead of scraping output, and its doc comment explains why that is the
safer choice when the command you are waiting on can be made to announce
itself: “a server that dies under a waiter makes tmux exit zero, which is
indistinguishable from being signalled … this reports that as a failure
instead.”
// The examples in <doc:Waiting>, and the waiting section of the README.
import LibTmux
public func waitingOnAChannel(_ server: Server, pane: Pane) async throws { // region: guides-capturing-output-176 try await server.run( "make; \(server.shellInvocation) wait-for -S built", in: pane ) try await server.wait(for: "built") // endregion}
public func watchingAFormat(_ server: Server, pane: Pane) async throws -> String? { try await server.connected(attachingTo: "work") { server, control in try await control.watch( FormatSubscription( name: "cmd", scope: .pane(pane.id), format: "#{pane_current_command}" ) ) for try await change in control.changes(named: "cmd") { return change.value } return nil }}
public func waitingOnOutput( _ server: Server, pane: Pane) async throws -> OutputWait { let ready = try RegexPattern("Listening on") let failed = try RegexPattern("EADDRINUSE|error", options: [.caseInsensitive]) let waited = try await server.waitForOutput( in: pane, matching: [ready], stoppingAt: [failed] ) return waited}
public func watchingForChanges( _ server: Server, pane: Pane, building: Bool) async throws { var mark = try await server.capture(pane, since: nil).cursor while building { let update = try await server.capture(pane, since: mark) for line in update.lines { print(line) } mark = update.cursor }}waitForOutput takes patterns for both success and failure, so a process
that fails fast is discovered immediately rather than by timing out.
Where this comes fromLink to section
| Port | Source | In this page | Checked by |
|---|---|---|---|
| Python | src/libtmux/pane.py (capture_pane), src/libtmux/server.py (wait_for) docstrings | hand-quoted | pytest runs every >>> doctest against a real, isolated tmux session on every test run |
| TypeScript | examples/capture/capture.ts (read), examples/agent/agent.ts (wait) | read whole from each file | both run against real tmux by bun test examples; agent.ts is additionally mirrored into README.md under a <!-- runs: ... --> marker checked by scripts/check-doc-runnable.ts |
| Go | examples/quickstart/main.go (read, already shown whole on the previous page), examples/control-mode-subscribe/main.go (wait) | read: hand-quoted; wait: read whole from the file | both run against real tmux as TestQuickstart / TestControlModeSubscribe; the wait file’s docs:watching region is additionally mirrored into README.md by go generate ./tmux |
| Rust | crates/libtmux/examples/scratch.rs, already shown whole on the previous page | hand-quoted excerpts of the same file | run to completion against a throwaway tmux by scripts/run-examples.sh, which CI runs |
| Java | root README.md Quickstart (read), examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java (wait) | read: hand-quoted; wait: read whole from the file | every README fence is compiled and run against real tmux by docs-tests; WatchPaneOutput is additionally run by the examples module’s ExamplesRunTest |
| .NET | root README.md, “Running something, and reading it back” | hand-quoted | one of the csharp run blocks compiled and run against real tmux by ReadmeExampleTests |
| C++ | examples/05-readme.cpp capture region (read); include/libtmux/server.hpp doc comment (wait, no fence) | hand-quoted | the capture region is quoted verbatim into README.md and checked by tools/docs/check_readme.py; the whole file is built and run by CTest |
| Swift | Examples/Sources/ExampleCode/Changing.swift (read, already shown whole on the previous page), Waiting.swift (wait) | read: hand-quoted excerpt; wait: read whole from the file | both matched against the README by Scripts/check_examples.py and run by swift test --package-path Examples |
Go, Rust, and Swift each reuse a file already shown in full on Attach and send keys: rather than dump the same file a second time, this page quotes just the relevant lines by hand, with a comment naming the source, and points back at the full listing there.