Initial patterm project

This commit is contained in:
2026-05-14 13:37:20 +01:00
commit 69ef09aac4
40 changed files with 6521 additions and 0 deletions

62
internal/vt/emulator.go Normal file
View File

@@ -0,0 +1,62 @@
// Package vt wraps a headless virtual terminal emulator behind a small
// Go interface. The intent is that all cgo to libghostty-vt is confined
// to the GhosttyEmulator implementation in this package.
package vt
// Screen identifies which buffer is currently displayed.
type Screen uint8
const (
ScreenPrimary Screen = iota
ScreenAlternate
)
// CursorState is a snapshot of cursor position and visibility.
type CursorState struct {
Col, Row uint16
Visible bool
}
// Emulator is the headless VT used by the daemon (and by the milestone-1 spike).
//
// Implementations are not required to be safe for concurrent use. The spike
// CLI funnels all calls through a single goroutine.
type Emulator interface {
// Write feeds bytes from the PTY master into the emulator. It returns
// the number of bytes consumed (always len(p) on success).
Write(p []byte) (int, error)
// Resize updates the emulator's cell grid. The caller is responsible
// for issuing TIOCSWINSZ on the PTY itself.
Resize(cols, rows uint16) error
// PlainText returns the active screen rendered as plain text, with
// soft-wrapped lines unwrapped and trailing whitespace trimmed.
PlainText() (string, error)
// ScreenText returns the active screen as fixed screen rows. Unlike
// PlainText, this preserves row boundaries so a host UI can repaint
// into a clipped viewport.
ScreenText() (string, error)
// SerializeVT returns the active screen as a VT byte sequence that, when
// written to a fresh terminal, reproduces the visible state (colours,
// styles, cursor, hyperlinks, etc.). Used as the daemon's "catch-up
// frame" for newly-attached clients.
SerializeVT() ([]byte, error)
// Cursor returns cursor position and visibility on the active screen.
Cursor() (CursorState, error)
// ActiveScreen reports whether we are on the primary or alternate buffer.
ActiveScreen() (Screen, error)
// OnWritePTY registers a callback that fires when the emulator wants
// to write bytes back to the PTY master (e.g. responses to DA / DSR
// queries). The callback runs synchronously inside Write and must not
// recurse into the emulator.
OnWritePTY(fn func([]byte))
// Close releases any underlying resources.
Close() error
}

390
internal/vt/ghostty.go Normal file
View File

