a73x

cefede26

feat: term_modes carries the alternate screen and DECCKM, so the wheel rule needs no engine

a73x   2026-09-04 18:04

Commit message
feat: term_modes carries the alternate screen and DECCKM, so the wheel rule needs no engine

The client's alternate-scroll rule asked its replica engine two mode
questions: is the alternate screen up, and is DECCKM set. Both are now
sampled bits on the wire beside bracketed paste and the mouse modes, so
the rule holds for a client that parses no VT of its own.

The daemon samples them in sampledModes, which is the one place the
change-detecting sampler and the attach-time send share.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsWfuJFQbTfGtKZLS5qw4q

src/engine/protocol.zig
Old New
@@ -40,7 +40,7 @@ pub const MsgType = enum(u8) {
40 cmd_state = 0x8a, // payload: CmdState (see encodeCmdState); pushed on marks-regime transitions 40 cmd_state = 0x8a, // payload: CmdState (see encodeCmdState); pushed on marks-regime transitions
41 await_reply = 0x8b, // payload: CmdState ++ 1 byte AwaitReason 41 await_reply = 0x8b, // payload: CmdState ++ 1 byte AwaitReason
42 status_reply = 0x8c, // payload: StatusReply (see encodeStatusReply) 42 status_reply = 0x8c, // payload: StatusReply (see encodeStatusReply)
43 term_modes = 0x8d, // payload: u32 LE bitset; bit0 bracketed paste 43 term_modes = 0x8d, // payload: u32 LE bitset; bit0 bracketed paste, then the mouse bits, alt_screen and cursor_keys (see TermModes)
44 term_title = 0x8e, // payload: UTF-8 title bytes, never empty (see sampleTermTitle), never longer than term_title_max 44 term_title = 0x8e, // payload: UTF-8 title bytes, never empty (see sampleTermTitle), never longer than term_title_max
45 term_event = 0x8f, // payload: 1 byte kind ++ kind-specific bytes (see TermEvent) 45 term_event = 0x8f, // payload: 1 byte kind ++ kind-specific bytes (see TermEvent)
46 selection_reply = 0x90, // payload: SelectionReply (see encodeSelectionReply) 46 selection_reply = 0x90, // payload: SelectionReply (see encodeSelectionReply)
@@ -703,7 +703,14 @@ pub const TermModes = packed struct(u32) {
703 mouse_sgr: bool = false, 703 mouse_sgr: bool = false,
704 mouse_urxvt: bool = false, 704 mouse_urxvt: bool = false,
705 mouse_sgr_pixels: bool = false, 705 mouse_sgr_pixels: bool = false,
706 _pad: u23 = 0, 706 // The alternate screen (DEC 1049) and DECCKM (DEC 1). The client's
707 // wheel rule needs both — the alternate screen decides whether a notch
708 // becomes arrow keys, DECCKM decides which arrows — and it needs them
709 // without a VT parser of its own, which is why they are sampled here
710 // rather than read off a replica engine.
711 alt_screen: bool = false,
712 cursor_keys: bool = false,
713 _pad: u21 = 0,
707 714
708 /// Whether the wheel belongs to the app rather than to scrollback — a 715 /// Whether the wheel belongs to the app rather than to scrollback — a
709 /// format mode spells events, it does not ask for them. 716 /// format mode spells events, it does not ask for them.
@@ -2553,6 +2560,19 @@ test "term_modes: the mouse bits are one per DEC mode, and only tracking is owne
2553 } 2560 }
2554 } 2561 }
2555 2562
2563 test "term_modes: alt_screen and cursor_keys round-trip and are off by default" {
2564 // The client's wheel rule used to ask its replica engine whether the
2565 // alternate screen was up and whether DECCKM was set. Carrying both as
2566 // sampled bits is what lets a client decide without an engine.
2567 const on = encodeTermModes(.{ .bracketed_paste = false, .alt_screen = true, .cursor_keys = true });
2568 const back = try decodeTermModes(&on);
2569 try std.testing.expect(back.alt_screen);
2570 try std.testing.expect(back.cursor_keys);
2571 try std.testing.expect(!back.bracketed_paste);
2572 const none = try decodeTermModes(&encodeTermModes(.{ .bracketed_paste = false }));
2573 try std.testing.expect(!none.alt_screen and !none.cursor_keys);
2574 }
2575
2556 test "term_modes: reserved bits go out zero and come back ignored" { 2576 test "term_modes: reserved bits go out zero and come back ignored" {
2557 // Reserved bits are what let focus reporting and cursor shape land 2577 // Reserved bits are what let focus reporting and cursor shape land
2558 // later without a new frame type or a version check, so both halves 2578 // later without a new frame type or a version check, so both halves
src/server/server.zig
Old New
@@ -2587,6 +2587,8 @@ pub const Server = struct {
2587 .mouse_sgr = m.sgr, 2587 .mouse_sgr = m.sgr,
2588 .mouse_urxvt = m.urxvt, 2588 .mouse_urxvt = m.urxvt,
2589 .mouse_sgr_pixels = m.sgr_pixels, 2589 .mouse_sgr_pixels = m.sgr_pixels,
2590 .alt_screen = eng.onAltScreen(),
2591 .cursor_keys = eng.cursorKeys(),
2590 }; 2592 };
2591 } 2593 }
2592 2594
src/server/server_test_modes.zig
Old New
@@ -409,6 +409,55 @@ test "Server: a session enabling bracketed paste tells its clients, and not agai
409 try std.testing.expectEqual(@as(usize, 0), resent); 409 try std.testing.expectEqual(@as(usize, 0), resent);
410 } 410 }
411 411
412 test "Server: a session entering and leaving the alternate screen tells its clients both times" {
413 const alloc = std.testing.allocator;
414
415 var td = try h.TestDaemon.open(alloc, "altmodes");
416 defer td.deinit();
417
418 // The pair a pager sets and clears together: the alternate screen and
419 // DECCKM. They are asserted as a pair because a client that read one
420 // and guessed the other would send `less` arrows it ignores.
421 try td.tmp.dir.writeFile(.{
422 .sub_path = "altmodes.sh",
423 .data =
424 \\#!/bin/sh
425 \\printf '\033[?1049h\033[?1h'
426 \\read -r go
427 \\printf '\033[?1l\033[?1049l'
428 \\exec sleep 30
429 \\
430 ,
431 .flags = .{ .mode = 0o755 },
432 });
433 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/altmodes.sh", .{td.tmp.path()}, 0);
434 defer alloc.free(script);
435
436 try td.start(.{ .shell = script });
437
438 const c = try dial.dialAttach(td.sock_path, 80, 24);
439 defer c.close();
440
441 // The attach-time frame reports the modes as they stood before the
442 // shell ran, so the loop pumps until one carries the pager's pair.
443 const entered = while (try awaitFrame(alloc, &td.srv, c.handle, .term_modes, 400)) |f| {
444 defer f.deinit(alloc);
445 const m = try proto.decodeTermModes(f.payload);
446 if (m.alt_screen and m.cursor_keys) break true;
447 } else false;
448 try std.testing.expect(entered);
449
450 // Leaving must be reported too. A client told only about entering
451 // would keep turning the wheel into arrows after the pager quit.
452 try proto.writeFrame(c.handle, .input, "go\n");
453 const left = while (try awaitFrame(alloc, &td.srv, c.handle, .term_modes, 400)) |f| {
454 defer f.deinit(alloc);
455 const m = try proto.decodeTermModes(f.payload);
456 if (!m.alt_screen and !m.cursor_keys) break true;
457 } else false;
458 try std.testing.expect(left);
459 }
460
412 test "Server: a client attaching to a session already in bracketed paste is told, on either resync branch" { 461 test "Server: a client attaching to a session already in bracketed paste is told, on either resync branch" {
413 const alloc = std.testing.allocator; 462 const alloc = std.testing.allocator;
414 463
src/tui/interact.zig
Old New
@@ -1750,8 +1750,11 @@ pub const Core = struct {
1750 // arrow keys it understands. Without this the notch is consumed and 1750 // arrow keys it understands. Without this the notch is consumed and
1751 // dropped. Only at the live view: a client already scrolled into 1751 // dropped. Only at the live view: a client already scrolled into
1752 // history owns its wheel. 1752 // history owns its wheel.
1753 if (wheel != 0 and self.scroll_rows == 0 and self.rep.eng.onAltScreen()) { 1753 // Both modes come off the daemon's sample rather than off this
1754 sendAltScroll(transport, wheel, self.rep.eng.cursorKeys()) catch return .lost; 1754 // client's replica engine, so the rule holds for a client that
1755 // parses no VT of its own.
1756 if (wheel != 0 and self.scroll_rows == 0 and self.semantic.terminal_modes.alt_screen) {
1757 sendAltScroll(transport, wheel, self.semantic.terminal_modes.cursor_keys) catch return .lost;
1755 wheel = 0; // spent on the session, not on our view 1758 wheel = 0; // spent on the session, not on our view
1756 } 1759 }
1757 1760
@@ -2594,6 +2597,40 @@ test "interact: alternate scroll spells its arrows the way the session reads the
2594 try std.testing.expectEqualStrings("\x1bOB", altScrollSeq(-1, true)); 2597 try std.testing.expectEqualStrings("\x1bOB", altScrollSeq(-1, true));
2595 } 2598 }
2596 2599
2600 test "interact: the wheel becomes arrows from the sampled modes, not from an engine" {
2601 const alloc = std.testing.allocator;
2602 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
2603 defer std.posix.close(p[0]);
2604 defer std.posix.close(p[1]);
2605 var core = try dragFixture(alloc, p[1]);
2606 defer core.deinit();
2607 var tr: InputTransport = .{};
2608
2609 // The daemon says the alternate screen is up and DECCKM is set. No
2610 // escape is fed to the replica here on purpose: the rule must hold for
2611 // a client that parses no VT at all.
2612 core.semantic.terminal_modes = .{
2613 .bracketed_paste = false,
2614 .alt_screen = true,
2615 .cursor_keys = true,
2616 };
2617 try mouse(&core, &tr, 64, 3, 2, 'M');
2618 try std.testing.expectEqualStrings("\x1bOA" ** wheel_rows, tr.take());
2619 try mouse(&core, &tr, 65, 3, 2, 'M');
2620 try std.testing.expectEqualStrings("\x1bOB" ** wheel_rows, tr.take());
2621
2622 // DECCKM off with the alternate screen still up is the CSI spelling.
2623 core.semantic.terminal_modes.cursor_keys = false;
2624 try mouse(&core, &tr, 64, 3, 2, 'M');
2625 try std.testing.expectEqualStrings("\x1b[A" ** wheel_rows, tr.take());
2626
2627 // The inverse: the modes say the primary screen, so the notch is this
2628 // client's own view to move and the session reads nothing at all.
2629 core.semantic.terminal_modes.alt_screen = false;
2630 try mouse(&core, &tr, 64, 3, 2, 'M');
2631 try std.testing.expectEqualStrings("", tr.take());
2632 }
2633
2597 test "interact: clicks, drags and releases are discarded rather than typed at the shell" { 2634 test "interact: clicks, drags and releases are discarded rather than typed at the shell" {
2598 var f: MouseFilter = .{}; 2635 var f: MouseFilter = .{};
2599 var out: [64]u8 = undefined; 2636 var out: [64]u8 = undefined;