libtmux for Perl
Perl-based tmux client
tmux client library for Perl. Control tmux, connect to and create servers, navigate sessions, windows, and panes, send keys, capture pane contents
use v5.44;use Config;use FindBin;use lib "$FindBin::Bin/../scripts/lib";use LibTmuxFixture;
# This complete demo owns a Linux child-reaping scope, including tmux panes.BEGIN { die 'Requires Linux x86_64 LP64' unless $^O eq 'linux' && $Config::Config{archname} =~ /x86_64/ && $Config::Config{ptrsize} == 8 && $Config::Config{ivsize} == 8; syscall(157, 36, 1, 0, 0, 0) == 0 or die "subreaper: $!";}
LibTmuxFixture->run( sub ($server, $root) { my $session = $server->new_session( name => 'demo', command => ['/bin/cat'] ); my $editor = $session->window_links->one->window; $server->rename_window(window => $editor, name => 'editor'); my $right = $server->split_pane( pane => $editor->panes->one, horizontal => true, command => ['/bin/cat'] ); my $logs = $server->new_window( session => $session, name => 'logs', command => ['/bin/cat'] ); $server->select_pane(pane => $right); my $snapshot = $server->snapshot; my $link = $snapshot->window_links->one({ window => {-is => {id => $logs->id}} }); $server->select_window(link => $link); $snapshot = $server->snapshot;
for my $entry ($snapshot->sessions->@*) { say 'session ', $entry->name; for my $link ($entry->window_links->@*) { my $window = $link->window; my $ids = join ' ', map { $_->id } $window->panes->@*; my $label = $link->index . ':' . $window->name; say ' window ', $label, ': ', $ids; } } my $panes = $snapshot->windows->one({name => 'editor'})->panes ->where({width => {-gte => 1}}) ->grep(sub ($pane) { $pane->height >= 1 }); my $selected = $panes->one({active => true}); die 'unexpected pane membership' unless $panes->count == 2; my $name = $selected->window->name; say $selected->id, ' is in window ', $name; });use v5.44;use Config;use FindBin;use lib "$FindBin::Bin/../scripts/lib";use LibTmuxFixture;
# This complete demo owns a Linux child-reaping scope, including tmux panes.BEGIN { die 'Requires Linux x86_64 LP64' unless $^O eq 'linux' && $Config::Config{archname} =~ /x86_64/ && $Config::Config{ptrsize} == 8 && $Config::Config{ivsize} == 8; syscall(157, 36, 1, 0, 0, 0) == 0 or die "subreaper: $!";}
try { LibTmuxFixture->run( sub ($server, $root) { my $session = $server->new_session( name => 'demo', command => ['/bin/cat'] ); my $editor = $session->window_links->one->window; $server->rename_window(window => $editor, name => 'editor'); my $right = $server->split_pane( pane => $editor->panes->one, horizontal => true, command => ['/bin/cat'] ); my $logs = $server->new_window( session => $session, name => 'logs', command => ['/bin/cat'] ); $server->select_pane(pane => $right); my $snapshot = $server->snapshot; my $link = $snapshot->window_links->one({ window => {-is => {id => $logs->id}} }); $server->select_window(link => $link); $snapshot = $server->snapshot;
for my $entry ($snapshot->sessions->@*) { say 'session ', $entry->name; for my $link ($entry->window_links->@*) { my $window = $link->window; my $ids = join ' ', map { $_->id } $window->panes->@*; my $label = $link->index . ':' . $window->name; say ' window ', $label, ': ', $ids; } } my $panes = $snapshot->windows->one({name => 'editor'})->panes ->where({width => {-gte => 1}}) ->grep(sub ($pane) { $pane->height >= 1 }); my $selected = $panes->one({active => true}); die 'unexpected pane membership' unless $panes->count == 2; my $name = $selected->window->name; say $selected->id, ' is in window ', $name; } );}catch ($error) { warn "tmux quickstart failed: $error\n"; die $error;}use v5.44;use Config;use FindBin;use lib "$FindBin::Bin/../scripts/lib";use LibTmuxFixture;
# This complete demo owns a Linux child-reaping scope, including tmux panes.BEGIN { die 'Requires Linux x86_64 LP64' unless $^O eq 'linux' && $Config::Config{archname} =~ /x86_64/ && $Config::Config{ptrsize} == 8 && $Config::Config{ivsize} == 8; syscall(157, 36, 1, 0, 0, 0) == 0 or die "subreaper: $!";}
LibTmuxFixture->run( sub ($server, $root) { my $session = $server->new_session( name => 'demo', command => ['/bin/cat'] ); my $failure; try { my $editor = $session->window_links->one->window; $server->rename_window(window => $editor, name => 'editor'); my $right = $server->split_pane( pane => $editor->panes->one, horizontal => true, command => ['/bin/cat'] ); my $logs = $server->new_window( session => $session, name => 'logs', command => ['/bin/cat'] ); $server->select_pane(pane => $right); my $snapshot = $server->snapshot; my $link = $snapshot->window_links->one({ window => {-is => {id => $logs->id}} }); $server->select_window(link => $link); $snapshot = $server->snapshot;
for my $entry ($snapshot->sessions->@*) { say 'session ', $entry->name; for my $link ($entry->window_links->@*) { my $window = $link->window; my $ids = join ' ', map { $_->id } $window->panes->@*; my $label = $link->index . ':' . $window->name; say ' window ', $label, ': ', $ids; } } my $panes = $snapshot->windows->one({name => 'editor'})->panes ->where({width => {-gte => 1}}) ->grep(sub ($pane) { $pane->height >= 1 }); my $selected = $panes->one({active => true}); die 'unexpected pane membership' unless $panes->count == 2; my $name = $selected->window->name; say $selected->id, ' is in window ', $name; } catch ($error) { $failure = $error } $server->kill_session(session => $session); die $failure if defined $failure; });use v5.44;use Config;use FindBin;use lib "$FindBin::Bin/../scripts/lib";use LibTmuxFixture;
# This complete demo owns a Linux child-reaping scope, including tmux panes.BEGIN { die 'Requires Linux x86_64 LP64' unless $^O eq 'linux' && $Config::Config{archname} =~ /x86_64/ && $Config::Config{ptrsize} == 8 && $Config::Config{ivsize} == 8; syscall(157, 36, 1, 0, 0, 0) == 0 or die "subreaper: $!";}
try { LibTmuxFixture->run( sub ($server, $root) { my $session = $server->new_session( name => 'demo', command => ['/bin/cat'] ); my $failure; try { my $editor = $session->window_links->one->window; $server->rename_window(window => $editor, name => 'editor'); my $right = $server->split_pane( pane => $editor->panes->one, horizontal => true, command => ['/bin/cat'] ); my $logs = $server->new_window( session => $session, name => 'logs', command => ['/bin/cat'] ); $server->select_pane(pane => $right); my $snapshot = $server->snapshot; my $link = $snapshot->window_links->one( { window => {-is => {id => $logs->id}} } ); $server->select_window(link => $link); $snapshot = $server->snapshot;
for my $entry ($snapshot->sessions->@*) { say 'session ', $entry->name; for my $link ($entry->window_links->@*) { my $window = $link->window; my $ids = join ' ', map { $_->id } $window->panes->@*; my $label = $link->index . ':' . $window->name; say ' window ', $label, ': ', $ids; } } my $panes = $snapshot->windows->one({name => 'editor'}) ->panes->where({width => {-gte => 1}}) ->grep(sub ($pane) { $pane->height >= 1 }); my $selected = $panes->one({active => true}); die 'unexpected pane membership' unless $panes->count == 2; my $name = $selected->window->name; say $selected->id, ' is in window ', $name; } catch ($error) { $failure = $error } $server->kill_session(session => $session); die $failure if defined $failure; } );}catch ($error) { warn "tmux quickstart failed: $error\n"; die $error;}Run this example
Linux x86_64 LP64, Perl 5.44.0, tmux 3.3a or newer, and the LibTmux core distribution installed from source. Save both files with the displayed directories. No CPAN release is claimed.
Save the program above as examples/quickstart.pl in a new directory, along with the files below.
The included standard-library fixture owns its private socket and verifies child exit. Cleanup also tears down the created session explicitly. The program stops its private tmux server and removes any temporary files it created when it finishes. Error handling adds diagnostics; every version checks required results and deadlines.
scripts/lib/LibTmuxFixture.pm
package LibTmuxFixture;use v5.44;use File::Temp qw(tempdir);use File::Path qw(remove_tree);use LibTmux::Server;use Errno qw(ECHILD EINTR);
sub _children ($pid) { open my $in, '<', "/proc/$pid/task/$pid/children" or return (); my $line = do { local $/; <$in> }; close $in; return grep { length } split /\s+/, $line;}
sub _stamp ($pid) { open my $in, '<', "/proc/$pid/stat" or return undef; my $line = do { local $/; <$in> }; close $in; return undef unless $line =~ /\A[0-9]+ \(.*\) (.*)\z/s; my @fields = split /\s+/, $1; return $fields[19];}
sub _tree ($pid, $owned, $order) { return if exists $owned->{$pid}; my $stamp = _stamp($pid); return unless defined $stamp; $owned->{$pid} = $stamp; push @$order, $pid; _tree($_, $owned, $order) for _children($pid);}
sub run ($class, $code, %args) { my %allowed = map { $_ => true } qw(executable timeout max_output env); die 'fixture selectors/configuration cannot be overridden' if grep { !$allowed{$_} } keys %args; my %baseline = map { $_ => true } _children($$); my $root = tempdir('libtmux-perl-fixture-XXXX', TMPDIR => 1, CLEANUP => 0); my $server = LibTmuxFixture::Server->new( socket_path => "$root/s", config_file => '/dev/null', executable => $ENV{LIBTMUX_TEST_TMUX} // 'tmux', timeout => 0.8, %args ); my ($result, $failure); local $SIG{TERM} = sub { die 'fixture interrupted by TERM' }; local $SIG{INT} = sub { die 'fixture interrupted by INT' }; try { $result = $code->($server, $root) } catch ($error) { $failure = $error } my $cleanup_error; { local $SIG{TERM} = 'IGNORE'; local $SIG{INT} = 'IGNORE'; try { my (%owned, @order); my $pid = $server->{fixture_pid}; if (!$pid && -S "$root/s") { try { $pid = 0 + $server->cmd('display-message', '-p', '#{pid}')->stdout } catch ($error) { die $error unless ref($error) && $error->isa('LibTmux::X::NoServer') } } _tree($pid, \%owned, \@order) if $pid; if (-S "$root/s") { try { $server->kill_server } catch ($error) { die $error unless ref($error) && $error->isa('LibTmux::X::NoServer') } }
# This author fixture runs in an isolated test process. Preserve children # that existed before its scope, including nested/borrowed fixtures. _tree($_, \%owned, \@order) for grep { !$baseline{$_} } _children($$); for my $child (@order) { my $stamp = _stamp($child); kill 'KILL', $child if defined($stamp) && $stamp eq $owned{$child}; } local $SIG{ALRM} = sub { die 'owned fixture child did not exit' }; alarm 1; for my $child (@order) { my $waited; do { $waited = waitpid($child, 0) } while ($waited < 0 && $! == EINTR); my $wait_error = 0 + $!; my $stamp = _stamp($child); die 'owned fixture process remained after teardown' if defined($stamp) && $stamp eq $owned{$child}; die 'fixture child exit was not observed' if $waited < 0 && $wait_error != ECHILD; } alarm 0; } catch ($error) { alarm 0; $cleanup_error = $error } } remove_tree($root); if (defined $failure) { if (ref($failure) && $failure isa LibTmux::X::Creation) { my $cause = $failure->cause; warn "fixture acquisition cause: $cause\n" if defined $cause; } warn "fixture cleanup also failed: $cleanup_error" if defined $cleanup_error; die $failure; } die $cleanup_error if defined $cleanup_error; return $result;}
package LibTmuxFixture::Server;use parent 'LibTmux::Server';
sub new_session ($self, %args) { my $session = $self->SUPER::new_session(%args); $self->{fixture_pid} = 0 + $self->cmd('display-message', '-p', '#{pid}')->stdout unless $self->{fixture_pid}; return $session;}1;Setup and run
perl examples/quickstart.plExpected output
session demo window 0:editor: %0 %1 window 1:logs: %2%1 is in window editorInstall LibTmux
Unpublished development distribution. Requires Perl 5.44.0, Linux x86_64 LP64, and tmux 3.3a or newer.
Workspace Manager
Install LibTmux-Workspace
The workspace package is separate from the core library.
$ cpm install --global ./packages/workspaceFrom the source checkout after core installation. No CPAN release.
Build the workspace from a program
my $workspace = LibTmux::Workspace->new( config => { schema => 'libtmux.workspace/v1', sessions => [ { name => 'workspace-example', windows => [ { name => 'api', cwd => $root, layout => 'even-horizontal', panes => [ {command => ['/bin/sleep', '120']}, { command => ['/bin/sleep', '120'], horizontal => true }, ], } ], } ], });die 'plan mutated state' if $workspace->plan->{mutates};my $loaded = $workspace->load(server => $server);die 'load failed' unless $loaded->{status} eq 'loaded';die 'panes missing' unless $server->snapshot->panes->count == 2;die 'repeat did not skip' unless $workspace->load(server => $server, existing => 'skip') ->{skipped}->@* == 1;say 'workspace load and repeat: ok';
Excerpt of examples/workspace.pl,
which perl scripts/run-examples.pl --installed "$prefix" runs in the repository's CI
(.github/workflows/scaffold.yml).
Topics
Subject-by-subject, with Perl in every code block.
Guides
Task guides for Perl.
Examples
Complete programs, sourced from the code each port tests.
Concepts
The domain model every port shares.