lib-vt: answer XTGETTCAP queries

Ghostty's full termio path answers XTGETTCAP from the static terminfo
map, but terminal/stream_terminal.zig, which backs libghostty-vt,
parses the same DCS request and then discards it. There is no XTGETTCAP
effect either, so an embedder cannot restore the replies through the
C API.

Programs query these over SSH instead of assuming the remote host has
the client's terminfo entry. This matters more for an embedder than for
the desktop app, which can install its entry on the remote through
shell integration.

Answer the queries in stream_terminal the same way termio does: look
up each requested key in the static terminfo map and write the reply
to the pty, skipping the lookups entirely when no write_pty effect is
set. The map now stores null-terminated responses so they can be
handed straight to write_pty without copying. terminal/dcs.zig and the
termio path are unchanged.

"TN" is handled separately. It names the terminfo entry the terminal
runs as, so it has to agree with TERM -- which is set in
termio/Exec.zig, a layer libghostty-vt does not contain. The library
never sees TERM and cannot answer on the embedder's behalf, and
answering with Ghostty's own entry from the static map would misreport
every embedder, so "TN" is intercepted before the map lookup. The name
is instead configured through a new option,
GHOSTTY_TERMINAL_OPT_TERMINFO_NAME: the string is copied into the
terminal, names longer than 128 bytes are rejected, and while unset
the query goes unanswered.

This is the first dependency from src/terminal on src/terminfo, so
libghostty-vt now carries Ghostty's terminfo table: +16,023 bytes
(+1.9%) on a wasm32-freestanding ReleaseSmall build.

Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
This commit is contained in:
Fredrik Fornwall
2026-08-09 02:59:37 +02:00
committed by Mitchell Hashimoto
parent 163e229756
commit 74efadb446
4 changed files with 329 additions and 21 deletions

View File

@@ -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;

View File

@@ -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(

View File

@@ -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

View File

@@ -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];
}
}