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
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; |