@@ -0,0 +1,390 @@
//go:build !nocgo
package vt
/*
#cgo CFLAGS: -I${SRCDIR}/../../third_party/libghostty-vt/install/include -DGHOSTTY_STATIC
#cgo LDFLAGS: -L${SRCDIR}/../../third_party/libghostty-vt/install/lib -l:libghostty-vt.a -lm -lpthread
#include <stdint.h>
#include <stddef.h>
#include <stdlib.h>
#include <ghostty/vt.h>
// Forward declaration of the exported Go callback (defined in ghostty_cgo.go).
extern void pattermGhosttyWritePty(GhosttyTerminal terminal,
void *userdata,
const uint8_t *data,
size_t len);
// Constant device-attributes response. vim/htop/etc. send DA1 (CSI c) on
// startup and block waiting for a reply; without this they hang forever.
// Conformance 62 = VT220-class with no advertised features, which is what
// kitty advertises and is enough for every TUI we've tested.
static bool patterm_da_cb(GhosttyTerminal terminal,
void *userdata,
GhosttyDeviceAttributes *out) {
(void)terminal; (void)userdata;
out->primary.conformance_level = 62;
out->primary.num_features = 0;
out->secondary.device_type = 1; // VT220
out->secondary.firmware_version = 100; // arbitrary
out->secondary.rom_cartridge = 0;
out->tertiary.unit_id = 0;
return true;
}
// Constant XTVERSION response. Some agent TUIs query this; without a
// response they wait. The GhosttyString memory must stay valid until the
// callback returns — a static const string is fine.
static GhosttyString patterm_xtversion_cb(GhosttyTerminal terminal,
void *userdata) {
(void)terminal; (void)userdata;
static const char ver[] = "patterm 0.0.1";
GhosttyString s;
s.ptr = (const uint8_t *)ver;
s.len = sizeof(ver) - 1;
return s;
}
// Constant ENQ response (empty). Some shells send ENQ on startup.
static GhosttyString patterm_enq_cb(GhosttyTerminal terminal, void *userdata) {
(void)terminal; (void)userdata;
GhosttyString s; s.ptr = NULL; s.len = 0;
return s;
}
// Helpers that hide casts cgo can't express directly.
static GhosttyResult patterm_install_write_pty(GhosttyTerminal t) {
return ghostty_terminal_set(t,
GHOSTTY_TERMINAL_OPT_WRITE_PTY,
(const void *)pattermGhosttyWritePty);
}
static GhosttyResult patterm_install_query_handlers(GhosttyTerminal t) {
GhosttyResult rc;
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES,
(const void *)patterm_da_cb);
if (rc != GHOSTTY_SUCCESS) return rc;
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_XTVERSION,
(const void *)patterm_xtversion_cb);
if (rc != GHOSTTY_SUCCESS) return rc;
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_ENQUIRY,
(const void *)patterm_enq_cb);
return rc;
}
static GhosttyResult patterm_set_userdata(GhosttyTerminal t, uintptr_t ud) {
return ghostty_terminal_set(t,
GHOSTTY_TERMINAL_OPT_USERDATA,
(const void *)ud);
}
static GhosttyFormatterTerminalOptions patterm_plain_fmt_opts(void) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
opts.unwrap = true;
opts.trim = true;
return opts;
}
static GhosttyFormatterTerminalOptions patterm_screen_fmt_opts(void) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
opts.unwrap = false;
opts.trim = false;
return opts;
}
// VT-format options for the daemon catch-up frame. Emits the active screen
// as VT escape sequences with cursor, style, hyperlink, mode, and tabstop
// state included so a freshly-attached client renders the existing screen
// correctly. unwrap/trim are NOT set — preserving wrap state and trailing
// cells is important for a faithful replay.
static GhosttyFormatterTerminalOptions patterm_vt_fmt_opts(void) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_VT;
opts.extra.modes = true;
opts.extra.scrolling_region = true;
opts.extra.tabstops = true;
opts.extra.screen.cursor = true;
opts.extra.screen.style = true;
opts.extra.screen.hyperlink = true;
return opts;
}
*/
import "C"
import (
"errors"
"fmt"
"runtime"
"runtime/cgo"
"sync"
"sync/atomic"
"unsafe"
)
// GhosttyEmulator is the libghostty-vt-backed Emulator implementation.
//
// The C terminal handle is not thread-safe. Callers must serialise access;
// the spike CLI does this by running all calls on one goroutine, so the
// mutex below is a defensive belt-and-braces rather than the primary
// safety mechanism.
type GhosttyEmulator struct {
mu sync.Mutex
term C.GhosttyTerminal
handle cgo.Handle
closed bool
// onWrite is read from a cgo callback that is invoked synchronously
// from inside Write() — i.e. while e.mu is already held by this
// goroutine. Taking the mutex again would deadlock, so the field is
// stored atomically and read without the mutex.
onWrite atomic.Pointer[writeCallback]
cols uint16
rows uint16
}
// writeCallback wraps the callback func so it can sit in atomic.Pointer.
type writeCallback struct{ fn func([]byte) }
// NewGhosttyEmulator creates a new emulator with the given grid size.
func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
if cols == 0 || rows == 0 {
return nil, fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows)
}
e := &GhosttyEmulator{cols: cols, rows: rows}
opts := C.GhosttyTerminalOptions{
cols: C.uint16_t(cols),
rows: C.uint16_t(rows),
max_scrollback: 0,
}
if rc := C.ghostty_terminal_new(nil, &e.term, opts); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: ghostty_terminal_new failed: %s", ghosttyResultStr(rc))
}
// Park ourselves in cgo's handle table so the C callback can find us.
e.handle = cgo.NewHandle(e)
if rc := C.patterm_set_userdata(e.term, C.uintptr_t(uintptr(e.handle))); rc != C.GHOSTTY_SUCCESS {
e.handle.Delete()
C.ghostty_terminal_free(e.term)
return nil, fmt.Errorf("vt: set userdata failed: %s", ghosttyResultStr(rc))
}
if rc := C.patterm_install_write_pty(e.term); rc != C.GHOSTTY_SUCCESS {
e.handle.Delete()
C.ghostty_terminal_free(e.term)
return nil, fmt.Errorf("vt: install write_pty failed: %s", ghosttyResultStr(rc))
}
if rc := C.patterm_install_query_handlers(e.term); rc != C.GHOSTTY_SUCCESS {
e.handle.Delete()
C.ghostty_terminal_free(e.term)
return nil, fmt.Errorf("vt: install query handlers failed: %s", ghosttyResultStr(rc))
}
// Make sure Close runs even if the caller forgets. Programs that hold
// the emulator for their full lifetime can ignore this.
runtime.SetFinalizer(e, func(x *GhosttyEmulator) { _ = x.Close() })
return e, nil
}
func (e *GhosttyEmulator) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return 0, errors.New("vt: emulator closed")
}
C.ghostty_terminal_vt_write(
e.term,
(*C.uint8_t)(unsafe.Pointer(&p[0])),
C.size_t(len(p)),
)
return len(p), nil
}
func (e *GhosttyEmulator) Resize(cols, rows uint16) error {
if cols == 0 || rows == 0 {
return fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows)
}
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return errors.New("vt: emulator closed")
}
rc := C.ghostty_terminal_resize(e.term,
C.uint16_t(cols), C.uint16_t(rows),
0, 0, // pixel dimensions: we don't use image protocols in the spike
)
if rc != C.GHOSTTY_SUCCESS {
return fmt.Errorf("vt: resize failed: %s", ghosttyResultStr(rc))
}
e.cols, e.rows = cols, rows
return nil
}
func (e *GhosttyEmulator) PlainText() (string, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return "", errors.New("vt: emulator closed")
}
opts := C.patterm_plain_fmt_opts()
return e.formatPlainLocked(opts)
}
func (e *GhosttyEmulator) ScreenText() (string, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return "", errors.New("vt: emulator closed")
}
opts := C.patterm_screen_fmt_opts()
return e.formatPlainLocked(opts)
}
func (e *GhosttyEmulator) formatPlainLocked(opts C.GhosttyFormatterTerminalOptions) (string, error) {
var fmtr C.GhosttyFormatter
if rc := C.ghostty_formatter_terminal_new(nil, &fmtr, e.term, opts); rc != C.GHOSTTY_SUCCESS {
return "", fmt.Errorf("vt: formatter_terminal_new failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_formatter_free(fmtr)
var buf *C.uint8_t
var n C.size_t
if rc := C.ghostty_formatter_format_alloc(fmtr, nil, &buf, &n); rc != C.GHOSTTY_SUCCESS {
return "", fmt.Errorf("vt: format_alloc failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_free(nil, buf, n)
if buf == nil || n == 0 {
return "", nil
}
return C.GoStringN((*C.char)(unsafe.Pointer(buf)), C.int(n)), nil
}
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil, errors.New("vt: emulator closed")
}
opts := C.patterm_vt_fmt_opts()
var fmtr C.GhosttyFormatter
if rc := C.ghostty_formatter_terminal_new(nil, &fmtr, e.term, opts); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: formatter_terminal_new (vt) failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_formatter_free(fmtr)
var buf *C.uint8_t
var n C.size_t
if rc := C.ghostty_formatter_format_alloc(fmtr, nil, &buf, &n); rc != C.GHOSTTY_SUCCESS {
return nil, fmt.Errorf("vt: format_alloc (vt) failed: %s", ghosttyResultStr(rc))
}
defer C.ghostty_free(nil, buf, n)
if buf == nil || n == 0 {
return nil, nil
}
return C.GoBytes(unsafe.Pointer(buf), C.int(n)), nil
}
func (e *GhosttyEmulator) Cursor() (CursorState, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return CursorState{}, errors.New("vt: emulator closed")
}
var col, row C.uint16_t
var visible C.bool
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_X, unsafe.Pointer(&col)); rc != C.GHOSTTY_SUCCESS {
return CursorState{}, fmt.Errorf("vt: get cursor_x failed: %s", ghosttyResultStr(rc))
}
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_Y, unsafe.Pointer(&row)); rc != C.GHOSTTY_SUCCESS {
return CursorState{}, fmt.Errorf("vt: get cursor_y failed: %s", ghosttyResultStr(rc))
}
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE, unsafe.Pointer(&visible)); rc != C.GHOSTTY_SUCCESS {
return CursorState{}, fmt.Errorf("vt: get cursor_visible failed: %s", ghosttyResultStr(rc))
}
return CursorState{Col: uint16(col), Row: uint16(row), Visible: bool(visible)}, nil
}
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return 0, errors.New("vt: emulator closed")
}
var s C.GhosttyTerminalScreen
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN, unsafe.Pointer(&s)); rc != C.GHOSTTY_SUCCESS {
return 0, fmt.Errorf("vt: get active_screen failed: %s", ghosttyResultStr(rc))
}
if s == C.GHOSTTY_TERMINAL_SCREEN_ALTERNATE {
return ScreenAlternate, nil
}
return ScreenPrimary, nil
}
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {
if fn == nil {
e.onWrite.Store(nil)
return
}
e.onWrite.Store(&writeCallback{fn: fn})
}
// writePTYCallback is called from the exported cgo shim. It runs inside a
// vt_write() that already owns e.mu, so it MUST NOT take the mutex.
func (e *GhosttyEmulator) writePTYCallback() func([]byte) {
cb := e.onWrite.Load()
if cb == nil {
return nil
}
return cb.fn
}
func (e *GhosttyEmulator) Close() error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return nil
}
e.closed = true
runtime.SetFinalizer(e, nil)
C.ghostty_terminal_free(e.term)
e.term = nil
e.handle.Delete()
return nil
}
func ghosttyResultStr(rc C.GhosttyResult) string {
switch rc {
case C.GHOSTTY_SUCCESS:
return "SUCCESS"
case C.GHOSTTY_OUT_OF_MEMORY:
return "OUT_OF_MEMORY"
case C.GHOSTTY_INVALID_VALUE:
return "INVALID_VALUE"
case C.GHOSTTY_OUT_OF_SPACE:
return "OUT_OF_SPACE"
case C.GHOSTTY_NO_VALUE:
return "NO_VALUE"
default:
return fmt.Sprintf("unknown(%d)", int(rc))
}
}
// Compile-time assertion that GhosttyEmulator satisfies Emulator.
var _ Emulator = (*GhosttyEmulator)(nil)

