391 lines
11 KiB
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/harrybrwn/patterm/internal/pty"
|
|
"github.com/harrybrwn/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
|
|
}
|