Files
patterm/cmd/spike/main.go

391 lines
11 KiB
Go

// cmd/spike is the milestone-1 throwaway: spawn a child in a PTY, pump bytes
// through a libghostty-vt-backed emulator, and dump the rendered grid as
// plain text on idle or hotkey.
//
// Stdin from the host terminal is forwarded raw to the child PTY, so vim,
// htop, claude, codex and friends behave as if you ran them directly. We are
// explicitly NOT encoding keys ourselves yet — that's a daemon-era concern.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/hjbdev/patterm/internal/pty"
"github.com/hjbdev/patterm/internal/vt"
cpty "github.com/creack/pty"
"golang.org/x/term"
)
const (
defaultCols = 120
defaultRows = 40
defaultIdleMS = 1000
readBufferBytes = 64 * 1024
)
// Known hotkey aliases mapped to their raw control bytes.
var hotkeyAliases = map[string]byte{
"ctrl-]": 0x1d, // GS — default, but some layouts/terminals swallow it
"ctrl-\\": 0x1c, // FS — sends SIGQUIT in cooked mode but raw passes through
"ctrl-^": 0x1e, // RS
"ctrl-_": 0x1f, // US
"ctrl-t": 0x14,
"ctrl-o": 0x0f,
"ctrl-space": 0x00, // NUL
}
type spikeFlags struct {
cols, rows int
idleMS int
followHost bool
noPassthrough bool
bytesPath string
gridPath string
gridToStderr bool
hotkey string
debugStdin bool
}
func main() {
var f spikeFlags
flag.IntVar(&f.cols, "cols", defaultCols, "PTY columns (overridden by host size if -follow-host)")
flag.IntVar(&f.rows, "rows", defaultRows, "PTY rows (overridden by host size if -follow-host)")
flag.IntVar(&f.idleMS, "dump-after-idle", defaultIdleMS, "dump grid to stderr after this many ms of PTY silence (0 disables)")
flag.BoolVar(&f.followHost, "follow-host", true, "use the host terminal's size and follow SIGWINCH")
flag.BoolVar(&f.noPassthrough, "no-stdin", false, "don't forward host stdin to the child PTY")
flag.StringVar(&f.bytesPath, "bytes-out", "", "tee raw PTY bytes to this file (default: spike-<pid>.bytes when child starts)")
flag.StringVar(&f.gridPath, "grid-out", "", "write grid dumps to this file (default: spike-<pid>.grid.log). Use - for stderr (will visually corrupt alt-screen TUIs).")
flag.BoolVar(&f.gridToStderr, "grid-stderr", false, "also echo each grid dump to stderr. Convenient for non-TUI children (echo, bash); avoid with vim/htop/agent CLIs.")
flag.StringVar(&f.hotkey, "hotkey", "ctrl-]", "key chord that triggers a grid dump: ctrl-], ctrl-\\, ctrl-^, ctrl-_, ctrl-t, ctrl-o, ctrl-space")
flag.BoolVar(&f.debugStdin, "debug-stdin", false, "log every stdin byte to stderr as we read it (for working out what your terminal sends)")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: spike [flags] -- <argv>\n\nflags:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nWhile running, press the configured -hotkey to dump the grid.\nDefault sink is spike-<pid>.grid.log; tail -f it in another terminal.\n")
}
flag.Parse()
argv := flag.Args()
if len(argv) == 0 {
flag.Usage()
os.Exit(2)
}
hotkey, ok := hotkeyAliases[strings.ToLower(f.hotkey)]
if !ok {
fmt.Fprintf(os.Stderr, "spike: unknown -hotkey %q (see -h for options)\n", f.hotkey)
os.Exit(2)
}
startCols, startRows := uint16(f.cols), uint16(f.rows)
if f.followHost {
if c, r, ok := hostSize(); ok {
startCols, startRows = c, r
}
}
if err := run(argv, startCols, startRows, f.idleMS, f.followHost, !f.noPassthrough, f.bytesPath, f.gridPath, f.gridToStderr, hotkey, f.debugStdin); err != nil {
fmt.Fprintf(os.Stderr, "spike: %v\n", err)
os.Exit(1)
}
}
func run(argv []string, cols, rows uint16, idleMS int, followHost, stdinPassthrough bool, bytesPath, gridPath string, gridToStderr bool, hotkey byte, debugStdin bool) error {
em, err := vt.NewGhosttyEmulator(cols, rows)
if err != nil {
return fmt.Errorf("emulator: %w", err)
}
defer em.Close()
child, err := pty.Start(argv, nil, "", cols, rows)
if err != nil {
return fmt.Errorf("pty: %w", err)
}
defer child.Close()
// Wire WRITE_PTY back to the child's stdin so DA/DSR query responses
// reach the program asking.
em.OnWritePTY(func(b []byte) {
if _, werr := child.Write(b); werr != nil {
fmt.Fprintf(os.Stderr, "\r\nspike: write_pty back to child failed: %v\r\n", werr)
}
})
// Set up the bytes tee.
if bytesPath == "" {
bytesPath = fmt.Sprintf("spike-%d.bytes", child.Pid())
}
bytesFile, err := os.Create(bytesPath)
if err != nil {
return fmt.Errorf("bytes tee: %w", err)
}
defer bytesFile.Close()
// Set up the grid sink. By default this is a file, not stderr, because
// writing a multi-line dump to the host terminal while an alt-screen TUI
// owns it visually corrupts the host display (the TUI inside the PTY is
// fine; libghostty-vt's grid is fine; only the host's render breaks).
var gridSink *os.File
gridIsStderr := false
switch gridPath {
case "-":
gridSink = os.Stderr
gridIsStderr = true
case "":
gridPath = fmt.Sprintf("spike-%d.grid.log", child.Pid())
fallthrough
default:
gridSink, err = os.Create(gridPath)
if err != nil {
return fmt.Errorf("grid log: %w", err)
}
defer gridSink.Close()
}
fmt.Fprintf(os.Stderr, "spike: child pid=%d, bytes=%s, grid=%s (%dx%d)\r\n",
child.Pid(), bytesPath, gridPath, cols, rows)
if !gridIsStderr {
fmt.Fprintf(os.Stderr, "spike: tail -f %s in another terminal to watch dumps live\r\n", gridPath)
}
// Set host stdin to raw mode so key sequences (arrows, Ctrl-C, etc.)
// reach the child intact. Save the state for restore.
var restoreState *term.State
if stdinPassthrough && term.IsTerminal(int(os.Stdin.Fd())) {
st, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("stdin raw: %w", err)
}
restoreState = st
defer term.Restore(int(os.Stdin.Fd()), restoreState)
}
// Idle detection: PTY reader updates lastWrite; ticker checks if we
// crossed the threshold without writes and prints a grid dump.
var lastWriteNS atomic.Int64
lastWriteNS.Store(time.Now().UnixNano())
var lastDumpNS atomic.Int64
var dumpRequest = make(chan string, 4)
// Coordinated shutdown.
var wg sync.WaitGroup
done := make(chan struct{})
closeDone := sync.OnceFunc(func() { close(done) })
// Reader: PTY -> stdout passthrough + emulator + bytes tee.
wg.Add(1)
go func() {
defer wg.Done()
defer closeDone()
buf := make([]byte, readBufferBytes)
for {
n, rerr := child.Read(buf)
if n > 0 {
chunk := buf[:n]
// Tee to host stdout so the user can see the TUI normally.
_, _ = os.Stdout.Write(chunk)
// Tee to bytes file for golden replay.
_, _ = bytesFile.Write(chunk)
// Feed the emulator.
if _, werr := em.Write(chunk); werr != nil {
fmt.Fprintf(os.Stderr, "\r\nspike: emulator.Write error: %v\r\n", werr)
}
lastWriteNS.Store(time.Now().UnixNano())
}
if rerr != nil {
// EIO from the PTY master is the normal "child closed its
// side" signal on Linux; treat it like EOF.
if rerr != io.EOF && !errors.Is(rerr, syscall.EIO) {
fmt.Fprintf(os.Stderr, "\r\nspike: pty read: %v\r\n", rerr)
}
return
}
}
}()
// Writer: stdin -> PTY, watching for the dump hotkey.
if stdinPassthrough {
wg.Add(1)
go func() {
defer wg.Done()
buf := make([]byte, 1024)
for {
select {
case <-done:
return
default:
}
n, rerr := os.Stdin.Read(buf)
if n > 0 {
chunk := buf[:n]
if debugStdin {
fmt.Fprintf(os.Stderr, "\r\nspike[debug-stdin]: %d bytes:", n)
for _, b := range chunk {
fmt.Fprintf(os.Stderr, " %02x", b)
}
fmt.Fprintf(os.Stderr, "\r\n")
}
out := make([]byte, 0, len(chunk))
for _, b := range chunk {
if b == hotkey {
select {
case dumpRequest <- "hotkey":
default:
// channel full; user is mashing the hotkey,
// dumps are still coming
}
continue
}
out = append(out, b)
}
if len(out) > 0 {
if _, werr := child.Write(out); werr != nil {
return
}
}
}
if rerr != nil {
return
}
}
}()
}
// SIGWINCH propagation.
if followHost {
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
wg.Add(1)
go func() {
defer wg.Done()
defer signal.Stop(winch)
for {
select {
case <-done:
return
case <-winch:
if c, r, ok := hostSize(); ok {
_ = child.Resize(c, r)
_ = em.Resize(c, r)
}
}
}
}()
}
// Idle ticker: enqueue an "idle" dump request when crossing the threshold.
if idleMS > 0 {
wg.Add(1)
go func() {
defer wg.Done()
tick := time.NewTicker(time.Duration(idleMS) * time.Millisecond / 4)
defer tick.Stop()
for {
select {
case <-done:
return
case <-tick.C:
now := time.Now().UnixNano()
lw := lastWriteNS.Load()
ld := lastDumpNS.Load()
if now-lw >= int64(idleMS)*int64(time.Millisecond) && lw > ld {
lastDumpNS.Store(now)
dumpRequest <- "idle"
}
}
}
}()
}
// Dump worker: serialises grid reads. PlainText is not cheap and we
// don't want overlapping calls.
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
case reason := <-dumpRequest:
dumpGrid(em, reason, gridSink, gridIsStderr || gridToStderr)
}
}
}()
// Wait for the child to exit, then close everything down.
exitErr := child.Wait()
closeDone()
wg.Wait()
// Final dump for the record.
dumpGrid(em, "final", gridSink, gridIsStderr || gridToStderr)
if restoreState != nil {
_ = term.Restore(int(os.Stdin.Fd()), restoreState)
}
fmt.Fprintf(os.Stderr, "spike: child exited (%v); bytes=%s grid=%s\r\n", exitErr, bytesPath, gridPath)
return nil
}
// dumpGrid renders the emulator's active screen and writes it to sink.
//
// When sinkIsTTY is true the lines are terminated with CRLF so they render
// correctly even when stdin is in raw mode. Otherwise we use plain LF —
// log files don't want CRs.
func dumpGrid(em *vt.GhosttyEmulator, reason string, sink *os.File, sinkIsTTY bool) {
txt, err := em.PlainText()
if err != nil {
fmt.Fprintf(os.Stderr, "\r\nspike: PlainText (%s): %v\r\n", reason, err)
return
}
cur, _ := em.Cursor()
scr, _ := em.ActiveScreen()
screenName := "primary"
if scr == vt.ScreenAlternate {
screenName = "alternate"
}
eol := "\n"
if sinkIsTTY {
eol = "\r\n"
}
sep := strings.Repeat("-", 78)
header := fmt.Sprintf("[grid dump: %s @ %s | screen=%s cursor=(%d,%d) visible=%v]",
reason, time.Now().Format(time.RFC3339Nano), screenName, cur.Col, cur.Row, cur.Visible)
fmt.Fprintf(sink, "%s%s%s%s%s%s", eol, sep, eol, header, eol, sep+eol)
for _, line := range strings.Split(txt, "\n") {
fmt.Fprintf(sink, "%s%s", line, eol)
}
fmt.Fprintf(sink, "%s%s", sep, eol)
// If the sink is a file (not the host TTY), print a one-line breadcrumb
// to stderr so the user knows the hotkey fired — but only for explicit
// user-triggered dumps (hotkey), and only when the child is on the
// primary screen. Skipping idle/final dumps keeps the host terminal
// quiet during normal interactive use.
if !sinkIsTTY && reason == "hotkey" && scr != vt.ScreenAlternate {
fmt.Fprintf(os.Stderr, "\r\nspike: dumped grid -> %s\r\n", sink.Name())
}
}
func hostSize() (cols, rows uint16, ok bool) {
ws, err := cpty.GetsizeFull(os.Stdin)
if err != nil {
return 0, 0, false
}
if ws.Cols == 0 || ws.Rows == 0 {
return 0, 0, false
}
return ws.Cols, ws.Rows, true
}