View File

@@ -0,0 +1,38 @@
//go:build !nocgo
package vt
/*
// This preamble must contain DECLARATIONS ONLY — cgo refuses to compile
// a file that both defines functions in its preamble and has //export
// directives. The helper definitions live in ghostty.go's preamble.
#include <stdint.h>
#include <stddef.h>
#include <ghostty/vt.h>
*/
import "C"
import (
"runtime/cgo"
"unsafe"
)
//export pattermGhosttyWritePty
func pattermGhosttyWritePty(_ C.GhosttyTerminal, userdata unsafe.Pointer, data *C.uint8_t, length C.size_t) {
if userdata == nil || data == nil || length == 0 {
return
}
h := cgo.Handle(uintptr(userdata))
v := h.Value()
e, ok := v.(*GhosttyEmulator)
if !ok || e == nil {
return
}
cb := e.writePTYCallback()
if cb == nil {
return
}
buf := C.GoBytes(unsafe.Pointer(data), C.int(length))
cb(buf)
}

View File

@@ -0,0 +1,30 @@
//go:build nocgo
// This file provides a stub GhosttyEmulator for `go vet` / `go build`
// invocations that pass the `nocgo` build tag, so the rest of the Go code can
// be checked without `libghostty-vt` being installed. The stub fails at
// construction time — there is no functional emulator in `nocgo` builds.
package vt
import "errors"
type GhosttyEmulator struct{}
func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
return nil, errors.New("vt: built with -tags nocgo; libghostty-vt is unavailable")
}
func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub }
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub }
func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub }
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }
func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub }
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub }
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {}
func (e *GhosttyEmulator) Close() error { return nil }
var errStub = errors.New("vt: built with -tags nocgo")
var _ Emulator = (*GhosttyEmulator)(nil)