From 56450a2c8c38e88b3fc306f9c4529ca554a0ce29 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Aug 2026 06:42:00 -0700 Subject: [PATCH] lib: update libghostty-vt and bind ground APIs Advance the pinned libghostty-vt revision to upstream main. The new revision requires Zig 0.16.0 and adds APIs for detecting and writing through VT stream ground boundaries. Expose the ground-state query and bounded write operation through typed Go methods. Cover already-ground, partial-consumption, unfinished-sequence, and empty-input behavior in tests. --- CMakeLists.txt | 2 +- README.md | 3 +- doc.go | 6 ++-- terminal.go | 40 +++++++++++++++++++++---- terminal_data.go | 19 ++++++++++++ terminal_data_test.go | 34 +++++++++++++++++++++ terminal_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 163 insertions(+), 11 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6397c55..cc5e00c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ project(go-libghostty LANGUAGES C) include(FetchContent) FetchContent_Declare(ghostty GIT_REPOSITORY https://github.com/ghostty-org/ghostty.git - GIT_TAG d929e6a34a091dcfd69d45011b96cc70b5575dac + GIT_TAG 51ed437cd1a202e625feb7fd0577354d81bcc54b ) FetchContent_MakeAvailable(ghostty) diff --git a/README.md b/README.md index 87b47f3..5d1ad95 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,8 @@ required and used for development of this module. For actual downstream usage, you can get `libghostty-vt` available however you like (e.g. system package, local checkout, etc.). -You need [Zig](https://ghostty.org/docs/install/build) and CMake on your PATH. +You need [Zig 0.16.0](https://ghostty.org/docs/install/build) or newer and +CMake on your PATH. The Nix development shell provides the required versions. ```shell make build diff --git a/doc.go b/doc.go index bbd2cfe..a947b9d 100644 --- a/doc.go +++ b/doc.go @@ -51,9 +51,9 @@ // [WithProgressReport], or on a live terminal with // [Terminal.SetEffectWritePty] and friends. // -// Effect callbacks run synchronously during [Terminal.VTWrite]. They -// must not call [Terminal.VTWrite] on the same terminal and should avoid -// blocking for long periods. +// Effect callbacks run synchronously during [Terminal.VTWrite] and +// [Terminal.VTWriteUntilGround]. They must not call either VT write method +// on the same terminal and should avoid blocking for long periods. // // [WithWritePty] is the most common effect — it delivers data that // the terminal wants to send back to the pty (e.g. query responses): diff --git a/terminal.go b/terminal.go index a06b54e..60e3330 100644 --- a/terminal.go +++ b/terminal.go @@ -13,11 +13,12 @@ import ( // Terminal wraps a Ghostty VT terminal handle. // It is stateful, not safe for concurrent use, and not reentrant. // Serialize all calls that touch a terminal, including getters, -// setters, [Terminal.VTWrite], [Terminal.Resize], [Terminal.Close], +// setters, [Terminal.VTWrite], [Terminal.VTWriteUntilGround], +// [Terminal.Resize], [Terminal.Close], // and any borrowed handles derived from it. Effect callbacks run -// synchronously during [Terminal.VTWrite]; they must not call -// [Terminal.VTWrite] on the same terminal and should avoid blocking -// for long periods. +// synchronously during VT writes; they must not call [Terminal.VTWrite] +// or [Terminal.VTWriteUntilGround] on the same terminal and should avoid +// blocking for long periods. // C: GhosttyTerminal type Terminal struct { ptr C.GhosttyTerminal @@ -770,8 +771,8 @@ func (t *Terminal) Compress(mode TerminalCompressionMode) (TerminalCompressionRe // VTWrite feeds raw VT-encoded bytes through the terminal's parser, // updating terminal state. Malformed input is handled gracefully and // will not cause an error. Effect callbacks run synchronously before -// this call returns; they must not call [Terminal.VTWrite] on the same -// terminal. +// this call returns; they must not call [Terminal.VTWrite] or +// [Terminal.VTWriteUntilGround] on the same terminal. func (t *Terminal) VTWrite(data []byte) { if len(data) == 0 { return @@ -779,6 +780,33 @@ func (t *Terminal) VTWrite(data []byte) { C.ghostty_terminal_vt_write(t.ptr, (*C.uint8_t)(&data[0]), C.size_t(len(data))) } +// VTWriteUntilGround feeds only the shortest prefix of data needed for the +// terminal's VT parser to return to its ground state. Ground is the stateless +// point between UTF-8 codepoints and VT sequences where callers can safely +// insert out-of-band VT data. +// +// If the parser is already at ground, the method consumes zero bytes and +// leaves data untouched. If all of data is consumed without reaching ground, +// consumed is len(data) and the returned error has [ResultNoValue]. Effect +// callbacks run synchronously for the consumed prefix only and must not call +// either VT write method on the same terminal. +// C: ghostty_terminal_vt_write_until_ground +func (t *Terminal) VTWriteUntilGround(data []byte) (consumed int, err error) { + var ptr *C.uint8_t + if len(data) > 0 { + ptr = (*C.uint8_t)(unsafe.Pointer(&data[0])) + } + + var out C.size_t + result := C.ghostty_terminal_vt_write_until_ground( + t.ptr, + ptr, + C.size_t(len(data)), + &out, + ) + return int(out), resultError(result) +} + // Write implements io.Writer by feeding data through the terminal's // VT parser. It always consumes all bytes and never returns an error. func (t *Terminal) Write(p []byte) (int, error) { diff --git a/terminal_data.go b/terminal_data.go index 7f6573d..8f2c268 100644 --- a/terminal_data.go +++ b/terminal_data.go @@ -164,6 +164,10 @@ const ( // initializes the mode field of a GhosttyTerminalModeConfig and the query // writes its value field (GhosttyTerminalModeConfig). TerminalDataMode TerminalData = C.GHOSTTY_TERMINAL_DATA_MODE + + // TerminalDataVTGround indicates whether VT processing is between UTF-8 + // codepoints and terminal sequences (bool). + TerminalDataVTGround TerminalData = C.GHOSTTY_TERMINAL_DATA_VT_GROUND ) // ActiveScreen returns which screen buffer is currently active. @@ -536,6 +540,21 @@ func (t *Terminal) TotalRows() (uint, error) { return uint(v), nil } +// VTGround reports whether VT processing is at the stateless point between +// UTF-8 codepoints and terminal sequences. Out-of-band VT data can be safely +// inserted while this returns true. +func (t *Terminal) VTGround() (bool, error) { + var v C.bool + if err := resultError(C.ghostty_terminal_get( + t.ptr, + C.GHOSTTY_TERMINAL_DATA_VT_GROUND, + unsafe.Pointer(&v), + )); err != nil { + return false, err + } + return bool(v), nil +} + // VTProcessingError reports whether VT processing has ever encountered a // non-gracefully handled failure that may have prevented a semantic update. // Reset does not clear this informational flag. diff --git a/terminal_data_test.go b/terminal_data_test.go index 636a992..9fe9a2b 100644 --- a/terminal_data_test.go +++ b/terminal_data_test.go @@ -297,6 +297,40 @@ func TestTerminalVTProcessingError(t *testing.T) { } } +func TestTerminalVTGround(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + ground, err := term.VTGround() + if err != nil { + t.Fatal(err) + } + if !ground { + t.Fatal("expected a fresh terminal to be at VT ground") + } + + term.VTWrite([]byte("\x1b[31")) + ground, err = term.VTGround() + if err != nil { + t.Fatal(err) + } + if ground { + t.Fatal("expected an unfinished CSI sequence to be above VT ground") + } + + term.VTWrite([]byte("m")) + ground, err = term.VTGround() + if err != nil { + t.Fatal(err) + } + if !ground { + t.Fatal("expected a completed CSI sequence to return to VT ground") + } +} + func TestTerminalColorRoundTrip(t *testing.T) { term, err := NewTerminal(WithSize(80, 24)) if err != nil { diff --git a/terminal_test.go b/terminal_test.go index bb7944a..c7e2573 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -90,6 +90,76 @@ func TestTerminalVTWrite(t *testing.T) { term.VTWrite(nil) // empty write } +func TestTerminalVTWriteUntilGround(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + // A terminal already at ground leaves the input untouched. + consumed, err := term.VTWriteUntilGround([]byte("untouched")) + if err != nil { + t.Fatal(err) + } + if consumed != 0 { + t.Fatalf("expected no bytes consumed at ground, got %d", consumed) + } + + // Complete a split CSI sequence, but stop before processing the printable + // suffix because the parser reaches ground after the final byte. + term.VTWrite([]byte("\x1b[31")) + input := []byte("mABC") + consumed, err = term.VTWriteUntilGround(input) + if err != nil { + t.Fatal(err) + } + if consumed != 1 { + t.Fatalf("expected one byte consumed through ground, got %d", consumed) + } + x, err := term.CursorX() + if err != nil { + t.Fatal(err) + } + if x != 0 { + t.Fatalf("expected suffix to remain unprocessed, cursor is at %d", x) + } + + term.VTWrite(input[consumed:]) + x, err = term.CursorX() + if err != nil { + t.Fatal(err) + } + if x != 3 { + t.Fatalf("expected suffix write to advance cursor to 3, got %d", x) + } + + // An empty slice while unfinished consumes nothing but cannot find a + // ground boundary. + term.VTWrite([]byte("\x1b[")) + consumed, err = term.VTWriteUntilGround(nil) + if consumed != 0 { + t.Fatalf("expected empty input to consume zero bytes, got %d", consumed) + } + assertResultError(t, err, ResultNoValue) +} + +func TestTerminalVTWriteUntilGroundNoValue(t *testing.T) { + term, err := NewTerminal(WithSize(80, 24)) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + term.VTWrite([]byte("\x1b[")) + input := []byte("123") + consumed, err := term.VTWriteUntilGround(input) + if consumed != len(input) { + t.Fatalf("expected all %d bytes consumed, got %d", len(input), consumed) + } + assertResultError(t, err, ResultNoValue) +} + func TestTerminalIOWriter(t *testing.T) { term, err := NewTerminal(WithSize(80, 24)) if err != nil { -- 2.51.2