This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

lib-vt: add pwd_changed callback for OSC 7/9/1337 (#12956)

Previously the libghostty-vt stream handler dropped .report_pwd as a
no-op, so embedders never saw shell-reported cwd changes and the
terminal's pwd field was never populated from escape sequences.

Wire the action to setPwd and expose a pwd_changed callback analogous to
title_changed via GHOSTTY_TERMINAL_OPT_PWD_CHANGED. The payload is
passed through unparsed; embedders read it with ghostty_terminal_get and
decode any URI scheme themselves.

This is proposed in
[discussion#12927](https://github.com/ghostty-org/ghostty/discussions/12927)

+141 -1
+34
include/ghostty/vt/terminal.h
··· 76 76 * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty | 77 77 * | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) | 78 78 * | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 | 79 + * | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 | 79 80 * | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) | 80 81 * | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) | 81 82 * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) | ··· 373 374 void* userdata); 374 375 375 376 /** 377 + * Callback function type for pwd_changed. 378 + * 379 + * Called when the terminal pwd (current working directory) changes via 380 + * escape sequences: OSC 7 (file:// URI), OSC 9 (ConEmu CurrentDir), or 381 + * OSC 1337 CurrentDir (iTerm2). Use ghostty_terminal_get() with 382 + * GHOSTTY_TERMINAL_DATA_PWD inside the callback to read the new value. 383 + * 384 + * The terminal stores whatever bytes the shell emitted, without parsing. 385 + * That means for OSC 7 the value is the raw URI (typically file://...); 386 + * for OSC 9/OSC 1337 it is typically a bare path. The embedder is 387 + * responsible for decoding any URI scheme or host if it cares about them. 388 + * 389 + * The callback also fires when the shell clears the pwd (e.g. an empty 390 + * OSC 7). In that case GHOSTTY_TERMINAL_DATA_PWD returns a zero-length 391 + * string. 392 + * 393 + * @param terminal The terminal handle 394 + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA 395 + * 396 + * @ingroup terminal 397 + */ 398 + typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal, 399 + void* userdata); 400 + 401 + /** 376 402 * Callback function type for write_pty. 377 403 * 378 404 * Called when the terminal needs to write data back to the pty, for ··· 659 685 */ 660 686 GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL = 24, 661 687 688 + /** 689 + * Callback invoked when the terminal pwd changes via escape 690 + * sequences (OSC 7, OSC 9, or OSC 1337 CurrentDir). Set to NULL 691 + * to ignore pwd change events. 692 + * 693 + * Input type: GhosttyTerminalPwdChangedFn 694 + */ 695 + GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25, 662 696 GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, 663 697 } GhosttyTerminalOption; 664 698
+77
src/terminal/c/terminal.zig
··· 52 52 enquiry: ?EnquiryFn = null, 53 53 xtversion: ?XtversionFn = null, 54 54 title_changed: ?TitleChangedFn = null, 55 + pwd_changed: ?PwdChangedFn = null, 55 56 size_cb: ?SizeFn = null, 56 57 57 58 /// Scratch buffer for DA1 feature codes. The device attributes ··· 85 86 86 87 /// C function pointer type for the title_changed callback. 87 88 pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; 89 + 90 + /// C function pointer type for the pwd_changed callback. 91 + pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; 88 92 89 93 /// C function pointer type for the size callback. 90 94 /// Returns true and fills out_size if size is available, ··· 199 203 func(@ptrCast(wrapper), wrapper.effects.userdata); 200 204 } 201 205 206 + fn pwdChangedTrampoline(handler: *Handler) void { 207 + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); 208 + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); 209 + const func = wrapper.effects.pwd_changed orelse return; 210 + func(@ptrCast(wrapper), wrapper.effects.userdata); 211 + } 212 + 202 213 fn sizeTrampoline(handler: *Handler) ?size_report.Size { 203 214 const stream_ptr: *Stream = @fieldParentPtr("handler", handler); 204 215 const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); ··· 283 294 .enquiry = &Effects.enquiryTrampoline, 284 295 .xtversion = &Effects.xtversionTrampoline, 285 296 .title_changed = &Effects.titleChangedTrampoline, 297 + .pwd_changed = &Effects.pwdChangedTrampoline, 286 298 .size = &Effects.sizeTrampoline, 287 299 }; 288 300 ··· 330 342 default_cursor_style = 22, 331 343 default_cursor_blink = 23, 332 344 glyph_protocol = 24, 345 + pwd_changed = 25, 333 346 334 347 /// Input type expected for setting the option. 335 348 pub fn InType(comptime self: Option) type { ··· 342 355 .enquiry => ?Effects.EnquiryFn, 343 356 .xtversion => ?Effects.XtversionFn, 344 357 .title_changed => ?Effects.TitleChangedFn, 358 + .pwd_changed => ?Effects.PwdChangedFn, 345 359 .size_cb => ?Effects.SizeFn, 346 360 .title, .pwd => ?*const lib.String, 347 361 .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, ··· 397 411 .enquiry => wrapper.effects.enquiry = value, 398 412 .xtversion => wrapper.effects.xtversion = value, 399 413 .title_changed => wrapper.effects.title_changed = value, 414 + .pwd_changed => wrapper.effects.pwd_changed = value, 400 415 .size_cb => wrapper.effects.size_cb = value, 401 416 .title => { 402 417 const str = if (value) |v| v.ptr[0..v.len] else ""; ··· 2361 2376 2362 2377 // OSC 2 without a callback should not crash 2363 2378 vt_write(t, "\x1B]2;Hello\x1B\\", 10); 2379 + } 2380 + 2381 + test "set pwd_changed callback" { 2382 + var t: Terminal = null; 2383 + try testing.expectEqual(Result.success, new( 2384 + &lib.alloc.test_allocator, 2385 + &t, 2386 + .{ 2387 + .cols = 80, 2388 + .rows = 24, 2389 + .max_scrollback = 0, 2390 + }, 2391 + )); 2392 + defer free(t); 2393 + 2394 + const S = struct { 2395 + var pwd_count: usize = 0; 2396 + var last_userdata: ?*anyopaque = null; 2397 + 2398 + fn pwdChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void { 2399 + pwd_count += 1; 2400 + last_userdata = ud; 2401 + } 2402 + }; 2403 + S.pwd_count = 0; 2404 + S.last_userdata = null; 2405 + 2406 + var sentinel: u8 = 88; 2407 + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); 2408 + try testing.expectEqual(Result.success, set(t, .pwd_changed, @ptrCast(&S.pwdChanged))); 2409 + 2410 + // OSC 7 ; file:///tmp ST — report pwd 2411 + const seq1 = "\x1B]7;file:///tmp\x1B\\"; 2412 + vt_write(t, seq1, seq1.len); 2413 + try testing.expectEqual(@as(usize, 1), S.pwd_count); 2414 + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); 2415 + try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?); 2416 + 2417 + // Another pwd change 2418 + const seq2 = "\x1B]7;file:///home/user\x1B\\"; 2419 + vt_write(t, seq2, seq2.len); 2420 + try testing.expectEqual(@as(usize, 2), S.pwd_count); 2421 + try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?); 2422 + } 2423 + 2424 + test "pwd_changed without callback is silent" { 2425 + var t: Terminal = null; 2426 + try testing.expectEqual(Result.success, new( 2427 + &lib.alloc.test_allocator, 2428 + &t, 2429 + .{ 2430 + .cols = 80, 2431 + .rows = 24, 2432 + .max_scrollback = 0, 2433 + }, 2434 + )); 2435 + defer free(t); 2436 + 2437 + // OSC 7 without a callback should not crash, but should still set the pwd 2438 + const seq = "\x1B]7;file:///tmp\x1B\\"; 2439 + vt_write(t, seq, seq.len); 2440 + try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?); 2364 2441 } 2365 2442 2366 2443 test "set size callback" {
+30 -1
src/terminal/stream_terminal.zig
··· 83 83 /// handler.terminal.getTitle(). 84 84 title_changed: ?*const fn (*Handler) void, 85 85 86 + /// Called when the terminal pwd changes via escape sequences 87 + /// (e.g. OSC 7). The new pwd can be queried via 88 + /// handler.terminal.getPwd(). 89 + pwd_changed: ?*const fn (*Handler) void, 90 + 86 91 /// Called in response to an XTVERSION query. Returns the version 87 92 /// string to report (e.g. "ghostty 1.2.3"). The returned memory 88 93 /// must be valid for the lifetime of the call. The maximum length ··· 99 104 .enquiry = null, 100 105 .size = null, 101 106 .title_changed = null, 107 + .pwd_changed = null, 102 108 .write_pty = null, 103 109 .xtversion = null, 104 110 }; ··· 268 274 .request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi), 269 275 .size_report => self.reportSize(value), 270 276 .window_title => self.windowTitle(value.title), 277 + .report_pwd => self.reportPwd(value.url), 271 278 .xtversion => self.reportXtversion(), 272 279 273 280 // No supported DCS commands have any terminal-modifying effects, ··· 278 285 => {}, 279 286 280 287 // Have no terminal-modifying effect 281 - .report_pwd, 282 288 .show_desktop_notification, 283 289 .progress_report, 284 290 .clipboard_contents, ··· 434 440 }; 435 441 436 442 const func = self.effects.title_changed orelse return; 443 + func(self); 444 + } 445 + 446 + fn reportPwd(self: *Handler, url_raw: []const u8) void { 447 + // Prevent DoS attacks by limiting url length. Headroom for 448 + // Linux PATH_MAX (4096) plus URI scheme/host and percent-encoding. 449 + const max_url_len = 4096; 450 + const url = if (url_raw.len > max_url_len) url: { 451 + log.warn("pwd url length {d} exceeds max length {d}, truncating", .{ 452 + url_raw.len, 453 + max_url_len, 454 + }); 455 + break :url url_raw[0..max_url_len]; 456 + } else url_raw; 457 + 458 + // We store the raw payload unparsed. Embedders read it via 459 + // getPwd() and are responsible for decoding any URI scheme. 460 + self.terminal.setPwd(url) catch |err| { 461 + log.warn("error setting pwd err={}", .{err}); 462 + return; 463 + }; 464 + 465 + const func = self.effects.pwd_changed orelse return; 437 466 func(self); 438 467 } 439 468