// 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-.bytes when child starts)") flag.StringVar(&f.gridPath, "grid-out", "", "write grid dumps to this file (default: spike-.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] -- \n\nflags:\n") flag.PrintDefaults() fmt.Fprintf(os.Stderr, "\nWhile running, press the configured -hotkey to dump the grid.\nDefault sink is spike-.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 }