Attach and send keys
The task: get a handle on a tmux session, find its active pane, type a command into it, and read back what printed. This is the round trip nearly every real program built on libtmux starts from — an agent that runs a command and checks the result, a test harness driving a CLI, a dashboard polling a long-running process. “Attach” here means obtaining a live handle from your program, not a terminal takeover — see Attaching to tmux for that distinction and for the real, terminal-taking-over kind Python also exposes.
Every block below is either read straight out of a file in that port’s own repository at build time, or quoted by hand from a doctest or README where no standalone file exists — the table at the end of this page says which, names the exact source, and says how that port’s own test suite checks it. None of it was rewritten to look alike: a whole tested file naturally carries more or fewer than exactly the three steps above — error handling, a second helper, a comment explaining a choice specific to that port, or no read-back at all — and that surrounding code is left in rather than trimmed to match. A difference in shape below is real fidelity to what each port actually ships, not inconsistency.
>>> import libtmux>>> server = libtmux.Server()>>> session = server.new_session(session_name='demo')Session(...)
>>> window = session.active_window>>> 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 { Server, TmuxCommandError, type ServerSnapshot } from "libtmux";
/** * A runnable tour of the API, driven by the tests so it cannot rot. * * Every step here appears in README.md. */export async function quickstart(server: Server): Promise<ServerSnapshot> { // Nothing is read until you ask. `snapshot()` is the only step that talks to // tmux; everything reachable from it resolves locally. const session = await server.newSession({ name: "quickstart" }); const editor = await session.newWindow({ name: "editor" }); await editor.split();
const snapshot = await server.snapshot();
// Declarative filtering, serializable and stable on the wire. const found = snapshot.windows.where({ name: "editor" }).one();
// Relations are plain properties: no await, no tmux command. const paneCount = found.panes.length; if (paneCount !== 2) throw new Error(`expected two panes, saw ${String(paneCount)}`);
// A criterion is spelled like the handle accessor it filters. if (snapshot.panes.count({ currentCommand: { contains: "" } }) === 0) { throw new Error("expected panes to report a current command"); } const first = found.panes.at(0); if (first === undefined) throw new Error("expected a pane"); await first.sendKeys("echo hello-from-libtmux", { literal: true });
// Failures carry their parts rather than a formatted sentence. try { await server.setOption("not-a-real-option", "1"); } catch (error) { if (!(error instanceof TmuxCommandError)) throw error; if (error.args[0] !== "set-option") throw error; }
return snapshot;}//! Build a throwaway session, use it, and leave nothing behind.//!//! ```console//! $ cargo run --example scratch//! ```
use std::time::Duration;
use libtmux::test::unique_name;use libtmux::{NewWindowOptions, PaneWait, Server, SplitDirection, SplitOptions};
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { // An example must not build sessions on whatever server the reader // happens to be using, so this one gets a socket of its own. More than one // libtmux runs on a developer's machine, so it goes in a directory this // one owns rather than straight into the temporary directory. let root = std::path::Path::new("/tmp/libtmux-rs-dev"); std::fs::create_dir_all(root)?; let socket = root.join(format!("{}.sock", unique_name("libtmux-scratch"))); let server = Server::builder().socket_path(&socket).build()?;
// The scope kills the session whether the body succeeds or fails, so a // failure partway through does not leave a session behind. println!("server on {}", socket.display());
let output = server .with_session(unique_name("scratch").as_str(), async |session| { println!(" session {} created", session.id());
let window = session .new_window(NewWindowOptions::new("work").command("sh")) .await?; println!(" window {} running sh", window.id());
window .split(SplitOptions::new(SplitDirection::Below).command("sh")) .await?; println!(" split it: {} panes", window.panes().await?.len());
// A window always has an active pane, but saying so with a panic // would be a worse example than handling it. let Some(pane) = window.active_pane().await? else { return Ok(0); }; println!(" typing into {}", pane.id()); pane.send_line("printf 'hello from tmux\\n'").await?;
// tmux runs the shell asynchronously, so wait for the output // rather than sleeping and hoping. The outcome is checked because // a wait that reached its deadline still returns successfully. match pane.wait_for_text("hello", Duration::from_secs(5)).await? { PaneWait::Arrived => println!(" the pane printed it"), other => println!(" gave up: {other:?}"), }
// region: capture let lines = pane.capture().await?; for line in lines.iter().filter(|line| !line.as_bytes().is_empty()) { println!(" | {}", line.to_string_lossy()); } // endregion
Ok::<_, Box<dyn std::error::Error>>(lines.len()) }) .await?;
println!("captured {output} lines, then the scope killed the session");
// The lenient form is the one that answers this question. The scope killed // the only session, so tmux exited with it, and the loud form reports that // as the failure it is rather than as the empty listing this is asking for. assert!( server.sessions_or_empty().await.is_empty(), "the scope cleaned up", );
println!( "sessions left behind: {}", server.sessions_or_empty().await.len() );
server.shutdown().await?;
// tmux does not unlink its socket when the server exits, so whatever named // one owns removing it. Leaving it behind is invisible until /tmp fills up. std::fs::remove_file(&socket)?; Ok(())}// Command quickstart demonstrates a complete session, window, and pane lifecycle.package main
import ( "context" "errors" "fmt" "log" "slices" "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(), 10*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-go-quickstart", WindowName: "start", }) 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:quickstart windowName := "work" window, err := session.NewWindow(ctx, tmux.NewWindowRequest{Name: &windowName}) if err != nil { return fmt.Errorf("create window: %w", err) } pane, err := window.SplitPane(ctx, tmux.SplitPaneRequest{ Direction: tmux.PaneDirectionRight, }) if err != nil { return fmt.Errorf("split window: %w", err) } command := "printf 'libtmux ready\\n'" if err := pane.SendKeys(ctx, tmux.SendKeysRequest{Command: &command, Literal: true}); err != nil { return fmt.Errorf("send command: %w", err) } // docs:end
ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() for { lines, err := pane.Capture(ctx, tmux.CapturePaneRequest{ Start: tmux.CaptureBoundary, End: tmux.CaptureBoundary, }) if err != nil { return fmt.Errorf("capture pane: %w", err) } if slices.Contains(lines, "libtmux ready") { fmt.Println("libtmux ready") return nil } select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: } }}package io.github.libtmux.examples;
import io.github.libtmux.Layout;import io.github.libtmux.Pane;import io.github.libtmux.Server;import io.github.libtmux.ServerConfig;import io.github.libtmux.ServerEndpoint;import io.github.libtmux.Session;import io.github.libtmux.Window;import java.nio.file.Path;
/** * Lays out a session the way you would set one up by hand before starting work. * * <pre>{@code * java BuildAWorkspace.java /tmp/libtmux-java-dev/demo/s * }</pre> */public final class BuildAWorkspace {
private BuildAWorkspace() {}
public static void main(String[] args) { run(Path.of(args.length > 0 ? args[0] : "/tmp/libtmux-java-dev/demo/s")); }
/** Separated from {@code main} so the suite can run exactly what a reader runs. */ public static String run(Path socket) { ServerConfig config = ServerConfig.builder() .endpoint(ServerEndpoint.socketPath(socket)) .build();
// Closing a server closes this client. The tmux server, and the session, outlive the program // — which is the whole point of tmux and the reason nothing here kills it. try (Server server = Server.open(config)) { Session session = server.hasSession("work") ? server.sessions().stream() .filter(candidate -> candidate.name().equals("work")) .findFirst() .orElseThrow() : server.newSession("work");
Window editor = session.newWindow(window -> window.named("editor").detached()); Pane shell = editor.split(split -> split.toRight()); shell.sendLine("git status --short");
editor.selectLayout(Layout.MAIN_VERTICAL);
return "session " + session.name() + " has " + session.refresh().windows().size() + " windows"; } }}using System.Runtime.Versioning;
namespace LibTmux.Examples.Snippets;
/// <summary>The default mode: one command, one client, one materialized object.</summary>[UnsupportedOSPlatform("windows")]public static class OneShot{ /// <summary>Connects, builds a hierarchy, and types into the pane it made.</summary> [Example("Connect, build a session and window, and type into a pane")] public static async Task ConnectAndBuild() { #region ConnectAndBuild Server server = await Server.ConnectAsync(); Session session = await server.CreateSessionAsync(new NewSessionRequest(name: "build")); Window window = await session.CreateWindowAsync(new NewWindowRequest(name: "tests")); Pane pane = (await window.GetPanesAsync())[0];
await pane.SendTextAsync("dotnet test"); #endregion }
/// <summary>Creates a window and prints what tmux answered about it.</summary> [Example("One command, one materialized window")] public static async Task CreateWindow(Session session, CancellationToken ct) { #region CreateWindow Window window = await session.CreateWindowAsync(new NewWindowRequest(name: "build"), ct); Console.WriteLine($"{window.Id} {window.Index}:{window.Name}"); #endregion }}// No tmux failure is thrown. Every call answers with a value that is either// the result or the reason there isn't one.const auto sessions = server.sessions();if (!sessions.has_value()) { std::fprintf(stderr, "%s\n", sessions.error().diagnostic.c_str()); return 1;}
for (const libtmux::Session& session : *sessions) { std::printf("%s has %lld window(s)\n", std::string{session.name()}.c_str(), session.window_count());}
const libtmux::Session& session = sessions->at(0);
// Build an arrangement without composing a single tmux argument.const auto editor = session.new_window({.name = "editor"});if (!editor.has_value()) { std::fprintf(stderr, "%s\n", editor.error().diagnostic.c_str()); return 1;}
const auto logs = editor->split({.horizontal = true, .percentage = 30});if (!logs.has_value()) { std::fprintf(stderr, "%s\n", logs.error().diagnostic.c_str()); return 1;}
(void)logs->send_text("journalctl -f");(void)logs->send_key("Enter");// The examples in the README's "Change what is there" section.
import LibTmux
public func buildASessionByHand(_ server: Server) async throws -> Pane { let session = try await server.newSession(named: "work", windowName: "editor") _ = try await server.setOption("@purpose", to: "development", scope: .session(session)) let logs = try await server.newWindow(in: session, named: "logs").window let pane = try await server.splitWindow(logs, direction: .right) try await server.run("tail -f /tmp/build.log", in: pane) return pane}
public func readBackWhatAPanePrinted(_ server: Server, _ pane: Pane) async throws -> [String] { let lines = try await server.capture(pane) print(lines.suffix(5).joined(separator: "\n")) return lines}
public func spendOneProcessOnAllOfIt(_ server: Server) async throws { var plan = TmuxCommandList() for name in ["edit", "test", "logs"] { plan = plan.then("new-window", ["-d", "-n", name]) } _ = try await server.run(plan)}Finding an existing session insteadLink to section
Every snippet above creates a fresh session. A script that runs more than once usually wants the opposite — attach if a session by that name already exists, create it otherwise. See Attaching to tmux for the verified call in each port, and Filtering and querying, in practice for what to do when the lookup might match more than one.
Where this comes fromLink to section
| Port | Source | In this page | Checked by |
|---|---|---|---|
| Python | src/libtmux/server.py, session.py, pane.py docstrings | hand-quoted, composed from three separate docstrings | pytest runs every >>> doctest (testpaths includes src/libtmux) against a real, isolated tmux session on every test run |
| TypeScript | examples/quickstart/quickstart.ts | read whole from the file | run against real tmux by bun test examples; its first half is also mirrored into README.md under a <!-- runs: ... --> marker, checked line-for-line by scripts/check-doc-runnable.ts |
| Rust | crates/libtmux/examples/scratch.rs | read whole from the file | run to completion against a throwaway tmux by scripts/run-examples.sh (just examples), which CI runs and which also asserts the example leaves no session behind |
| Go | examples/quickstart/main.go | read whole from the file | the whole file runs against a real tmux server as TestQuickstart; the docs:quickstart region inside it is additionally mirrored into README.md by go generate ./tmux, and CI fails if the two drift |
| Java | examples/src/main/java/io/github/libtmux/examples/BuildAWorkspace.java | read whole from the file | run against real tmux by the examples module’s own ExamplesRunTest |
| .NET | examples/LibTmux.Examples/Snippets/OneShot.cs | read whole from the file | its ConnectAndBuild region is mirrored into README.md and checked by sync_snippets.py --check; the mirrored csharp run block is additionally compiled and run by ReadmeExampleTests |
| C++ | examples/05-readme.cpp, the connect and build regions | hand-quoted — the file also carries the regions for five other sections of the README | quoted verbatim into README.md, checked for drift by tools/docs/check_readme.py, and the whole file is built and run by CTest |
| Swift | Examples/Sources/ExampleCode/Changing.swift | read whole from the file | matched against the README’s “Change what is there” section by Scripts/check_examples.py; compiled and run through the package’s public products by swift test --package-path Examples |
“Read whole from the file” means the fence names the file with file="..."
and the page is built by reading it, so the block above cannot say anything
the file itself does not — there is no separate copy to fall out of sync.
Nothing in any port’s repository uses the marker comments (region: name /
endregion) this site’s own tooling looks for to slice a piece out of a
longer file, so where only part of a file is relevant here, that part is
quoted by hand instead, with the file and region named in the table above
rather than pretended into a file= fence that would fail to build.