diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 8a914a47d..147410678 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1210,6 +1210,22 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: size_t* */ GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES = 36, + + /** + * Set the name of the terminfo entry this terminal runs as, reported + * in response to an XTGETTCAP query for "TN" (e.g. "xterm-256color"). + * + * The string data is copied into the terminal. A NULL value pointer + * clears the name (equivalent to setting an empty string). A name + * longer than 128 bytes returns GHOSTTY_INVALID_VALUE. + * + * If this is unset then we don't report anything for an XTGETTCAP + * TN query, because we don't know what the embedding terminal around + * libghostty is advertising itself as. + * + * Input type: GhosttyString* + */ + GHOSTTY_TERMINAL_OPT_TERMINFO_NAME = 37, 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 52619bc58..99a45e1ed 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -98,6 +98,9 @@ const TerminalWrapper = struct { /// We also need to store a temp dir path for some operations (e.g., kitty /// graphics). This provides stable storage for the API calls. tmp_dir_path: [max_path_bytes]u8, + /// The terminfo name reported for XTGETTCAP "TN". The stream handler holds + /// a slice into this. + terminfo_name_buf: [Handler.max_terminfo_name_bytes]u8, stream: Stream, effects: Effects = .{}, tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{}, @@ -519,6 +522,7 @@ fn wrap( .terminal = t, .io = io, .tmp_dir_path = undefined, + .terminfo_name_buf = undefined, .stream = Stream.init(.{ .allocator = alloc, .handler = handler, @@ -963,6 +967,7 @@ pub const Option = enum(c_int) { mode = 34, unknown_sequence = 35, unknown_max_bytes = 36, + terminfo_name = 37, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -981,7 +986,7 @@ pub const Option = enum(c_int) { .size_cb => ?Effects.SizeFn, .clipboard_write => ?Effects.ClipboardWriteFn, .unknown_sequence => ?Effects.UnknownSequenceFn, - .title, .pwd => ?*const lib.String, + .title, .pwd, .terminfo_name => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, .kitty_image_storage_limit => ?*const u64, @@ -1067,6 +1072,15 @@ fn setTyped( const str = if (value) |v| v.ptr[0..v.len] else ""; wrapper.terminal.setPwd(str) catch return .out_of_memory; }, + .terminfo_name => { + const str = if (value) |v| v.ptr[0..v.len] else ""; + if (str.len > wrapper.terminfo_name_buf.len) return .invalid_value; + @memcpy(wrapper.terminfo_name_buf[0..str.len], str); + wrapper.stream.handler.terminfo_name = if (str.len > 0) + wrapper.terminfo_name_buf[0..str.len] + else + null; + }, .color_foreground => { wrapper.terminal.colors.foreground.default = if (value) |v| .fromC(v.*) else null; wrapper.terminal.flags.dirty.palette = true; @@ -3735,6 +3749,73 @@ test "xtversion without callback reports default" { try testing.expectEqualStrings("\x1BP>|libghostty\x1B\\", S.last_data.?); } +test "set terminfo_name option" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + // While no name is set the query goes unanswered; other capabilities + // are still served from the static map. + const query = "\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ "\x1B\\"; + vt_write(t, query, query.len); + try testing.expect(S.last_data == null); + const co_query = "\x1BP+q" ++ std.fmt.bytesToHex("Co", .upper) ++ "\x1B\\"; + vt_write(t, co_query, co_query.len); + try testing.expectEqualStrings( + "\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++ + std.fmt.bytesToHex("256", .upper) ++ "\x1B\\", + S.last_data.?, + ); + S.deinit(); + + // The name is copied, so the caller's buffer can go away afterwards. + var name: [14]u8 = "xterm-256color".*; + const value: lib.String = .{ .ptr = &name, .len = name.len }; + try testing.expectEqual(Result.success, set(t, .terminfo_name, @ptrCast(&value))); + @memset(&name, 'z'); + + vt_write(t, query, query.len); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings( + "\x1BP1+r" ++ std.fmt.bytesToHex("TN", .upper) ++ "=" ++ + std.fmt.bytesToHex("xterm-256color", .upper) ++ "\x1B\\", + S.last_data.?, + ); + + // Clearing with NULL leaves the query unanswered again. + S.deinit(); + try testing.expectEqual(Result.success, set(t, .terminfo_name, null)); + vt_write(t, query, query.len); + try testing.expect(S.last_data == null); + + // Names beyond the maximum are rejected rather than truncated. + const long: [Handler.max_terminfo_name_bytes + 1]u8 = @splat('a'); + const long_value: lib.String = .{ .ptr = &long, .len = long.len }; + try testing.expectEqual(Result.invalid_value, set(t, .terminfo_name, @ptrCast(&long_value))); +} + test "set title_changed 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 c13f82769..2599097af 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -17,6 +17,7 @@ const osc_color = @import("osc/parsers/color.zig"); const kitty_color = @import("kitty/color.zig"); const size_report = @import("size_report.zig"); const simd = @import("../simd/main.zig"); +const terminfo = @import("../terminfo/main.zig"); const Terminal = @import("Terminal.zig"); const log = std.log.scoped(.stream_terminal); @@ -76,6 +77,17 @@ pub const Handler = struct { /// before starting the Stream to enable APC capture. unknown_sequence: ?*const fn (*Handler, UnknownSequence) void = null, + /// The name of the terminfo entry this terminal runs as, reported in + /// response to an XTGETTCAP query for "TN". + /// + /// The memory must remain valid for the lifetime of the handler. + /// Empty names and names longer than `max_terminfo_name_bytes` are + /// silently ignored. + terminfo_name: ?[]const u8 = null, + + /// Maximum byte length accepted for `terminfo_name`. + pub const max_terminfo_name_bytes = 128; + pub const Effects = struct { /// Called when the terminal needs to write data back to the pty, /// e.g. in response to a DECRQM query. The data is only valid @@ -424,12 +436,51 @@ pub const Handler = struct { self.writePty(response[0..encoded.len :0]); }, - .tmux, - .xtgettcap, - => {}, + .xtgettcap => |*gettcap| { + if (self.effects.write_pty == null) return; + const map = comptime terminfo.ghostty.xtgettcapMap(); + while (gettcap.next()) |key| { + if (std.mem.eql(u8, key, encoded_tn_key)) { + self.writeTerminfoName(); + continue; + } + self.writePty(map.get(key) orelse continue); + } + }, + + .tmux => {}, } } + // Hex-encoded "TN", the XTGETTCAP key naming the terminfo entry. + // The static map also carries this key with Ghostty's own name, so + // it is intercepted before the lookup: an embedder that never + // configured a name must not be reported as Ghostty's entry. + const encoded_tn_key = &std.fmt.bytesToHex("TN", .upper); + + /// Answer an XTGETTCAP "TN" query from the configured terminfo name. + /// Unset, empty, or over-long names leave the query unanswered. + fn writeTerminfoName(self: *Handler) void { + const name = self.terminfo_name orelse return; + if (name.len == 0 or name.len > max_terminfo_name_bytes) return; + + // Fixed upper bound for an encoded "TN" reply calculated + // at comptime from our max terminfo size. + const max_tn_response_bytes = + comptime "\x1bP1+r".len + encoded_tn_key.len + "=".len + + (max_terminfo_name_bytes * 2) + "\x1b\\".len + + 1; // null terminator + + // Values are hex-encoded uppercase, matching the static map. The + // buffer fits any name allowed above, so the print cannot fail. + var buf: [max_tn_response_bytes]u8 = undefined; + self.writePty(std.fmt.bufPrintZ( + &buf, + "\x1bP1+r" ++ encoded_tn_key ++ "={X}\x1b\\", + .{name}, + ) catch unreachable); + } + fn bell(self: *Handler) void { const func = self.effects.bell orelse return; func(self); @@ -1550,6 +1601,161 @@ test "DECRQSS without write effect is ignored" { try testing.expect(!s.handler.semantic_failure); } +test "XTGETTCAP responses" { + var t: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer t.deinit(testing.allocator); + + const S = struct { + var response: [128]u8 = undefined; + var response_len: usize = 0; + var calls: usize = 0; + + fn reset() void { + response_len = 0; + calls = 0; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + @memcpy(response[0..data.len], data); + response_len = data.len; + calls += 1; + } + + fn expectResponse(expected: []const u8) !void { + try testing.expectEqual(@as(usize, 1), calls); + try testing.expectEqualStrings( + expected, + response[0..response_len], + ); + reset(); + } + }; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // The full capability table comes from the static terminfo map; this + // checks the wiring for a valued and a valueless (boolean) capability. + s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("Co", .upper) ++ "\x1B\\"); + try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++ + std.fmt.bytesToHex("256", .upper) ++ "\x1B\\"); + s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("am", .upper) ++ "\x1B\\"); + try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("am", .upper) ++ "\x1B\\"); + + // One response per requested key; lowercase hex is normalized by the + // DCS parser. The capture holds the last ("Co") reply. + s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("am", .lower) ++ ";" ++ + std.fmt.bytesToHex("Co", .lower) ++ "\x1B\\"); + try testing.expectEqual(@as(usize, 2), S.calls); + try testing.expectEqualStrings( + "\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++ + std.fmt.bytesToHex("256", .upper) ++ "\x1B\\", + S.response[0..S.response_len], + ); + S.reset(); + + // Unknown and malformed keys are skipped without an error. + s.nextSlice("\x1BP+qWHO;5;GG\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.calls); + try testing.expect(!s.handler.semantic_failure); +} + +test "XTGETTCAP without write effect is ignored" { + var t: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer t.deinit(testing.allocator); + + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); + defer s.deinit(); + + s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ ";" ++ + std.fmt.bytesToHex("am", .upper) ++ "\x1B\\"); + try testing.expect(!s.handler.semantic_failure); +} + +test "XTGETTCAP TN responses" { + var t: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer t.deinit(testing.allocator); + + const S = struct { + var response: [512]u8 = undefined; + var response_len: usize = 0; + var calls: usize = 0; + + fn reset() void { + response_len = 0; + calls = 0; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + @memcpy(response[0..data.len], data); + response_len = data.len; + calls += 1; + } + + fn expectResponse(expected: []const u8) !void { + try testing.expectEqual(@as(usize, 1), calls); + try testing.expectEqualStrings( + expected, + response[0..response_len], + ); + reset(); + } + }; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + const tn_query = "\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ "\x1B\\"; + + // While no name is configured the query goes unanswered. + s.nextSlice(tn_query); + try testing.expectEqual(@as(usize, 0), S.calls); + + // A configured name is reported hex-encoded. + s.handler.terminfo_name = "xterm-256color"; + s.nextSlice(tn_query); + try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("TN", .upper) ++ "=" ++ + std.fmt.bytesToHex("xterm-256color", .upper) ++ "\x1B\\"); + + // A maximum-length name is still reported in full. + const max_name = "a" ** Handler.max_terminfo_name_bytes; + s.handler.terminfo_name = max_name; + s.nextSlice(tn_query); + try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("TN", .upper) ++ "=" ++ + std.fmt.bytesToHex(max_name.*, .upper) ++ "\x1B\\"); + + // An empty name is silent; "Co" is still answered. + s.handler.terminfo_name = ""; + s.nextSlice("\x1BP+q" ++ std.fmt.bytesToHex("TN", .upper) ++ ";" ++ + std.fmt.bytesToHex("Co", .upper) ++ "\x1B\\"); + try S.expectResponse("\x1BP1+r" ++ std.fmt.bytesToHex("Co", .upper) ++ "=" ++ + std.fmt.bytesToHex("256", .upper) ++ "\x1B\\"); + + // As are names beyond the maximum length. + s.handler.terminfo_name = "a" ** (Handler.max_terminfo_name_bytes + 1); + s.nextSlice(tn_query); + try testing.expectEqual(@as(usize, 0), S.calls); + try testing.expect(!s.handler.semantic_failure); +} + test "DCS command memory is released" { var t: Terminal = try .init( testing.io, @@ -1560,8 +1766,8 @@ test "DCS command memory is released" { var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); - // A completed, unsupported command transfers its allocation to Command; - // dcsCommand must release it even though stream_terminal ignores it. + // A completed command transfers its allocation to Command; dcsCommand + // must release it even when there is no write effect. s.nextSlice("\x1BP+q536D756C78\x1B\\"); // An incomplete command remains owned by the handler and must be released diff --git a/src/terminfo/Source.zig b/src/terminfo/Source.zig index 922eec016..357e0ca26 100644 --- a/src/terminfo/Source.zig +++ b/src/terminfo/Source.zig @@ -12,7 +12,7 @@ const std = @import("std"); /// and are used to look up this terminal. Historically, the final name in the /// list was the most common name for the terminal and contains spaces and /// other characters. See terminfo(5) for details. -names: []const []const u8, +names: []const [:0]const u8, /// The set of capabilities in this terminfo file. capabilities: []const Capability, @@ -36,7 +36,7 @@ pub const Capability = struct { /// because it is a common integer size but this may be wrong. numeric: u32, - string: []const u8, + string: [:0]const u8, }; }; @@ -68,8 +68,12 @@ pub fn encode(self: Source, writer: *std.Io.Writer) !void { /// Returns a StaticStringMap for all of the capabilities in this terminfo. /// The value is the value that should be sent as a response to XTGETTCAP. /// Important: the value is the FULL response included the escape sequences. -pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { - const KV = struct { []const u8, []const u8 }; +/// +/// The responses are null-terminated so that they can be handed directly +/// to APIs that require a sentinel (e.g. the libghostty-vt write_pty +/// callback) without copying. +pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([:0]const u8) { + const KV = struct { []const u8, [:0]const u8 }; // We have all of our capabilities plus To, TN, and RGB which aren't // in the capabilities list but are query-able. @@ -115,11 +119,11 @@ pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { break :string result; }, .numeric => |v| numeric: { - var buf: [10]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buf); + var buf: [11]u8 = @splat(0); + var writer: std.Io.Writer = .fixed(buf[0..10]); writer.printInt(v, 10, .upper, .{}) catch unreachable; const final = buf; - break :numeric final[0..writer.end]; + break :numeric final[0..writer.end :0]; }, }, }; @@ -130,8 +134,9 @@ pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { // The key is just the raw hex-encoded string entry[0] = hexencode(entry[0]); - // The value is more complex - var buf: [5 + entry[0].len + 1 + (entry[1].len * 2) + 2]u8 = undefined; + // The value is more complex. The buffer is zeroed so the byte + // after the response is already the null terminator. + var buf: [5 + entry[0].len + 1 + (entry[1].len * 2) + 2 + 1]u8 = @splat(0); const out = if (std.mem.eql(u8, entry[1], "")) std.fmt.bufPrint( &buf, "\x1bP1+r{s}\x1b\\", @@ -143,11 +148,11 @@ pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) { ) catch unreachable; const final = buf; - entry[1] = final[0..out.len]; + entry[1] = final[0..out.len :0]; } const kvs_final = kvs; - return std.StaticStringMap([]const u8).initComptime(&kvs_final); + return std.StaticStringMap([:0]const u8).initComptime(&kvs_final); } fn hexencode(comptime input: []const u8) []const u8 { @@ -160,13 +165,13 @@ fn comptimeReplace( input: []const u8, needle: []const u8, replacement: []const u8, -) []const u8 { +) [:0]const u8 { comptime { const len = std.mem.replacementSize(u8, input, needle, replacement); - var buf: [len]u8 = undefined; - _ = std.mem.replace(u8, input, needle, replacement, &buf); + var buf: [len + 1]u8 = @splat(0); + _ = std.mem.replace(u8, input, needle, replacement, buf[0..len]); const final = buf; - return &final; + return final[0..len :0]; } }