diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index fb3102147..b22e8aedc 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -76,6 +76,7 @@ extern "C" { * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty | * | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) | * | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 | + * | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 | * | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) | * | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) | * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) | @@ -372,6 +373,31 @@ typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal, typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, void* userdata); +/** + * Callback function type for pwd_changed. + * + * Called when the terminal pwd (current working directory) changes via + * escape sequences: OSC 7 (file:// URI), OSC 9 (ConEmu CurrentDir), or + * OSC 1337 CurrentDir (iTerm2). Use ghostty_terminal_get() with + * GHOSTTY_TERMINAL_DATA_PWD inside the callback to read the new value. + * + * The terminal stores whatever bytes the shell emitted, without parsing. + * That means for OSC 7 the value is the raw URI (typically file://...); + * for OSC 9/OSC 1337 it is typically a bare path. The embedder is + * responsible for decoding any URI scheme or host if it cares about them. + * + * The callback also fires when the shell clears the pwd (e.g. an empty + * OSC 7). In that case GHOSTTY_TERMINAL_DATA_PWD returns a zero-length + * string. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal, + void* userdata); + /** * Callback function type for write_pty. * @@ -659,6 +685,14 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL = 24, + /** + * Callback invoked when the terminal pwd changes via escape + * sequences (OSC 7, OSC 9, or OSC 1337 CurrentDir). Set to NULL + * to ignore pwd change events. + * + * Input type: GhosttyTerminalPwdChangedFn + */ + GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 9fcf2e940..0bacbc068 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -52,6 +52,7 @@ const Effects = struct { enquiry: ?EnquiryFn = null, xtversion: ?XtversionFn = null, title_changed: ?TitleChangedFn = null, + pwd_changed: ?PwdChangedFn = null, size_cb: ?SizeFn = null, /// Scratch buffer for DA1 feature codes. The device attributes @@ -86,6 +87,9 @@ const Effects = struct { /// C function pointer type for the title_changed callback. pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; + /// C function pointer type for the pwd_changed callback. + pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; + /// C function pointer type for the size callback. /// Returns true and fills out_size if size is available, /// or returns false to silently ignore the query. @@ -199,6 +203,13 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } + fn pwdChangedTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.pwd_changed orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } + fn sizeTrampoline(handler: *Handler) ?size_report.Size { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -283,6 +294,7 @@ fn new_( .enquiry = &Effects.enquiryTrampoline, .xtversion = &Effects.xtversionTrampoline, .title_changed = &Effects.titleChangedTrampoline, + .pwd_changed = &Effects.pwdChangedTrampoline, .size = &Effects.sizeTrampoline, }; @@ -330,6 +342,7 @@ pub const Option = enum(c_int) { default_cursor_style = 22, default_cursor_blink = 23, glyph_protocol = 24, + pwd_changed = 25, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -342,6 +355,7 @@ pub const Option = enum(c_int) { .enquiry => ?Effects.EnquiryFn, .xtversion => ?Effects.XtversionFn, .title_changed => ?Effects.TitleChangedFn, + .pwd_changed => ?Effects.PwdChangedFn, .size_cb => ?Effects.SizeFn, .title, .pwd => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, @@ -397,6 +411,7 @@ fn setTyped( .enquiry => wrapper.effects.enquiry = value, .xtversion => wrapper.effects.xtversion = value, .title_changed => wrapper.effects.title_changed = value, + .pwd_changed => wrapper.effects.pwd_changed = value, .size_cb => wrapper.effects.size_cb = value, .title => { const str = if (value) |v| v.ptr[0..v.len] else ""; @@ -2363,6 +2378,68 @@ test "title_changed without callback is silent" { vt_write(t, "\x1B]2;Hello\x1B\\", 10); } +test "set pwd_changed callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var pwd_count: usize = 0; + var last_userdata: ?*anyopaque = null; + + fn pwdChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void { + pwd_count += 1; + last_userdata = ud; + } + }; + S.pwd_count = 0; + S.last_userdata = null; + + var sentinel: u8 = 88; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set(t, .pwd_changed, @ptrCast(&S.pwdChanged))); + + // OSC 7 ; file:///tmp ST — report pwd + const seq1 = "\x1B]7;file:///tmp\x1B\\"; + vt_write(t, seq1, seq1.len); + try testing.expectEqual(@as(usize, 1), S.pwd_count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?); + + // Another pwd change + const seq2 = "\x1B]7;file:///home/user\x1B\\"; + vt_write(t, seq2, seq2.len); + try testing.expectEqual(@as(usize, 2), S.pwd_count); + try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?); +} + +test "pwd_changed without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // OSC 7 without a callback should not crash, but should still set the pwd + const seq = "\x1B]7;file:///tmp\x1B\\"; + vt_write(t, seq, seq.len); + try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?); +} + test "set size callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index eda2af0e0..e3d42c51e 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -83,6 +83,11 @@ pub const Handler = struct { /// handler.terminal.getTitle(). title_changed: ?*const fn (*Handler) void, + /// Called when the terminal pwd changes via escape sequences + /// (e.g. OSC 7). The new pwd can be queried via + /// handler.terminal.getPwd(). + pwd_changed: ?*const fn (*Handler) void, + /// Called in response to an XTVERSION query. Returns the version /// string to report (e.g. "ghostty 1.2.3"). The returned memory /// must be valid for the lifetime of the call. The maximum length @@ -99,6 +104,7 @@ pub const Handler = struct { .enquiry = null, .size = null, .title_changed = null, + .pwd_changed = null, .write_pty = null, .xtversion = null, }; @@ -268,6 +274,7 @@ pub const Handler = struct { .request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi), .size_report => self.reportSize(value), .window_title => self.windowTitle(value.title), + .report_pwd => self.reportPwd(value.url), .xtversion => self.reportXtversion(), // No supported DCS commands have any terminal-modifying effects, @@ -278,7 +285,6 @@ pub const Handler = struct { => {}, // Have no terminal-modifying effect - .report_pwd, .show_desktop_notification, .progress_report, .clipboard_contents, @@ -437,6 +443,29 @@ pub const Handler = struct { func(self); } + fn reportPwd(self: *Handler, url_raw: []const u8) void { + // Prevent DoS attacks by limiting url length. Headroom for + // Linux PATH_MAX (4096) plus URI scheme/host and percent-encoding. + const max_url_len = 4096; + const url = if (url_raw.len > max_url_len) url: { + log.warn("pwd url length {d} exceeds max length {d}, truncating", .{ + url_raw.len, + max_url_len, + }); + break :url url_raw[0..max_url_len]; + } else url_raw; + + // We store the raw payload unparsed. Embedders read it via + // getPwd() and are responsible for decoding any URI scheme. + self.terminal.setPwd(url) catch |err| { + log.warn("error setting pwd err={}", .{err}); + return; + }; + + const func = self.effects.pwd_changed orelse return; + func(self); + } + fn requestMode(self: *Handler, mode: modes.Mode) void { const report = self.terminal.modes.getReport(.fromMode(mode)); self.sendModeReport(report);