// Package pty wraps creack/pty with the small surface the spike needs. package pty import ( "fmt" "io" "os" "os/exec" cpty "github.com/creack/pty" ) // PTY holds a child process attached to a pseudo-terminal master fd. type PTY struct { master *os.File cmd *exec.Cmd } // Start spawns argv with stdin/stdout/stderr attached to a new PTY sized // (cols, rows). The returned PTY exposes the master fd for the parent to // read from and write to. func Start(argv []string, env []string, cols, rows uint16) (*PTY, error) { if len(argv) == 0 { return nil, fmt.Errorf("pty: empty argv") } cmd := exec.Command(argv[0], argv[1:]...) if env != nil { cmd.Env = ensureTerm(env) } else { // Default to the parent environment but force TERM to xterm-256color // so child programs assume something modern and we observe SGR + alt // screen sequences. cmd.Env = ensureTerm(os.Environ()) } ws := &cpty.Winsize{Cols: cols, Rows: rows} master, err := cpty.StartWithSize(cmd, ws) if err != nil { return nil, fmt.Errorf("pty: start %v: %w", argv, err) } return &PTY{master: master, cmd: cmd}, nil } func (p *PTY) Read(b []byte) (int, error) { if p.master == nil { return 0, io.ErrClosedPipe } return p.master.Read(b) } func (p *PTY) Write(b []byte) (int, error) { if p.master == nil { return 0, io.ErrClosedPipe } return p.master.Write(b) } func (p *PTY) Resize(cols, rows uint16) error { if p.master == nil { return io.ErrClosedPipe } return cpty.Setsize(p.master, &cpty.Winsize{Cols: cols, Rows: rows}) } // Wait blocks until the child exits and returns its exit error if any. func (p *PTY) Wait() error { if p.cmd == nil { return nil } return p.cmd.Wait() } // Pid returns the child's PID, or -1 if the process is not running. func (p *PTY) Pid() int { if p.cmd == nil || p.cmd.Process == nil { return -1 } return p.cmd.Process.Pid } // Close terminates the child (best effort) and releases the master fd. func (p *PTY) Close() error { var firstErr error if p.master != nil { if err := p.master.Close(); err != nil && firstErr == nil { firstErr = err } p.master = nil } if p.cmd != nil && p.cmd.Process != nil { _ = p.cmd.Process.Kill() } return firstErr } // envDefaults are added to the child's environment unless the parent already // set them. Modern agent TUIs check these and silently downgrade rendering // when they're missing (no truecolor, ASCII-only banners, etc.). var envDefaults = map[string]string{ "TERM": "xterm-256color", "COLORTERM": "truecolor", } // envStrip names variables we DROP before launching a child. COLUMNS / // LINES inherited from the parent shell describe the *host* terminal, // not the PTY we created — when they leak through, TUIs that prefer // env over TIOCGWINSZ render past the PTY's actual cell grid and // overwrite our chrome. var envStrip = map[string]bool{ "COLUMNS": true, "LINES": true, } func ensureTerm(env []string) []string { have := make(map[string]bool, len(envDefaults)) out := make([]string, 0, len(env)+len(envDefaults)) for _, kv := range env { key := envKey(kv) if envStrip[key] { continue } if _, isDefault := envDefaults[key]; isDefault { have[key] = true } out = append(out, kv) } for k, v := range envDefaults { if !have[k] { out = append(out, k+"="+v) } } return out } func envKey(kv string) string { for i := 0; i < len(kv); i++ { if kv[i] == '=' { return kv[:i] } } return kv }