config: support unlimited scrollback limits

This commit is contained in:
Mitchell Hashimoto
2026-07-26 20:08:30 -07:00
parent 65c48213b6
commit 1092204df1
3 changed files with 171 additions and 16 deletions

View File

@@ -41,6 +41,7 @@ const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig");
const KeyRemapSet = @import("../input/key_mods.zig").RemapSet;
pub const WindowPaddingBalance = @import("../renderer/size.zig").PaddingBalance;
const string = @import("string.zig");
const Limit = @import("limit.zig").Limit;
// We do this instead of importing all of terminal/main.zig to
// limit the dependency graph. This is important because some things
@@ -1383,10 +1384,10 @@ input: RepeatableReadableIO = .{},
/// are set, then the first one reached will determine when scrollback is
/// removed.
///
/// It is not currently possible to set an unlimited byte limit.
/// The default is 50 MB. Set this to `unlimited` to remove the byte limit.
///
/// This can be changed at runtime but will only affect new terminal surfaces.
@"scrollback-limit-bytes": usize = 50_000_000, // 50MB
@"scrollback-limit-bytes": Limit(usize, 50_000_000) = .default,
/// The maximum number of lines of scrollback to retain. This excludes
/// the active screen. Soft-wrapped lines count as multiple lines.
@@ -1399,12 +1400,12 @@ input: RepeatableReadableIO = .{},
/// This means that the actual limited lines will likely be slightly
/// higher in practice.
///
/// If this is unset, then no line limit will be applied. A separate maximum
/// can be set with `scrollback-limit-bytes`; if both limits are set, then the
/// first one reached will determine when scrollback is removed.
/// The default is `unlimited`. A separate maximum can be set with
/// `scrollback-limit-bytes`; if both limits are set, then the first one reached
/// will determine when scrollback is removed.
///
/// This can be changed at runtime but will only affect new terminal surfaces.
@"scrollback-limit-lines": ?usize = null,
@"scrollback-limit-lines": Limit(usize, std.math.maxInt(usize)) = .default,
/// Whether to compress scrollback pages while the terminal is idle.
///
@@ -10856,6 +10857,15 @@ test "scrollback limits" {
var cfg = try Config.default(alloc);
defer cfg.deinit();
try testing.expectEqual(
@as(usize, 50_000_000),
cfg.@"scrollback-limit-bytes".value,
);
try testing.expectEqual(
std.math.maxInt(usize),
cfg.@"scrollback-limit-lines".value,
);
var it: TestIterator = .{ .data = &.{
"--scrollback-limit-bytes=1234",
"--scrollback-limit-lines=567",
@@ -10864,21 +10874,41 @@ test "scrollback limits" {
try testing.expectEqual(
@as(usize, 1234),
cfg.@"scrollback-limit-bytes",
cfg.@"scrollback-limit-bytes".value,
);
try testing.expectEqual(
@as(?usize, 567),
cfg.@"scrollback-limit-lines",
@as(usize, 567),
cfg.@"scrollback-limit-lines".value,
);
var unset_it: TestIterator = .{ .data = &.{
var unlimited_it: TestIterator = .{ .data = &.{
"--scrollback-limit-bytes=unlimited",
"--scrollback-limit-lines=unlimited",
} };
try cfg.loadIter(alloc, &unlimited_it);
try testing.expectEqual(
std.math.maxInt(usize),
cfg.@"scrollback-limit-bytes".value,
);
try testing.expectEqual(
std.math.maxInt(usize),
cfg.@"scrollback-limit-lines".value,
);
var reset_it: TestIterator = .{ .data = &.{
"--scrollback-limit-bytes=",
"--scrollback-limit-lines=",
} };
try cfg.loadIter(alloc, &unset_it);
try cfg.loadIter(alloc, &reset_it);
try testing.expectEqual(
@as(?usize, null),
cfg.@"scrollback-limit-lines",
@as(usize, 50_000_000),
cfg.@"scrollback-limit-bytes".value,
);
try testing.expectEqual(
std.math.maxInt(usize),
cfg.@"scrollback-limit-lines".value,
);
}
@@ -10895,7 +10925,17 @@ test "compatibility: scrollback-limit renamed to bytes" {
try testing.expectEqual(
@as(usize, 1234),
cfg.@"scrollback-limit-bytes",
cfg.@"scrollback-limit-bytes".value,
);
var unlimited_it: TestIterator = .{ .data = &.{
"--scrollback-limit=unlimited",
} };
try cfg.loadIter(alloc, &unlimited_it);
try testing.expectEqual(
std.math.maxInt(usize),
cfg.@"scrollback-limit-bytes".value,
);
}

115
src/config/limit.zig Normal file
View File

@@ -0,0 +1,115 @@
const std = @import("std");
const formatterpkg = @import("formatter.zig");
/// A configurable integer limit with a declared default and support for the
/// special value `unlimited`.
///
/// Unlimited is stored as the maximum value of the integer type so the value
/// remains directly comparable without optional unwrapping.
pub fn Limit(comptime T: type, comptime default_value: T) type {
comptime std.debug.assert(@typeInfo(T) == .int);
return struct {
const Self = @This();
value: T = default_value,
/// The limit initialized with its declared default value.
pub const default: Self = .{};
/// Parses an integer limit or the special value "unlimited".
pub fn parseCLI(input_: ?[]const u8) !Self {
const input = input_ orelse return error.ValueRequired;
if (std.mem.eql(u8, input, "unlimited")) {
return .{ .value = std.math.maxInt(T) };
}
return .{
.value = std.fmt.parseInt(T, input, 0) catch
return error.InvalidValue,
};
}
/// Formats the maximum integer value as "unlimited" and all other
/// values as integers.
pub fn formatEntry(
self: Self,
formatter: formatterpkg.EntryFormatter,
) !void {
if (self.value == std.math.maxInt(T)) {
try formatter.formatEntry([]const u8, "unlimited");
} else {
try formatter.formatEntry(T, self.value);
}
}
/// Returns the configured value, or null for the unlimited sentinel.
pub fn optional(self: Self) ?T {
if (self.value == std.math.maxInt(T)) return null;
return self.value;
}
/// Returns an independent copy of this value for Config cloning.
pub fn clone(
self: Self,
_: std.mem.Allocator,
) std.mem.Allocator.Error!Self {
return self;
}
};
}
test "Limit default and parsing" {
const testing = std.testing;
const TestLimit = Limit(usize, 42);
try testing.expectEqual(@as(usize, 42), TestLimit.default.value);
try testing.expectEqual(
@as(usize, 123),
(try TestLimit.parseCLI("123")).value,
);
try testing.expectEqual(
std.math.maxInt(usize),
(try TestLimit.parseCLI("unlimited")).value,
);
try testing.expectError(error.InvalidValue, TestLimit.parseCLI("invalid"));
}
test "Limit optional" {
const testing = std.testing;
const TestLimit = Limit(u16, 42);
try testing.expectEqual(@as(?u16, 42), (TestLimit{}).optional());
try testing.expectEqual(
@as(?u16, null),
(TestLimit{ .value = std.math.maxInt(u16) }).optional(),
);
}
test "Limit formatting" {
const testing = std.testing;
const TestLimit = Limit(usize, 42);
{
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
try (TestLimit{ .value = 123 }).formatEntry(
formatterpkg.entryFormatter("limit", &buf.writer),
);
try testing.expectEqualStrings("limit = 123\n", buf.written());
}
{
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
try (TestLimit{ .value = std.math.maxInt(usize) }).formatEntry(
formatterpkg.entryFormatter("limit", &buf.writer),
);
try testing.expectEqualStrings(
"limit = unlimited\n",
buf.written(),
);
}
}

View File

@@ -246,8 +246,8 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
break :opts .{
.cols = grid_size.columns,
.rows = grid_size.rows,
.max_scrollback_bytes = opts.full_config.@"scrollback-limit-bytes",
.max_scrollback_lines = opts.full_config.@"scrollback-limit-lines",
.max_scrollback_bytes = opts.full_config.@"scrollback-limit-bytes".optional(),
.max_scrollback_lines = opts.full_config.@"scrollback-limit-lines".optional(),
.default_modes = default_modes,
.default_cursor_style = opts.config.cursor_style,
.default_cursor_blink = opts.config.cursor_blink,