From 07272ae88f4e15608383f0cc786586ec84d66e41 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Mar 2026 19:54:34 -0700 Subject: [PATCH] stream: add bell effect callback support Add an Effects struct to the readonly stream Handler that allows callers to provide optional callbacks for side effects like bell. Previously, the bell action was silently ignored along with other query/response actions. Now it is handled separately and dispatched through the effects callback if one is provided. Add a test that verifies bell with a null callback (default readonly behavior) does not crash, and that a provided callback is invoked the correct number of times. --- src/terminal/stream_readonly.zig | 71 +++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index a3e98c8e0..d072418d1 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -31,6 +31,24 @@ pub const Handler = struct { /// The terminal state to modify. terminal: *Terminal, + /// Callbacks for certain effects that handlers may have. These + /// may or may not fully replace internal handling of certain effects, + /// but they allow for the handler to trigger or query external + /// effects. + effects: Effects = .readonly, + + pub const Effects = struct { + /// Called when the bell is rung (BEL). + bell: ?*const fn (*Handler) void, + + /// No effects means that the stream effectively becomes readonly + /// that only affects pure terminal state and ignores all side + /// effects beyond that. + pub const readonly: Effects = .{ + .bell = null, + }; + }; + pub fn init(terminal: *Terminal) Handler { return .{ .terminal = terminal, @@ -170,6 +188,9 @@ pub const Handler = struct { .color_operation => try self.colorOperation(value.op, &value.requests), .kitty_color_report => try self.kittyColorOperation(value), + // Effect-based handlers + .bell => self.bell(), + // No supported DCS commands have any terminal-modifying effects, // but they may in the future. For now we just ignore it. .dcs_hook, @@ -185,7 +206,6 @@ pub const Handler = struct { => {}, // Have no terminal-modifying effect - .bell, .enquiry, .request_mode, .request_mode_unknown, @@ -205,6 +225,11 @@ pub const Handler = struct { } } + inline fn bell(self: *Handler) void { + const func = self.effects.bell orelse return; + func(self); + } + inline fn horizontalTab(self: *Handler, count: u16) void { for (0..count) |_| { const x = self.terminal.screens.active.cursor.x; @@ -1001,6 +1026,50 @@ test "semantic prompt end_prompt_start_input_terminate_eol clears on linefeed" { try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); } +test "bell effect callback" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + // Test bell with null callback (default readonly effects) doesn't crash + { + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + s.nextSlice("\x07"); + + // Terminal should still be functional after bell + s.nextSlice("AfterBell"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AfterBell", str); + } + + t.fullReset(); + + // Test bell with a callback + { + const S = struct { + var bell_count: usize = 0; + fn bell(_: *Handler) void { + bell_count += 1; + } + }; + S.bell_count = 0; + + var handler: Handler = .init(&t); + handler.effects.bell = &S.bell; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x07"); + try testing.expectEqual(@as(usize, 1), S.bell_count); + + s.nextSlice("\x07\x07"); + try testing.expectEqual(@as(usize, 3), S.bell_count); + } +} + test "stream: CSI W with intermediate but no params" { // Regression test from AFL++ crash. CSI ? W without // parameters caused an out-of-bounds access on input.params[0].