Implement an interactive shell with PTY
masterTo create a fully interactive shell experience, you must handle three key aspects:
- PTY Lifecycle: Start the command with
pty.Start(c)and ensure the PTY is closed when finished. - Terminal Resizing: Listen for
syscall.SIGWINCHsignals and usepty.InheritSize(os.Stdin, ptmx)to synchronize the PTY size with the user's terminal window. - Raw Mode: Put the local terminal (
os.Stdin) into raw mode usinggolang.org/x/term.MakeRawso that keystrokes are passed directly to the PTY without being processed by the local terminal's line discipline. Remember to restore the terminal state usingterm.Restorewhen finished.
Finally, bridge the input and output by copying os.Stdin to the PTY and the PTY to os.Stdout.
package main
import (
"io"
"log"
"os"
"os/exec"
"os/signal"
"syscall"
"github.com/creack/pty"
"golang.org/x/term"
)
func test() error {
// Create arbitrary command.
c := exec.Command("bash")
// Start the command with a pty.
ptmx, err := pty.Start(c)
if err != nil {
return err
}
// Make sure to close the pty at the end.
defer func() { _ = ptmx.Close() }() // Best effort.
// Handle pty size.
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGWINCH)
go func() {
for range ch {
if err := pty.InheritSize(os.Stdin, ptmx); err != nil {
log.Printf("error resizing pty: %s", err)
}
}
}()
ch <- syscall.SIGWINCH // Initial resize.
defer func() { signal.Stop(ch); close(ch) }() // Cleanup signals when done.
// Set stdin in raw mode.
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
panic(err)
}
defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() // Best effort.
// Copy stdin to the pty and the pty to stdout.
// NOTE: The goroutine will keep reading until the next keystroke before returning.
go func() { _, _ = io.Copy(ptmx, os.Stdin) }()
_, _ = io.Copy(os.Stdout, ptmx)
return nil
}
func main() {
if err := test(); err != nil {
log.Fatal(err)
}
}