6 Commits

Author SHA1 Message Date
Mitchell Hashimoto
5013d028a3 terminal: fix csi parsing (#8417)
Make `MAX_PARAMS` public and increase CSI parameter limit from 16 to 24.
Fix potential out-of-bounds read in SGR partial sequence extraction.

Related discussion:
https://github.com/ghostty-org/ghostty/discussions/5198

DISCLAIMER: the tests were written with Claude Code's help.
2025-08-27 07:20:16 -07:00
Mitchell Hashimoto
adfc93047c terminal: fix up some tests to be more robust 2025-08-27 07:15:42 -07:00
Adrià Arrufat
a3f4997fbc fix(terminal): handle CSI/SGR with many parameters
Adds tests to ensure CSI and SGR sequences with 17 or more parameters are correctly parsed, fixing a bug where later parameters were previously dropped.
2025-08-27 07:10:17 -07:00
Adrià Arrufat
56d3fd872e fix(terminal): improve CSI parameter parsing
Make `MAX_PARAMS` public and increase CSI parameter limit from 16 to 24.
Fix potential out-of-bounds read in SGR partial sequence extraction.
2025-08-27 07:10:17 -07:00
Mitchell Hashimoto
58e85bf133 macos: use visible frame for quick terminal sizing calculation (#8423)
Fixes #8418

This fixes issues where left/right positions would be cut off from the
menu bar. And makes it so that size 100%,100% doesn't overflow into the
non-visible space of the edge of the screen.

I didn't just copy and paste, I tested each of these code paths.
2025-08-27 07:06:55 -07:00
Mitchell Hashimoto
19a27383f8 macos: use visible frame for quick terminal sizing calculation
Fixes #8418

This fixes issues where left/right positions would be cut off from the
menu bar. And makes it so that size 100%,100% doesn't overflow into the
non-visible space of the edge of the screen.
2025-08-27 06:59:02 -07:00
4 changed files with 103 additions and 19 deletions

View File

@@ -13,7 +13,7 @@ enum QuickTerminalPosition : String {
guard let screen = window.screen ?? NSScreen.main else { return }
window.setFrame(.init(
origin: window.frame.origin,
size: size.calculate(position: self, screenDimensions: screen.frame.size)
size: size.calculate(position: self, screenDimensions: screen.visibleFrame.size)
), display: false)
}
@@ -58,7 +58,7 @@ enum QuickTerminalPosition : String {
/// Get the configured frame size for initial positioning and animations.
func configuredFrameSize(on screen: NSScreen, terminalSize: QuickTerminalSize) -> NSSize {
let dimensions = terminalSize.calculate(position: self, screenDimensions: screen.frame.size)
let dimensions = terminalSize.calculate(position: self, screenDimensions: screen.visibleFrame.size)
return NSSize(width: dimensions.width, height: dimensions.height)
}
@@ -66,16 +66,24 @@ enum QuickTerminalPosition : String {
func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch (self) {
case .top:
return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: screen.frame.maxY)
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: screen.visibleFrame.maxY)
case .bottom:
return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: -window.frame.height)
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: -window.frame.height)
case .left:
return .init(x: screen.frame.minX-window.frame.width, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2))
return .init(
x: screen.visibleFrame.minX-window.frame.width,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .right:
return .init(x: screen.frame.maxX, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2))
return .init(
x: screen.visibleFrame.maxX,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .center:
return .init(x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: screen.visibleFrame.height - window.frame.width)
@@ -86,16 +94,24 @@ enum QuickTerminalPosition : String {
func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint {
switch (self) {
case .top:
return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: screen.visibleFrame.maxY - window.frame.height)
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: screen.visibleFrame.maxY - window.frame.height)
case .bottom:
return .init(x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2), y: screen.frame.minY)
return .init(
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: screen.visibleFrame.minY)
case .left:
return .init(x: screen.frame.minX, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2))
return .init(
x: screen.visibleFrame.minX,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .right:
return .init(x: screen.visibleFrame.maxX - window.frame.width, y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2))
return .init(
x: screen.visibleFrame.maxX - window.frame.width,
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
case .center:
return .init(x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2))
@@ -125,13 +141,13 @@ enum QuickTerminalPosition : String {
switch self {
case .top:
return CGPoint(
x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2),
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: window.frame.origin.y // Keep the same Y position
)
case .bottom:
return CGPoint(
x: round(screen.frame.origin.x + (screen.frame.width - window.frame.width) / 2),
x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2),
y: window.frame.origin.y // Keep the same Y position
)
@@ -153,13 +169,13 @@ enum QuickTerminalPosition : String {
case .left:
return CGPoint(
x: window.frame.origin.x, // Keep the same X position
y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2)
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
)
case .right:
return CGPoint(
x: window.frame.origin.x, // Keep the same X position
y: round(screen.frame.origin.y + (screen.frame.height - window.frame.height) / 2)
y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)
)
case .top, .bottom, .center:

