terminal: treat high bytes in DCS strings as payload data (#14016)

Refs #11216

The dcs_passthrough state only forwarded bytes 0x00-0x7E to the DCS
handler. Bytes 0x80-0x9F hit the "anywhere" C1 transitions and exited
the string, while 0xA0-0xFF fell through to the default transition and
were silently dropped. **This breaks any DCS payload carrying UTF-8. **

A continuation byte in the C1 range terminates or corrupts the string:
"Ü" is 0xC3 0x9C, so the 0xC3 is dropped and the 0x9C acts as 8-bit ST,
ending the DCS mid-character.

Also, a payload byte such as 0x9B (second byte of "Û") transitions to
csi_entry, so the remainder of the payload executes as a live control
sequence. This is a prerequisite for tmux control mode (#1935), whose
%output notifications carry raw UTF-8 pane content.

Fix this in the parse table only: override 0x80-0xFF in dcs_passthrough
to put and in dcs_ignore to ignore, exactly how osc_string already
claims 0x20-0xFF (including 0x9C) as data. This deviates from the
vt100.net state machine
(https://vt100.net/emu/dec_ansi_parser) deliberately and includes 0x9C:
a raw 0x9C is indistinguishable from a UTF-8 continuation byte, and we
don't honor 8-bit C1 controls in the ground state either.
This commit is contained in:
Mitchell Hashimoto
2026-08-25 08:42:26 -07:00
committed by GitHub

View File

@@ -206,6 +206,13 @@ fn genTable() Table {
single(&result, 0x19, source, source, .ignore);
range(&result, 0, 0x17, source, source, .ignore);
range(&result, 0x1C, 0x1F, source, source, .ignore);
// High bytes are ignored payload data, overriding the
// "anywhere" C1 transitions. See dcs_passthrough below for more.
// In dcs_ignore the additional concern is that a UTF-8 payload in
// an ignored DCS could otherwise begin a live sequence mid-string
// (e.g. 0x9B => csi_entry).
range(&result, 0x80, 0xFF, source, source, .ignore);
}
// dcs_param
@@ -241,6 +248,21 @@ fn genTable() Table {
range(&result, 0x1C, 0x1F, source, source, .put);
range(&result, 0x20, 0x7E, source, source, .put);
single(&result, 0x7F, source, source, .ignore);
// High bytes are payload data, overriding the "anywhere" C1
// transitions, matching how osc_string handles them below.
// Ghostty is UTF-8 only, and DCS payloads carry UTF-8 text
// (e.g. tmux control mode pane content): without this, a
// UTF-8 continuation byte in the C1 range terminates or
// corrupts the string and 0xA0-0xFF are silently dropped.
//
// This includes 0x9C (8-bit ST) on purpose: a raw 0x9C is
// indistinguishable from a UTF-8 continuation byte ("Ü" is
// 0xC3 0x9C), and Ghostty doesn't honor 8-bit C1 controls in
// the ground state either (they go through UTF-8 decoding).
// DCS strings terminate via 7-bit ST (ESC \) and abort via
// CAN/SUB, which are unaffected here.
range(&result, 0x80, 0xFF, source, source, .put);
}
// csi_param
@@ -386,3 +408,41 @@ test {
// that it succeeds in creation.
_ = table;
}
test "dcs_passthrough: high bytes are payload data" {
// Bytes 0x80-0xFF within a DCS string are payload data, not C1
// controls. This includes 0x9C (8-bit ST): a raw 0x9C is
// indistinguishable from a UTF-8 continuation byte (e.g. "Ü" is
// 0xC3 0x9C) and Ghostty doesn't support 8-bit C1 controls
// anywhere else.
for (0x80..0x100) |c| {
const entry = table[c][@intFromEnum(State.dcs_passthrough)];
try std.testing.expectEqual(State.dcs_passthrough, entry.state);
try std.testing.expectEqual(Action.put, entry.action);
}
}
test "dcs_ignore: high bytes are ignored payload data" {
// Same as dcs_passthrough: a UTF-8 payload inside an ignored DCS
// must not trigger "anywhere" C1 transitions (e.g. 0x9B beginning
// a CSI mid-string).
for (0x80..0x100) |c| {
const entry = table[c][@intFromEnum(State.dcs_ignore)];
try std.testing.expectEqual(State.dcs_ignore, entry.state);
try std.testing.expectEqual(Action.ignore, entry.action);
}
}
test "dcs_passthrough: ESC, CAN, and SUB still exit" {
// 7-bit ST (ESC \) is the DCS terminator and CAN/SUB abort, so
// these must continue to leave dcs_passthrough. dcs_unhook is
// emitted by the parser on any transition out of dcs_passthrough.
const esc = table[0x1B][@intFromEnum(State.dcs_passthrough)];
try std.testing.expectEqual(State.escape, esc.state);
const can = table[0x18][@intFromEnum(State.dcs_passthrough)];
try std.testing.expectEqual(State.ground, can.state);
const sub = table[0x1A][@intFromEnum(State.dcs_passthrough)];
try std.testing.expectEqual(State.ground, sub.state);
}