ptyx is a Go library that provides a simple, cross-platform API for managing pseudo-terminals (PTY) and terminal TTYs.
- Cross-Platform PTY: Simple API to spawn processes in a pseudo-terminal on macOS, Linux, BSDs (using
ptmx) and on Windows (usingConPTY). - TTY Control: Functions to control the local terminal, including setting raw mode, getting terminal size, and receiving resize notifications.
- I/O Bridge: A
Muxutility to easily connect the local terminal's stdin/stdout to the PTY session. - Zero External Dependencies: Relies only on the standard library and the official
golang.org/xpackages (sys,term).
ptyx acts as a bridge between a user's terminal and a pseudo-terminal (PTY), enabling interactive command-line applications. It abstracts away the platform-specific details of PTY/TTY management.
The architecture consists of three main components:
-
User Interfaces (Console): This represents the user's actual terminal.
ptyxcaptures input from itsstdin(Pipe In) and writes output to itsstdout(Pipe Out). -
Interface Handlers (ptyx): The core of the library, which manages the communication pipes. It ensures that data flows smoothly between the user's console and the underlying pseudo-terminal.
-
Pseudoterminal (Backend): The OS-level PTY implementation that runs the child process.
- On Windows, this is the ConPTY API.
- On Unix-like systems (macOS, Linux), this is a traditional
/dev/ptmxdevice.
go get github.com/KennethanCeyer/ptyx# Interactive shell
go run ./cmd/shell
# Local spinner/progress (no PTY)
go run ./cmd/spinner
# Show terminal color support
go run ./cmd/color
# Test ANSI color passthrough by running the color demo in a PTY
go run ./cmd/passthrough
# Raw stdin echo
go run ./cmd/echo
# Capture and parse terminal output as events
go run ./cmd/event
# Send input to a program waiting in a PTY
go run ./cmd/scan
# Run a sequence of commands in a shell and handle interruption
go run ./cmd/sequence
# Run a shell inside a PTY, which itself runs inside a PTY
go run ./cmd/nested
# Resize bridge
go run ./cmd/resize -- /bin/sh
# Run an arbitrary command in a PTY
go run ./cmd/run -- bash -lc "echo hi; read -p 'press:' x; echo done"ptyx is designed to be simple to use. Here are a few examples showing how to accomplish common tasks.
This is the most basic use case: running a command in a pseudo-terminal and streaming its output.
package main
import (
"context"
"io"
"log"
"os"
"github.com/KennethanCeyer/ptyx"
)
func main() {
// Spawn a command in a new PTY session.
// A context is used for cancellation.
s, err := ptyx.Spawn(context.Background(), ptyx.SpawnOpts{
Prog: "ping",
Args: []string{"8.8.8.8"},
})
if err != nil {
log.Fatalf("spawn failed: %v", err)
}
// Ensure the session is closed to clean up resources.
defer s.Close()
// Stream the PTY output to standard out.
go io.Copy(os.Stdout, s.PtyReader())
// Wait for the process to exit.
if err := s.Wait(); err != nil {
log.Printf("process wait failed: %v", err)
}
}For interactive applications like a terminal emulator, you need to connect the user's TTY to the PTY session. ptyx makes this easy.
The following example creates a complete, cross-platform interactive shell.
package main
import (
"context"
"fmt"
"log"
"runtime"
"github.com/KennethanCeyer/ptyx"
)
func main() {
// 1. Get a handle to the local console/TTY.
c, err := ptyx.NewConsole()
if err != nil {
log.Fatalf("failed to create console: %v", err)
}
defer c.Close()
// 2. Enable virtual terminal processing for color support (especially on Windows).
c.EnableVT()
// 3. Set the TTY to raw mode to pass all key presses directly to the PTY.
st, err := c.MakeRaw()
if err == nil {
defer c.Restore(st)
}
// 4. Get the initial terminal size.
w, h := c.Size()
shell := "sh"
if runtime.GOOS == "windows" {
shell = "powershell.exe"
}
// 5. Spawn the shell in a new PTY with the correct dimensions.
s, err := ptyx.Spawn(context.Background(), ptyx.SpawnOpts{Prog: shell, Cols: w, Rows: h})
if err != nil {
log.Fatalf("failed to spawn: %v", err)
}
defer s.Close()
// 6. Create a multiplexer to bridge I/O between the local TTY and the PTY.
m := ptyx.NewMux()
if err := m.Start(c, s); err != nil {
log.Fatalf("failed to start mux: %v", err)
}
defer m.Stop()
// 7. Handle terminal resize events.
go func() {
for range c.OnResize() {
_ = s.Resize(c.Size())
}
}()
// 8. Wait for the PTY session to end.
if err := s.Wait(); err != nil {
if exitErr, ok := err.(*ptyx.ExitError); ok {
fmt.Printf("\nProcess exited with code %d\n", exitErr.ExitCode)
}
}
}You can gracefully terminate a PTY session by cancelling its context. A common use case is handling user interruptions (e.g., Ctrl+C).
package main
import (
"context"
"io"
"log"
"os"
"os/signal"
"github.com/KennethanCeyer/ptyx"
)
func main() {
// Create a context that is cancelled when an interrupt signal is received.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
log.Println("Spawning 'ping' process. Press Ctrl+C to terminate.")
s, err := ptyx.Spawn(ctx, ptyx.SpawnOpts{Prog: "ping", Args: []string{"8.8.8.8"}})
if err != nil {
log.Fatalf("spawn failed: %v", err)
}
defer s.Close()
go io.Copy(os.Stdout, s.PtyReader())
// Wait for the process to exit. This will block until the context is cancelled.
if err := s.Wait(); err != nil {
log.Printf("Process terminated: %v", err)
}
}type Console interface {
In() io.Reader
Out() io.Writer
Err() *os.File
IsATTYOut() bool
Size() (int, int)
MakeRaw() (RawState, error)
Restore(RawState) error
EnableVT()
OnResize() <-chan struct{}
Close() error
}
type Session interface {
PtyReader() io.Reader
PtyWriter() io.Writer
Resize(cols, rows int) error
Wait() error
Kill() error
Close() error
Pid() int
CloseStdin() error
}
type Mux interface {
Start(c Console, s Session) error
Stop() error
}
type SpawnOpts struct {
Prog string
Args []string
Env []string
Dir string
Cols int
Rows int
}
type ExitError struct {
ExitCode int
}
type RawState interface{}- Unix/macOS/WSL: full PTY support using openpty or /dev/ptmx.
- Windows: Full ConPTY session support, console VT, and resize.