View File

@@ -193,7 +193,7 @@ pub const Action = union(enum) {
/// Maximum number of intermediate characters during parsing. This is
/// 4 because we also use the intermediates array for UTF8 decoding which
/// can be at most 4 bytes.
const MAX_INTERMEDIATE = 4;
pub const MAX_INTERMEDIATE = 4;
/// Maximum number of CSI parameters. This is arbitrary. Practically, the
/// only CSI command that uses more than 3 parameters is the SGR command
@@ -206,7 +206,7 @@ const MAX_INTERMEDIATE = 4;
/// number. I implore TUI authors to not use more than this number of CSI
/// params, but I suspect we'll introduce a slow path with heap allocation
/// one day.
const MAX_PARAMS = 24;
pub const MAX_PARAMS = 24;
/// Current state of the state machine
state: State,
@@ -949,6 +949,55 @@ test "csi: too many params" {
}
}
test "csi: sgr with up to our max parameters" {
for (1..MAX_PARAMS + 1) |max| {
var p = init();
_ = p.next(0x1B);
_ = p.next('[');
for (0..max - 1) |_| {
_ = p.next('1');
_ = p.next(';');
}
_ = p.next('2');
{
const a = p.next('H');
try testing.expect(p.state == .ground);
try testing.expect(a[0] == null);
try testing.expect(a[1].? == .csi_dispatch);
try testing.expect(a[2] == null);
const csi = a[1].?.csi_dispatch;
try testing.expectEqual(@as(usize, max), csi.params.len);
try testing.expectEqual(@as(u16, 2), csi.params[max - 1]);
}
}
}
test "csi: sgr beyond our max drops it" {
// Has to be +2 for the loops below
const max = MAX_PARAMS + 2;
var p = init();
_ = p.next(0x1B);
_ = p.next('[');
for (0..max - 1) |_| {
_ = p.next('1');
_ = p.next(';');
}
_ = p.next('2');
{
const a = p.next('H');
try testing.expect(p.state == .ground);
try testing.expect(a[0] == null);
try testing.expect(a[1] == null);
try testing.expect(a[2] == null);
}
}
test "dcs: XTGETTCAP" {
var p = init();
_ = p.next(0x1B);

View File

@@ -134,7 +134,7 @@ pub const Parser = struct {
self.idx += 1;
return .{ .unknown = .{
.full = self.params,
.partial = slice[0 .. self.idx - start + 1],
.partial = slice[0..@min(self.idx - start + 1, slice.len)],
} };
},
};

View File

@@ -249,7 +249,7 @@ pub fn Stream(comptime Handler: type) type {
// the parser state to ground.
0x18, 0x1A => self.parser.state = .ground,
// A parameter digit:
'0'...'9' => if (self.parser.params_idx < 16) {
'0'...'9' => if (self.parser.params_idx < Parser.MAX_PARAMS) {
self.parser.param_acc *|= 10;
self.parser.param_acc +|= c - '0';
// The parser's CSI param action uses param_acc_idx
@@ -259,7 +259,7 @@ pub fn Stream(comptime Handler: type) type {
self.parser.param_acc_idx |= 1;
},
// A parameter separator:
':', ';' => if (self.parser.params_idx < 16) {
':', ';' => if (self.parser.params_idx < Parser.MAX_PARAMS) {
self.parser.params[self.parser.params_idx] = self.parser.param_acc;
if (c == ':') self.parser.params_sep.set(self.parser.params_idx);
self.parser.params_idx += 1;
@@ -2601,3 +2601,22 @@ test "stream CSI ? W reset tab stops" {
try s.nextSlice("\x1b[?1;2;3W");
try testing.expect(s.handler.reset);
}
test "stream: SGR with 17+ parameters for underline color" {
const H = struct {
attrs: ?sgr.Attribute = null,
called: bool = false,
pub fn setAttribute(self: *@This(), attr: sgr.Attribute) !void {
self.attrs = attr;
self.called = true;
}
};
var s: Stream(H) = .init(.{});
// Kakoune-style SGR with underline color as 17th parameter
// This tests the fix where param 17 was being dropped
try s.nextSlice("\x1b[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136;0m");
try testing.expect(s.handler.called);
}