Classifies every running child as idle/working/thinking/permission/error using one of three pluggable strategies (output_activity, osc_title_stability, osc_title_status) plus optional regex promoters applied to the tail of recent output. State and last-match reason are exposed via MCP on ProcessInfo and get_process_status. Per-preset configuration lives on a new preset.IdleDetection block with bundled defaults for the first-party claude/codex/opencode presets. OSC title plumbing is exposed as Emulator.Title(), polled from the session pump after each emulator write so title-change activity feeds into the classifier without an extra cgo callback. The MCP timer surface expands to match Solo: timer_set, timer_fire_when_idle_any/all, timer_cancel, timer_pause, timer_resume, timer_list. timer_wait is now a thin wrapper that shares the same manager so it shows up in timer_list while pending. Timer bodies are delivered to the owner process through the existing InjectAsOrchestrator path. Top-level (non-agent) callers can attach timers to a specific process via owner_process_id; omitting it grants universal cancel/pause/resume/list privileges. The sidebar gains a state glyph per process row and appends a nearest-timer indicator when one is pending or paused. Tests: idle_test.go covers the classify() pure function across the three strategies and regex promotion; timers_test.go covers the manager. Harness scenarios cover output_activity, osc_title_stability, osc_title_status, and regex promotion, plus timer_set delivery, cancel, pause/resume, idle_any-on-transition, idle_all-pending, and idle_all-already-satisfied. A new wait_until_mcp harness step type polls an MCP method until an assertion holds.
668 lines
21 KiB
Go
668 lines
21 KiB
Go
//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 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)
|