//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 #include #include #include // 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 void patterm_scroll_viewport_top(GhosttyTerminal t) { GhosttyTerminalScrollViewport beh; beh.tag = GHOSTTY_SCROLL_VIEWPORT_TOP; beh.value.delta = 0; ghostty_terminal_scroll_viewport(t, beh); } static void patterm_scroll_viewport_bottom(GhosttyTerminal t) { GhosttyTerminalScrollViewport beh; beh.tag = GHOSTTY_SCROLL_VIEWPORT_BOTTOM; beh.value.delta = 0; ghostty_terminal_scroll_viewport(t, beh); } static void patterm_scroll_viewport_delta(GhosttyTerminal t, intptr_t d) { GhosttyTerminalScrollViewport beh; beh.tag = GHOSTTY_SCROLL_VIEWPORT_DELTA; beh.value.delta = d; ghostty_terminal_scroll_viewport(t, beh); } 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" "strings" "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: 5000, } 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 } // Size returns the current grid (cols, rows). SPEC ยง7 get_process_output // and get_process_status surface these. func (e *GhosttyEmulator) Size() (uint16, uint16) { e.mu.Lock() defer e.mu.Unlock() return e.cols, e.rows } 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 } type styledCellSGR struct { fgSet, bgSet bool fgR, fgG, fgB uint8 bgR, bgG, bgB uint8 bold, italic, faint, blink, inverse, invisible, strikethrough, overline bool underline int } func (s styledCellSGR) equal(o styledCellSGR) bool { return s == o } func sgrSeq(s styledCellSGR) string { var b strings.Builder b.WriteString("\x1b[0") if s.bold { b.WriteString(";1") } if s.faint { b.WriteString(";2") } if s.italic { b.WriteString(";3") } if s.underline != 0 { b.WriteString(";4") } if s.blink { b.WriteString(";5") } if s.inverse { b.WriteString(";7") } if s.invisible { b.WriteString(";8") } if s.strikethrough { b.WriteString(";9") } if s.overline { b.WriteString(";53") } if s.fgSet { fmt.Fprintf(&b, ";38;2;%d;%d;%d", s.fgR, s.fgG, s.fgB) } if s.bgSet { fmt.Fprintf(&b, ";48;2;%d;%d;%d", s.bgR, s.bgG, s.bgB) } b.WriteByte('m') return b.String() } func (e *GhosttyEmulator) StyledScreenVT() ([]byte, error) { e.mu.Lock() defer e.mu.Unlock() if e.closed { return nil, errors.New("vt: emulator closed") } var state C.GhosttyRenderState if rc := C.ghostty_render_state_new(nil, &state); rc != C.GHOSTTY_SUCCESS { return nil, fmt.Errorf("vt: render_state_new failed: %s", ghosttyResultStr(rc)) } defer C.ghostty_render_state_free(state) if rc := C.ghostty_render_state_update(state, e.term); rc != C.GHOSTTY_SUCCESS { return nil, fmt.Errorf("vt: render_state_update failed: %s", ghosttyResultStr(rc)) } var rows C.uint16_t if rc := C.ghostty_render_state_get(state, C.GHOSTTY_RENDER_STATE_DATA_ROWS, unsafe.Pointer(&rows)); rc != C.GHOSTTY_SUCCESS { return nil, fmt.Errorf("vt: render_state rows failed: %s", ghosttyResultStr(rc)) } var iter C.GhosttyRenderStateRowIterator if rc := C.ghostty_render_state_row_iterator_new(nil, &iter); rc != C.GHOSTTY_SUCCESS { return nil, fmt.Errorf("vt: row_iterator_new failed: %s", ghosttyResultStr(rc)) } defer C.ghostty_render_state_row_iterator_free(iter) if rc := C.ghostty_render_state_get(state, C.GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, unsafe.Pointer(&iter)); rc != C.GHOSTTY_SUCCESS { return nil, fmt.Errorf("vt: render_state row iterator failed: %s", ghosttyResultStr(rc)) } var cells C.GhosttyRenderStateRowCells if rc := C.ghostty_render_state_row_cells_new(nil, &cells); rc != C.GHOSTTY_SUCCESS { return nil, fmt.Errorf("vt: row_cells_new failed: %s", ghosttyResultStr(rc)) } defer C.ghostty_render_state_row_cells_free(cells) var out strings.Builder for row := 0; row < int(rows) && C.ghostty_render_state_row_iterator_next(iter); row++ { if rc := C.ghostty_render_state_row_get(iter, C.GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, unsafe.Pointer(&cells)); rc != C.GHOSTTY_SUCCESS { return nil, fmt.Errorf("vt: render_state row cells failed: %s", ghosttyResultStr(rc)) } rowCells := make([]struct { text string sgr styledCellSGR draw bool }, 0, int(e.cols)) lastDraw := -1 for col := 0; col < int(e.cols) && C.ghostty_render_state_row_cells_next(cells); col++ { var cell C.GhosttyCell _ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, unsafe.Pointer(&cell)) var wide C.GhosttyCellWide _ = C.ghostty_cell_get(cell, C.GHOSTTY_CELL_DATA_WIDE, unsafe.Pointer(&wide)) if wide == C.GHOSTTY_CELL_WIDE_SPACER_TAIL || wide == C.GHOSTTY_CELL_WIDE_SPACER_HEAD { rowCells = append(rowCells, struct { text string sgr styledCellSGR draw bool }{}) continue } var style C.GhosttyStyle style.size = C.size_t(unsafe.Sizeof(style)) _ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, unsafe.Pointer(&style)) sgr := styledCellSGR{ bold: bool(style.bold), italic: bool(style.italic), faint: bool(style.faint), blink: bool(style.blink), inverse: bool(style.inverse), invisible: bool(style.invisible), strikethrough: bool(style.strikethrough), overline: bool(style.overline), underline: int(style.underline), } var fg C.GhosttyColorRgb if rc := C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR, unsafe.Pointer(&fg)); rc == C.GHOSTTY_SUCCESS { sgr.fgSet, sgr.fgR, sgr.fgG, sgr.fgB = true, uint8(fg.r), uint8(fg.g), uint8(fg.b) } var bg C.GhosttyColorRgb if rc := C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR, unsafe.Pointer(&bg)); rc == C.GHOSTTY_SUCCESS { sgr.bgSet, sgr.bgR, sgr.bgG, sgr.bgB = true, uint8(bg.r), uint8(bg.g), uint8(bg.b) } var graphemeLen C.uint32_t _ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, unsafe.Pointer(&graphemeLen)) text := "" if graphemeLen > 0 { buf := make([]C.uint32_t, int(graphemeLen)) _ = C.ghostty_render_state_row_cells_get(cells, C.GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, unsafe.Pointer(&buf[0])) rs := make([]rune, len(buf)) for i, r := range buf { rs[i] = rune(r) } text = string(rs) } draw := text != "" || sgr.bgSet if draw { lastDraw = col if text == "" { text = " " } } rowCells = append(rowCells, struct { text string sgr styledCellSGR draw bool }{text: text, sgr: sgr, draw: draw}) } if lastDraw < 0 { continue } fmt.Fprintf(&out, "\x1b[%d;1H", row+1) cur := styledCellSGR{} out.WriteString("\x1b[0m") for col := 0; col <= lastDraw && col < len(rowCells); col++ { cell := rowCells[col] if !cell.draw { if !cur.equal(styledCellSGR{}) { cur = styledCellSGR{} out.WriteString("\x1b[0m") } out.WriteByte(' ') continue } if !cell.sgr.equal(cur) { cur = cell.sgr out.WriteString(sgrSeq(cur)) } out.WriteString(cell.text) } out.WriteString("\x1b[0m") } return []byte(out.String()), 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 } // Title returns the most recent window title set by OSC 0/2 escape // sequences. The libghostty-vt API hands back a borrowed pointer that // stays valid only until the next vt_write/reset, so we copy out to a // Go string under the same mutex that gates writes. An empty string // (len=0) means no title has been set. func (e *GhosttyEmulator) Title() (string, error) { e.mu.Lock() defer e.mu.Unlock() if e.closed { return "", errors.New("vt: emulator closed") } var s C.GhosttyString if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_TITLE, unsafe.Pointer(&s)); rc != C.GHOSTTY_SUCCESS { return "", fmt.Errorf("vt: get title failed: %s", ghosttyResultStr(rc)) } if s.ptr == nil || s.len == 0 { return "", nil } return C.GoStringN((*C.char)(unsafe.Pointer(s.ptr)), C.int(s.len)), 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 } // ScrollViewportTop scrolls the viewport to the top of the scrollback. func (e *GhosttyEmulator) ScrollViewportTop() error { e.mu.Lock() defer e.mu.Unlock() if e.closed { return errors.New("vt: emulator closed") } C.patterm_scroll_viewport_top(e.term) return nil } // ScrollViewportBottom scrolls the viewport to the bottom (active area). func (e *GhosttyEmulator) ScrollViewportBottom() error { e.mu.Lock() defer e.mu.Unlock() if e.closed { return errors.New("vt: emulator closed") } C.patterm_scroll_viewport_bottom(e.term) return nil } // ScrollViewportDelta scrolls the viewport by `delta` rows. Negative is up. func (e *GhosttyEmulator) ScrollViewportDelta(delta int) error { e.mu.Lock() defer e.mu.Unlock() if e.closed { return errors.New("vt: emulator closed") } C.patterm_scroll_viewport_delta(e.term, C.intptr_t(delta)) return 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)