diff --git a/CMakeLists.txt b/CMakeLists.txt index bb90aca..8092d5d 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 5a35415a5d59a117e654735ca5a01f876dec5841 + GIT_TAG 2dd79f3bc6af649e68422b08e21ad0300fd8b391 ) FetchContent_MakeAvailable(ghostty) diff --git a/doc.go b/doc.go index fbb6b83..bbd2cfe 100644 --- a/doc.go +++ b/doc.go @@ -47,8 +47,9 @@ // // The terminal communicates side-effects back to the host through // effect callbacks. Register them at creation time with functional -// options like [WithWritePty], [WithBell], and [WithEnquiry], or -// on a live terminal with [Terminal.SetEffectWritePty] and friends. +// options like [WithWritePty], [WithDesktopNotification], and +// [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 diff --git a/examples/effects/main.go b/examples/effects/main.go index 982330f..8e660dc 100644 --- a/examples/effects/main.go +++ b/examples/effects/main.go @@ -1,8 +1,9 @@ // Example program demonstrating terminal effect callbacks. // -// It registers write_pty, bell, title_changed, and clipboard_write effect -// handlers, then feeds VT sequences that trigger each one. Output shows how -// the callbacks fire and how terminal state can be queried from within them. +// It registers a representative set of effects, including desktop +// notifications and progress reports, then feeds VT sequences that trigger +// each one. Output shows how the callbacks fire and how terminal state can be +// queried from within them. package main import ( @@ -39,6 +40,16 @@ func main() { return ghostty.ClipboardWriteSuccess }), + // desktop_notification: called for OSC 9 and OSC 777 requests. + ghostty.WithDesktopNotification(func(_ *ghostty.Terminal, notification ghostty.TerminalDesktopNotification) { + fmt.Printf("desktop_notification: title=%q body=%q\n", notification.Title, notification.Body) + }), + + // progress_report: called for ConEmu OSC 9;4 progress updates. + ghostty.WithProgressReport(func(_ *ghostty.Terminal, report ghostty.TerminalProgressReport) { + fmt.Printf("progress_report: state=%d progress=%d\n", report.State, report.Progress) + }), + // title_changed: called when the terminal title changes via OSC 0/2. // The terminal is passed directly as a parameter. ghostty.WithTitleChanged(func(t *ghostty.Terminal) { @@ -66,6 +77,12 @@ func main() { // OSC 52 (set clipboard) → triggers clipboard_write with decoded data. term.VTWrite([]byte("\x1b]52;c;SGVsbG8gY2xpcGJvYXJk\x1b\\")) + // OSC 777 (desktop notification) → triggers desktop_notification. + term.VTWrite([]byte("\x1b]777;notify;Build;Complete\x1b\\")) + + // OSC 9;4 (42% progress) → triggers progress_report. + term.VTWrite([]byte("\x1b]9;4;1;42\x1b\\")) + // Another BEL → triggers bell handler again. term.VTWrite([]byte{0x07}) diff --git a/terminal.go b/terminal.go index 0f0eaa3..6f2d5c1 100644 --- a/terminal.go +++ b/terminal.go @@ -28,16 +28,18 @@ type Terminal struct { // dispatch to the appropriate Go effect handler. handle cgo.Handle - onWritePty WritePtyFn - onBell BellFn - onClipboardWrite ClipboardWriteFn - onTitleChanged TitleChangedFn - onPwdChanged PwdChangedFn - onEnquiry EnquiryFn - onXtversion XtversionFn - onSize SizeFn - onColorScheme ColorSchemeFn - onDeviceAttributes DeviceAttributesFn + onWritePty WritePtyFn + onBell BellFn + onClipboardWrite ClipboardWriteFn + onDesktopNotification DesktopNotificationFn + onTitleChanged TitleChangedFn + onPwdChanged PwdChangedFn + onProgressReport ProgressReportFn + onEnquiry EnquiryFn + onXtversion XtversionFn + onSize SizeFn + onColorScheme ColorSchemeFn + onDeviceAttributes DeviceAttributesFn // effectBuf holds C-allocated memory for the most recent response // returned by an effect trampoline (e.g. enquiry, xtversion). @@ -69,16 +71,18 @@ type TerminalConfig struct { MaxScrollbackLines *uint // Effect handlers applied after terminal creation. - onWritePty WritePtyFn - onBell BellFn - onClipboardWrite ClipboardWriteFn - onTitleChanged TitleChangedFn - onPwdChanged PwdChangedFn - onEnquiry EnquiryFn - onXtversion XtversionFn - onSize SizeFn - onColorScheme ColorSchemeFn - onDeviceAttributes DeviceAttributesFn + onWritePty WritePtyFn + onBell BellFn + onClipboardWrite ClipboardWriteFn + onDesktopNotification DesktopNotificationFn + onTitleChanged TitleChangedFn + onPwdChanged PwdChangedFn + onProgressReport ProgressReportFn + onEnquiry EnquiryFn + onXtversion XtversionFn + onSize SizeFn + onColorScheme ColorSchemeFn + onDeviceAttributes DeviceAttributesFn } // WritePtyFn is called when the terminal writes data back to the pty @@ -168,6 +172,24 @@ const ( // C: GhosttyTerminalClipboardWriteFn type ClipboardWriteFn func(t *Terminal, write ClipboardWrite) ClipboardWriteResult +// TerminalDesktopNotification is a request from the running program to show a +// desktop notification. Title is empty for protocols such as OSC 9 that +// provide only a body. Both strings are copied into Go-owned memory before +// the callback runs and may be retained. +// C: GhosttyTerminalDesktopNotification +type TerminalDesktopNotification struct { + // Title is the notification title, or empty when the protocol omits it. + Title string + + // Body is the notification body. + Body string +} + +// DesktopNotificationFn is called synchronously when the terminal receives +// a desktop notification request via OSC 9 or OSC 777. +// C: GhosttyTerminalDesktopNotificationFn +type DesktopNotificationFn func(t *Terminal, notification TerminalDesktopNotification) + // TitleChangedFn is called when the terminal title changes via OSC 0/2. // The parameter is the terminal that triggered the effect. // C: GhosttyTerminalTitleChangedFn @@ -179,6 +201,44 @@ type TitleChangedFn func(t *Terminal) // C: GhosttyTerminalPwdChangedFn type PwdChangedFn func(t *Terminal) +// TerminalProgressState identifies the state of a progress report emitted by +// the running program. +// C: GhosttyTerminalProgressState +type TerminalProgressState int + +const ( + // TerminalProgressStateRemove requests removal of any progress indication. + TerminalProgressStateRemove TerminalProgressState = C.GHOSTTY_TERMINAL_PROGRESS_STATE_REMOVE + + // TerminalProgressStateSet reports determinate progress. + TerminalProgressStateSet TerminalProgressState = C.GHOSTTY_TERMINAL_PROGRESS_STATE_SET + + // TerminalProgressStateError reports failed progress. + TerminalProgressStateError TerminalProgressState = C.GHOSTTY_TERMINAL_PROGRESS_STATE_ERROR + + // TerminalProgressStateIndeterminate reports progress without a percentage. + TerminalProgressStateIndeterminate TerminalProgressState = C.GHOSTTY_TERMINAL_PROGRESS_STATE_INDETERMINATE + + // TerminalProgressStatePause reports paused progress. + TerminalProgressStatePause TerminalProgressState = C.GHOSTTY_TERMINAL_PROGRESS_STATE_PAUSE +) + +// TerminalProgressReport is a progress update emitted by the running program +// via OSC 9;4. +// C: GhosttyTerminalProgressReport +type TerminalProgressReport struct { + // State is the literal progress state reported by the program. + State TerminalProgressState + + // Progress is a percentage from 0 through 100, or -1 when omitted. + Progress int8 +} + +// ProgressReportFn is called synchronously when the terminal receives a +// progress report via OSC 9;4. +// C: GhosttyTerminalProgressReportFn +type ProgressReportFn func(t *Terminal, report TerminalProgressReport) + // EnquiryFn is called when the terminal receives ENQ (0x05). // The first parameter is the terminal that triggered the effect. // Return the response bytes; nil or empty means no response. @@ -261,6 +321,14 @@ func WithClipboardWrite(fn ClipboardWriteFn) TerminalOption { } } +// WithDesktopNotification registers an effect handler invoked for desktop +// notification requests received via OSC 9 or OSC 777. +func WithDesktopNotification(fn DesktopNotificationFn) TerminalOption { + return func(c *TerminalConfig) { + c.onDesktopNotification = fn + } +} + // WithTitleChanged registers an effect handler invoked when the // terminal title changes via OSC 0 or OSC 2. func WithTitleChanged(fn TitleChangedFn) TerminalOption { @@ -277,6 +345,14 @@ func WithPwdChanged(fn PwdChangedFn) TerminalOption { } } +// WithProgressReport registers an effect handler invoked for progress reports +// received via OSC 9;4. +func WithProgressReport(fn ProgressReportFn) TerminalOption { + return func(c *TerminalConfig) { + c.onProgressReport = fn + } +} + // WithEnquiry registers an effect handler invoked when the terminal // receives an ENQ character (0x05). Return the response bytes; nil // or empty means no response. @@ -368,17 +444,19 @@ func NewTerminal(opts ...TerminalOption) (*Terminal, error) { } t := &Terminal{ - ptr: cterm, - onWritePty: cfg.onWritePty, - onBell: cfg.onBell, - onClipboardWrite: cfg.onClipboardWrite, - onTitleChanged: cfg.onTitleChanged, - onPwdChanged: cfg.onPwdChanged, - onEnquiry: cfg.onEnquiry, - onXtversion: cfg.onXtversion, - onSize: cfg.onSize, - onColorScheme: cfg.onColorScheme, - onDeviceAttributes: cfg.onDeviceAttributes, + ptr: cterm, + onWritePty: cfg.onWritePty, + onBell: cfg.onBell, + onClipboardWrite: cfg.onClipboardWrite, + onDesktopNotification: cfg.onDesktopNotification, + onTitleChanged: cfg.onTitleChanged, + onPwdChanged: cfg.onPwdChanged, + onProgressReport: cfg.onProgressReport, + onEnquiry: cfg.onEnquiry, + onXtversion: cfg.onXtversion, + onSize: cfg.onSize, + onColorScheme: cfg.onColorScheme, + onDeviceAttributes: cfg.onDeviceAttributes, } // Always set userdata to our handle so trampolines can find us. diff --git a/terminal_effect.go b/terminal_effect.go index e59ccda..f441851 100644 --- a/terminal_effect.go +++ b/terminal_effect.go @@ -15,8 +15,10 @@ package libghostty extern void goWritePtyTrampoline(GhosttyTerminal, void*, uint8_t*, size_t); extern void goBellTrampoline(GhosttyTerminal, void*); extern GhosttyClipboardWriteResult goClipboardWriteTrampoline(GhosttyTerminal, void*, GhosttyClipboardWrite*); +extern void goDesktopNotificationTrampoline(GhosttyTerminal, void*, GhosttyTerminalDesktopNotification*); extern void goTitleChangedTrampoline(GhosttyTerminal, void*); extern void goPwdChangedTrampoline(GhosttyTerminal, void*); +extern void goProgressReportTrampoline(GhosttyTerminal, void*, GhosttyTerminalProgressReport*); extern GhosttyString goEnquiryTrampoline(GhosttyTerminal, void*); extern GhosttyString goXtversionTrampoline(GhosttyTerminal, void*); extern bool goSizeTrampoline(GhosttyTerminal, void*, GhosttySizeReportSize*); @@ -35,12 +37,18 @@ static inline GhosttyResult set_bell(GhosttyTerminal t) { static inline GhosttyResult set_clipboard_write(GhosttyTerminal t) { return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE, (const void*)goClipboardWriteTrampoline); } +static inline GhosttyResult set_desktop_notification(GhosttyTerminal t) { + return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION, (const void*)goDesktopNotificationTrampoline); +} static inline GhosttyResult set_title_changed(GhosttyTerminal t) { return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, (const void*)goTitleChangedTrampoline); } static inline GhosttyResult set_pwd_changed(GhosttyTerminal t) { return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_PWD_CHANGED, (const void*)goPwdChangedTrampoline); } +static inline GhosttyResult set_progress_report(GhosttyTerminal t) { + return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT, (const void*)goProgressReportTrampoline); +} static inline GhosttyResult set_enquiry(GhosttyTerminal t) { return ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_ENQUIRY, (const void*)goEnquiryTrampoline); } @@ -87,6 +95,11 @@ func (t *Terminal) syncEffects() { } else { C.clear_effect(t.ptr, C.GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE) } + if t.onDesktopNotification != nil { + C.set_desktop_notification(t.ptr) + } else { + C.clear_effect(t.ptr, C.GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION) + } if t.onTitleChanged != nil { C.set_title_changed(t.ptr) } else { @@ -97,6 +110,11 @@ func (t *Terminal) syncEffects() { } else { C.clear_effect(t.ptr, C.GHOSTTY_TERMINAL_OPT_PWD_CHANGED) } + if t.onProgressReport != nil { + C.set_progress_report(t.ptr) + } else { + C.clear_effect(t.ptr, C.GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT) + } if t.onEnquiry != nil { C.set_enquiry(t.ptr) } else { @@ -160,7 +178,7 @@ func goClipboardWriteTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer, wr return C.GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA } - count, ok := clipboardSizeToInt(write.contents_len) + count, ok := ghosttySizeToInt(write.contents_len) if !ok || (count > 0 && write.contents == nil) { return C.GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA } @@ -169,11 +187,11 @@ func goClipboardWriteTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer, wr if count > 0 { cContents := unsafe.Slice(write.contents, count) for i, content := range cContents { - mime, valid := copyClipboardString(content.mime) + mime, valid := copyGhosttyString(content.mime) if !valid { return C.GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA } - data, valid := copyClipboardString(content.data) + data, valid := copyGhosttyString(content.data) if !valid { return C.GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA } @@ -192,20 +210,20 @@ func goClipboardWriteTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer, wr return C.GhosttyClipboardWriteResult(result) } -// clipboardSizeToInt converts a C size to a Go slice length without allowing +// ghosttySizeToInt converts a C size to a Go slice length without allowing // an overflowing conversion to produce an invalid unsafe.Slice length. -func clipboardSizeToInt(size C.size_t) (int, bool) { +func ghosttySizeToInt(size C.size_t) (int, bool) { if uint64(size) > uint64(^uint(0)>>1) { return 0, false } return int(size), true } -// copyClipboardString copies a borrowed, binary-safe GhosttyString into Go -// memory. For zero-length strings the pointer is intentionally ignored because -// libghostty does not require it to be valid. -func copyClipboardString(value C.GhosttyString) ([]byte, bool) { - length, ok := clipboardSizeToInt(value.len) +// copyGhosttyString copies a borrowed, binary-safe GhosttyString into Go +// memory. For zero-length strings the pointer is intentionally ignored +// because libghostty does not require it to be valid. +func copyGhosttyString(value C.GhosttyString) ([]byte, bool) { + length, ok := ghosttySizeToInt(value.len) if !ok { return nil, false } @@ -219,6 +237,41 @@ func copyClipboardString(value C.GhosttyString) ([]byte, bool) { return append([]byte(nil), unsafe.Slice((*byte)(unsafe.Pointer(value.ptr)), length)...), true } +//export goDesktopNotificationTrampoline +func goDesktopNotificationTrampoline( + _ C.GhosttyTerminal, + userdata unsafe.Pointer, + notification *C.GhosttyTerminalDesktopNotification, +) { + t := terminalFromUserdata(userdata) + if t.onDesktopNotification == nil { + return + } + + // GhosttyTerminalDesktopNotification is a sized struct so newer + // libghostty versions can extend it without breaking existing callbacks. + // Ignore descriptors that do not contain the full layout this binding + // knows how to read. + if notification == nil || + notification.size < C.size_t(C.sizeof_GhosttyTerminalDesktopNotification) { + return + } + + title, ok := copyGhosttyString(notification.title) + if !ok { + return + } + body, ok := copyGhosttyString(notification.body) + if !ok { + return + } + + t.onDesktopNotification(t, TerminalDesktopNotification{ + Title: string(title), + Body: string(body), + }) +} + //export goTitleChangedTrampoline func goTitleChangedTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer) { t := terminalFromUserdata(userdata) @@ -235,6 +288,30 @@ func goPwdChangedTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer) { } } +//export goProgressReportTrampoline +func goProgressReportTrampoline( + _ C.GhosttyTerminal, + userdata unsafe.Pointer, + report *C.GhosttyTerminalProgressReport, +) { + t := terminalFromUserdata(userdata) + if t.onProgressReport == nil { + return + } + + // GhosttyTerminalProgressReport is also a sized struct. Only read the + // fields when the caller supplied the complete layout known here. + if report == nil || + report.size < C.size_t(C.sizeof_GhosttyTerminalProgressReport) { + return + } + + t.onProgressReport(t, TerminalProgressReport{ + State: TerminalProgressState(report.state), + Progress: int8(report.progress), + }) +} + //export goEnquiryTrampoline func goEnquiryTrampoline(_ C.GhosttyTerminal, userdata unsafe.Pointer) C.GhosttyString { t := terminalFromUserdata(userdata) diff --git a/terminal_opt.go b/terminal_opt.go index 902cb9e..caf244e 100644 --- a/terminal_opt.go +++ b/terminal_opt.go @@ -31,6 +31,13 @@ func (t *Terminal) SetEffectClipboardWrite(fn ClipboardWriteFn) { t.syncEffects() } +// SetEffectDesktopNotification registers (or clears) the desktop-notification +// effect on a live terminal. Pass nil to clear. +func (t *Terminal) SetEffectDesktopNotification(fn DesktopNotificationFn) { + t.onDesktopNotification = fn + t.syncEffects() +} + // SetEffectTitleChanged registers (or clears) the title-changed effect // on a live terminal. Pass nil to clear. func (t *Terminal) SetEffectTitleChanged(fn TitleChangedFn) { @@ -45,6 +52,13 @@ func (t *Terminal) SetEffectPwdChanged(fn PwdChangedFn) { t.syncEffects() } +// SetEffectProgressReport registers (or clears) the progress-report effect on +// a live terminal. Pass nil to clear. +func (t *Terminal) SetEffectProgressReport(fn ProgressReportFn) { + t.onProgressReport = fn + t.syncEffects() +} + // SetEffectEnquiry registers (or clears) the enquiry effect on a live // terminal. Pass nil to clear. func (t *Terminal) SetEffectEnquiry(fn EnquiryFn) { diff --git a/terminal_opt_test.go b/terminal_opt_test.go index c2b8499..80143a8 100644 --- a/terminal_opt_test.go +++ b/terminal_opt_test.go @@ -242,6 +242,101 @@ func TestTerminalSetEffectClipboardWrite(t *testing.T) { } } +func TestTerminalDesktopNotificationEffect(t *testing.T) { + var notifications []TerminalDesktopNotification + term, err := NewTerminal( + WithSize(80, 24), + WithDesktopNotification(func(_ *Terminal, notification TerminalDesktopNotification) { + notifications = append(notifications, notification) + }), + ) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + // OSC 777 preserves its separate title and body, including when the + // sequence arrives across multiple VT writes. + term.VTWrite([]byte("\x1b]777;notify;Codex;")) + if len(notifications) != 0 { + t.Fatalf("expected incomplete notification to remain buffered, got %d", len(notifications)) + } + term.VTWrite([]byte("Needs attention\x1b\\")) + if len(notifications) != 1 { + t.Fatalf("expected 1 desktop notification, got %d", len(notifications)) + } + if got := notifications[0]; got.Title != "Codex" || got.Body != "Needs attention" { + t.Fatalf("unexpected desktop notification: %+v", got) + } + + // OSC 9 has no title and preserves its body. + term.VTWrite([]byte("\x1b]9;Build complete\x07")) + if len(notifications) != 2 { + t.Fatalf("expected 2 desktop notifications, got %d", len(notifications)) + } + if got := notifications[1]; got.Title != "" || got.Body != "Build complete" { + t.Fatalf("unexpected OSC 9 desktop notification: %+v", got) + } + + // Callback strings are copied and remain valid after the parser reuses its + // borrowed input buffer for later terminal writes. + if got := notifications[0]; got.Title != "Codex" || got.Body != "Needs attention" { + t.Fatalf("expected retained desktop notification, got %+v", got) + } + + term.SetEffectDesktopNotification(nil) + term.VTWrite([]byte("\x1b]9;Ignored\x07")) + if len(notifications) != 2 { + t.Fatalf("expected callback to remain cleared, got %d notifications", len(notifications)) + } +} + +func TestTerminalProgressReportEffect(t *testing.T) { + var reports []TerminalProgressReport + term, err := NewTerminal( + WithSize(80, 24), + WithProgressReport(func(_ *Terminal, report TerminalProgressReport) { + reports = append(reports, report) + }), + ) + if err != nil { + t.Fatal(err) + } + defer term.Close() + + tests := []struct { + sequence string + state TerminalProgressState + progress int8 + }{ + {"\x1b]9;4;0;\x1b\\", TerminalProgressStateRemove, -1}, + {"\x1b]9;4;1;42\x07", TerminalProgressStateSet, 42}, + {"\x1b]9;4;2;7\x1b\\", TerminalProgressStateError, 7}, + {"\x1b]9;4;3\x1b\\", TerminalProgressStateIndeterminate, -1}, + {"\x1b]9;4;4;75\x1b\\", TerminalProgressStatePause, 75}, + } + for i, test := range tests { + midpoint := len(test.sequence) / 2 + term.VTWrite([]byte(test.sequence[:midpoint])) + if len(reports) != i { + t.Fatalf("expected split report %d to remain buffered, got %d reports", i, len(reports)) + } + term.VTWrite([]byte(test.sequence[midpoint:])) + if len(reports) != i+1 { + t.Fatalf("expected report %d, got %d reports", i, len(reports)) + } + if got := reports[i]; got.State != test.state || got.Progress != test.progress { + t.Fatalf("unexpected progress report %d: %+v", i, got) + } + } + + term.SetEffectProgressReport(nil) + term.VTWrite([]byte("\x1b]9;4;1;90\x1b\\")) + if len(reports) != len(tests) { + t.Fatalf("expected callback to remain cleared, got %d reports", len(reports)) + } +} + func TestTerminalWithWritePty(t *testing.T) { var received []byte term, err := NewTerminal(WithSize(80, 24), WithWritePty(func(_ *Terminal, data []byte) {