6c8c34de
feat: the prefix table learns splits, hjkl, and a resize mode
a73x 2026-08-24 18:06
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -59,7 +59,11 @@ The focused tile has the terminal, and only the prefix is held back: | |||
| 59 | | `Ctrl-\` `d` (or `Ctrl-\` `Ctrl-\`) | detach and leave mux (session keeps running) | | 59 | | `Ctrl-\` `d` (or `Ctrl-\` `Ctrl-\`) | detach and leave mux (session keeps running) | |
| 60 | | `Ctrl-\` `c` | create a session, add a tile, focus it | | 60 | | `Ctrl-\` `c` | create a session, add a tile, focus it | |
| 61 | | `Ctrl-\` `n` / `Ctrl-\` `p` | focus the next / previous session of this daemon, wrapping | | 61 | | `Ctrl-\` `n` / `Ctrl-\` `p` | focus the next / previous session of this daemon, wrapping | |
| 62 | | `Ctrl-\` `l` | skip back to the last tile you focused | | 62 | | `Ctrl-\` `h` `j` `k` `l` | focus the pane left / down / up / right | |
| 63 | | `Ctrl-\` `\|` (or `\`) | split right: new session in a pane to the right | | ||
| 64 | | `Ctrl-\` `-` | split below: new session in a pane below | | ||
| 65 | | `Ctrl-\` `f` | fullscreen the focused pane (toggle) | | ||
| 66 | | `Ctrl-\` `r` | resize mode: `h`/`l` shrink/grow width, `k`/`j` shrink/grow height; Esc or any other key leaves | | ||
| 63 | | `Ctrl-\` `1`-`9` | focus tile N | | 67 | | `Ctrl-\` `1`-`9` | focus tile N | |
| 64 | | `Ctrl-\` `x` | forget the focused tile (off the wall file; the session keeps running) | | 68 | | `Ctrl-\` `x` | forget the focused tile (off the wall file; the session keeps running) | |
| 65 | | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | | 69 | | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | |
| @@ -317,13 +321,14 @@ The keys are the ones listed under "Quick start, local" — `mux wall` and | |||
| 317 | `mux` are the same program, entered through different doors, so there is | 321 | `mux` are the same program, entered through different doors, so there is |
| 318 | one key table and not two. Every typed key goes to the focused tile; only | 322 | one key table and not two. Every typed key goes to the focused tile; only |
| 319 | the `Ctrl-\` prefix is held back. `Ctrl-\` `1`-`9` focuses a tile (as does | 323 | the `Ctrl-\` prefix is held back. `Ctrl-\` `1`-`9` focuses a tile (as does |
| 320 | clicking one), `Ctrl-\` `n`/`p`/`c` walk the daemon's session ring, `Ctrl-\` `l` | 324 | clicking one), `Ctrl-\` `n`/`p`/`c` walk the daemon's session ring, `Ctrl-\` |
| 321 | skips back to the last tile you focused, `Ctrl-\` `x` forgets the focused tile, | 325 | `h`/`j`/`k`/`l` move focus between panes, `Ctrl-\` `|`/`-` split right or |
| 322 | `Ctrl-\` `d` leaves. Any other command key after the prefix is swallowed along with it, | 326 | below, `Ctrl-\` `f` fullscreen, `Ctrl-\` `r` resize mode, `Ctrl-\` `x` |
| 323 | as in a plain client. | 327 | forgets the focused tile, `Ctrl-\` `d` leaves. Any other command key after |
| 328 | the prefix is swallowed along with it, as in a plain client. | ||
| 324 | 329 | ||
| 325 | The focused tile's label bar carries a `>` marker. Focus is client-local: | 330 | The focused tile's label bar carries a `>` marker. Focus is client-local: |
| 326 | moving it sends nothing on the wire, so `Ctrl-\` `n` and `Ctrl-\` `l` move | 331 | moving it sends nothing on the wire, so `Ctrl-\` `n` and `Ctrl-\` `h` move |
| 327 | between sessions instantly — no reconnect, no snapshot, no flash. Every | 332 | between sessions instantly — no reconnect, no snapshot, no flash. Every |
| 328 | tile keeps replicating at its own rect whether you are looking at it or | 333 | tile keeps replicating at its own rect whether you are looking at it or |
| 329 | not, so nothing reflows when focus moves away. | 334 | not, so nothing reflows when focus moves away. |
src/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -88,6 +88,8 @@ pub const detach_key: u8 = 0x1c; | |||
| 88 | /// the run while `.next_session` moves the focus to the next session | 88 | /// the run while `.next_session` moves the focus to the next session |
| 89 | /// (`wallview`'s run loop) — but which byte spells it is one table. | 89 | /// (`wallview`'s run loop) — but which byte spells it is one table. |
| 90 | pub const PrefixFilter = struct { | 90 | pub const PrefixFilter = struct { |
| 91 | pub const Dir = enum { left, down, up, right }; | ||
| 92 | |||
| 91 | /// Callers switch on it, so a new variant is additive. | 93 | /// Callers switch on it, so a new variant is additive. |
| 92 | pub const Action = union(enum) { | 94 | pub const Action = union(enum) { |
| 93 | none, | 95 | none, |
| @@ -95,12 +97,16 @@ pub const PrefixFilter = struct { | |||
| 95 | new_session, | 97 | new_session, |
| 96 | next_session, | 98 | next_session, |
| 97 | prev_session, | 99 | prev_session, |
| 98 | last_session, | ||
| 99 | wall, | 100 | wall, |
| 100 | // The digit pressed (1-9): `Ctrl-\ 3` focuses tile 3. Four bits, | 101 | // The digit pressed (1-9): `Ctrl-\ 3` focuses tile 3. Four bits, |
| 101 | // because nine values do not fit in three. | 102 | // because nine values do not fit in three. |
| 102 | focus: u4, | 103 | focus: u4, |
| 103 | forget, | 104 | forget, |
| 105 | focus_dir: Dir, | ||
| 106 | split_right, | ||
| 107 | split_below, | ||
| 108 | fullscreen, | ||
| 109 | resize: Dir, | ||
| 104 | }; | 110 | }; |
| 105 | 111 | ||
| 106 | pub const Out = struct { forward: []const u8, action: Action }; | 112 | pub const Out = struct { forward: []const u8, action: Action }; |
| @@ -110,6 +116,11 @@ pub const PrefixFilter = struct { | |||
| 110 | /// boundary is still one chord. | 116 | /// boundary is still one chord. |
| 111 | pending: bool = false, | 117 | pending: bool = false, |
| 112 | 118 | ||
| 119 | /// `Ctrl-\ r` enters resize mode: bare hjkl trade cells without a | ||
| 120 | /// fresh prefix. Esc leaves silently; any other byte leaves and is | ||
| 121 | /// processed normally — the mode never eats prose. | ||
| 122 | resizing: bool = false, | ||
| 123 | |||
| 113 | /// Filters one raw stdin chunk in place — the layer only ever removes | 124 | /// Filters one raw stdin chunk in place — the layer only ever removes |
| 114 | /// bytes, so the survivors compact leftwards over the same buffer. | 125 | /// bytes, so the survivors compact leftwards over the same buffer. |
| 115 | /// An action ends the chunk: whatever was typed behind it is dropped. | 126 | /// An action ends the chunk: whatever was typed behind it is dropped. |
| @@ -125,6 +136,22 @@ pub const PrefixFilter = struct { | |||
| 125 | pub fn feed(self: *PrefixFilter, buf: []u8) Out { | 136 | pub fn feed(self: *PrefixFilter, buf: []u8) Out { |
| 126 | var kept: usize = 0; | 137 | var kept: usize = 0; |
| 127 | for (buf) |b| { | 138 | for (buf) |b| { |
| 139 | if (self.resizing) { | ||
| 140 | switch (b) { | ||
| 141 | 'h' => return .{ .forward = buf[0..kept], .action = .{ .resize = .left } }, | ||
| 142 | 'j' => return .{ .forward = buf[0..kept], .action = .{ .resize = .down } }, | ||
| 143 | 'k' => return .{ .forward = buf[0..kept], .action = .{ .resize = .up } }, | ||
| 144 | 'l' => return .{ .forward = buf[0..kept], .action = .{ .resize = .right } }, | ||
| 145 | 0x1b => { | ||
| 146 | self.resizing = false; | ||
| 147 | continue; | ||
| 148 | }, | ||
| 149 | else => { | ||
| 150 | self.resizing = false; | ||
| 151 | // Fall through to normal processing of this byte. | ||
| 152 | }, | ||
| 153 | } | ||
| 154 | } | ||
| 128 | if (self.pending) { | 155 | if (self.pending) { |
| 129 | self.pending = false; | 156 | self.pending = false; |
| 130 | switch (b) { | 157 | switch (b) { |
| @@ -132,19 +159,18 @@ pub const PrefixFilter = struct { | |||
| 132 | 'c' => return .{ .forward = buf[0..kept], .action = .new_session }, | 159 | 'c' => return .{ .forward = buf[0..kept], .action = .new_session }, |
| 133 | 'n' => return .{ .forward = buf[0..kept], .action = .next_session }, | 160 | 'n' => return .{ .forward = buf[0..kept], .action = .next_session }, |
| 134 | 'p' => return .{ .forward = buf[0..kept], .action = .prev_session }, | 161 | 'p' => return .{ .forward = buf[0..kept], .action = .prev_session }, |
| 135 | // Not the same as the `else` arm `l` used to fall | 162 | 'h' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .left } }, |
| 136 | // through to, and the difference is real: an unknown | 163 | 'j' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .down } }, |
| 137 | // command key drops itself and lets the REST of the | 164 | 'k' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .up } }, |
| 138 | // read through, while a chord ends the chunk and drops | 165 | 'l' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .right } }, |
| 139 | // whatever was typed behind it. `l` is a chord now, so | 166 | '|', '\\' => return .{ .forward = buf[0..kept], .action = .split_right }, |
| 140 | // it behaves like `n` and `p` and not like `z`, and the | 167 | '-' => return .{ .forward = buf[0..kept], .action = .split_below }, |
| 141 | // bytes behind it are gone either way — which is the | 168 | 'f' => return .{ .forward = buf[0..kept], .action = .fullscreen }, |
| 142 | // rule every chord already keeps, for the reason argued | 169 | 'r' => { |
| 143 | // above. | 170 | self.resizing = true; |
| 144 | 'l' => return .{ .forward = buf[0..kept], .action = .last_session }, | 171 | continue; |
| 172 | }, | ||
| 145 | 'w' => return .{ .forward = buf[0..kept], .action = .wall }, | 173 | 'w' => return .{ .forward = buf[0..kept], .action = .wall }, |
| 146 | // The wall does not act on these yet; the chord ends the | ||
| 147 | // chunk like every arm above. | ||
| 148 | '1'...'9' => return .{ .forward = buf[0..kept], .action = .{ .focus = @intCast(b - '0') } }, | 174 | '1'...'9' => return .{ .forward = buf[0..kept], .action = .{ .focus = @intCast(b - '0') } }, |
| 149 | 'x' => return .{ .forward = buf[0..kept], .action = .forget }, | 175 | 'x' => return .{ .forward = buf[0..kept], .action = .forget }, |
| 150 | else => {}, | 176 | else => {}, |
| @@ -2031,7 +2057,7 @@ test "interact: a chord split across two reads is still one chord" { | |||
| 2031 | 2057 | ||
| 2032 | test "interact: an unknown command key is swallowed with its prefix" { | 2058 | test "interact: an unknown command key is swallowed with its prefix" { |
| 2033 | var f: PrefixFilter = .{}; | 2059 | var f: PrefixFilter = .{}; |
| 2034 | var chunk = "a\x1crb".*; | 2060 | var chunk = "a\x1czb".*; |
| 2035 | const out = f.feed(&chunk); | 2061 | const out = f.feed(&chunk); |
| 2036 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | 2062 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); |
| 2037 | try std.testing.expectEqualStrings("ab", out.forward); | 2063 | try std.testing.expectEqualStrings("ab", out.forward); |
| @@ -2042,24 +2068,6 @@ test "interact: an unknown command key is swallowed with its prefix" { | |||
| 2042 | try std.testing.expectEqualStrings("d", next.forward); | 2068 | try std.testing.expectEqualStrings("d", next.forward); |
| 2043 | } | 2069 | } |
| 2044 | 2070 | ||
| 2045 | // The TABLE names `l` and the wall's run loop gives it meaning; what this | ||
| 2046 | // pins is the chord layer, not the focus move. | ||
| 2047 | // Split across reads for the same reason every other chord is: | ||
| 2048 | // a read boundary is not a chord boundary. | ||
| 2049 | test "interact: Ctrl-\\ l is a chord in the table, whoever acts on it" { | ||
| 2050 | var f: PrefixFilter = .{}; | ||
| 2051 | var chunk = "ab\x1clcd".*; | ||
| 2052 | const out = f.feed(&chunk); | ||
| 2053 | try std.testing.expectEqual(PrefixFilter.Action.last_session, out.action); | ||
| 2054 | try std.testing.expectEqualStrings("ab", out.forward); | ||
| 2055 | |||
| 2056 | var g: PrefixFilter = .{}; | ||
| 2057 | var first = "x\x1c".*; | ||
| 2058 | try std.testing.expectEqual(PrefixFilter.Action.none, g.feed(&first).action); | ||
| 2059 | var second = "l".*; | ||
| 2060 | try std.testing.expectEqual(PrefixFilter.Action.last_session, g.feed(&second).action); | ||
| 2061 | } | ||
| 2062 | |||
| 2063 | test "interact: bytes with no prefix pass through untouched" { | 2071 | test "interact: bytes with no prefix pass through untouched" { |
| 2064 | var f: PrefixFilter = .{}; | 2072 | var f: PrefixFilter = .{}; |
| 2065 | var chunk = "hello\x1b[A".*; | 2073 | var chunk = "hello\x1b[A".*; |
| @@ -2070,16 +2078,69 @@ test "interact: bytes with no prefix pass through untouched" { | |||
| 2070 | 2078 | ||
| 2071 | test "interact: two chords in one buffer are consumed independently" { | 2079 | test "interact: two chords in one buffer are consumed independently" { |
| 2072 | var f: PrefixFilter = .{}; | 2080 | var f: PrefixFilter = .{}; |
| 2073 | var chunk = "\x1crz\x1cq".*; | 2081 | var chunk = "\x1czq".*; |
| 2074 | const out = f.feed(&chunk); | 2082 | const out = f.feed(&chunk); |
| 2075 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | 2083 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); |
| 2076 | try std.testing.expectEqualStrings("z", out.forward); | 2084 | try std.testing.expectEqualStrings("q", out.forward); |
| 2077 | var chunk2 = "\x1cr\x1cd".*; | 2085 | var chunk2 = "\x1cz\x1cd".*; |
| 2078 | const out2 = f.feed(&chunk2); | 2086 | const out2 = f.feed(&chunk2); |
| 2079 | try std.testing.expectEqual(PrefixFilter.Action.detach, out2.action); | 2087 | try std.testing.expectEqual(PrefixFilter.Action.detach, out2.action); |
| 2080 | try std.testing.expectEqualStrings("", out2.forward); | 2088 | try std.testing.expectEqualStrings("", out2.forward); |
| 2081 | } | 2089 | } |
| 2082 | 2090 | ||
| 2091 | test "interact: hjkl under the prefix answer directional focus" { | ||
| 2092 | var f: PrefixFilter = .{}; | ||
| 2093 | var buf = "\x1chZZ".*; | ||
| 2094 | const out = f.feed(&buf); | ||
| 2095 | try std.testing.expectEqual(PrefixFilter.Action{ .focus_dir = .left }, out.action); | ||
| 2096 | try std.testing.expectEqual(@as(usize, 0), out.forward.len); | ||
| 2097 | } | ||
| 2098 | |||
| 2099 | test "interact: the split chords and fullscreen answer, backslash aliases the pipe" { | ||
| 2100 | inline for (.{ | ||
| 2101 | .{ "\x1c|", PrefixFilter.Action.split_right }, | ||
| 2102 | .{ "\x1c\\", PrefixFilter.Action.split_right }, | ||
| 2103 | .{ "\x1c-", PrefixFilter.Action.split_below }, | ||
| 2104 | .{ "\x1cf", PrefixFilter.Action.fullscreen }, | ||
| 2105 | }) |case| { | ||
| 2106 | var f: PrefixFilter = .{}; | ||
| 2107 | var buf = case[0].*; | ||
| 2108 | try std.testing.expectEqual(case[1], f.feed(&buf).action); | ||
| 2109 | } | ||
| 2110 | } | ||
| 2111 | |||
| 2112 | test "interact: resize mode is sticky across reads and Esc leaves it silently" { | ||
| 2113 | var f: PrefixFilter = .{}; | ||
| 2114 | var enter = "\x1cr".*; | ||
| 2115 | try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&enter).action); | ||
| 2116 | var grow = "l".*; | ||
| 2117 | try std.testing.expectEqual(PrefixFilter.Action{ .resize = .right }, f.feed(&grow).action); | ||
| 2118 | var again = "j".*; | ||
| 2119 | try std.testing.expectEqual(PrefixFilter.Action{ .resize = .down }, f.feed(&again).action); | ||
| 2120 | var esc = "\x1b".*; | ||
| 2121 | const out = f.feed(&esc); | ||
| 2122 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | ||
| 2123 | try std.testing.expectEqual(@as(usize, 0), out.forward.len); | ||
| 2124 | var plain = "l".*; | ||
| 2125 | try std.testing.expectEqual(@as(usize, 1), f.feed(&plain).forward.len); | ||
| 2126 | } | ||
| 2127 | |||
| 2128 | test "interact: prose ends resize mode and goes to the session" { | ||
| 2129 | var f: PrefixFilter = .{}; | ||
| 2130 | var enter = "\x1cr".*; | ||
| 2131 | _ = f.feed(&enter); | ||
| 2132 | var typed = "vim".*; | ||
| 2133 | const out = f.feed(&typed); | ||
| 2134 | try std.testing.expectEqual(PrefixFilter.Action.none, out.action); | ||
| 2135 | try std.testing.expectEqualStrings("vim", out.forward); | ||
| 2136 | } | ||
| 2137 | |||
| 2138 | test "interact: l is not last-session any more" { | ||
| 2139 | var f: PrefixFilter = .{}; | ||
| 2140 | var buf = "\x1cl".*; | ||
| 2141 | try std.testing.expectEqual(PrefixFilter.Action{ .focus_dir = .right }, f.feed(&buf).action); | ||
| 2142 | } | ||
| 2143 | |||
| 2083 | test "interact: a wheel report becomes a scroll and never reaches the pty" { | 2144 | test "interact: a wheel report becomes a scroll and never reaches the pty" { |
| 2084 | var f: MouseFilter = .{}; | 2145 | var f: MouseFilter = .{}; |
| 2085 | var out: [64]u8 = undefined; | 2146 | var out: [64]u8 = undefined; |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -38,8 +38,10 @@ const usage = | |||
| 38 | \\ --version prints the version | 38 | \\ --version prints the version |
| 39 | \\ | 39 | \\ |
| 40 | \\ mux wall [SPELLING...] shows several sessions at once, one stripe | 40 | \\ mux wall [SPELLING...] shows several sessions at once, one stripe |
| 41 | \\ each; `Ctrl-\ 1-9` focuses a tile and types into it, `Ctrl-\ d` | 41 | \\ each; `Ctrl-\ 1-9` focuses a tile and types into it, `Ctrl-\ h/j/k/l` |
| 42 | \\ leaves. SPELLING is the wall grammar | 42 | \\ moves between panes, `Ctrl-\ |/-` split right/below, `Ctrl-\ f` |
| 43 | \\ fullscreen, `Ctrl-\ r` resize mode, `Ctrl-\ d` leaves. SPELLING is | ||
| 44 | \\ the wall grammar | ||
| 43 | \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION], | 45 | \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION], |
| 44 | \\ one argument per tile, but `--sock PATH` may also be two arguments | 46 | \\ one argument per tile, but `--sock PATH` may also be two arguments |
| 45 | \\ as in muxweb); with none, the saved wall is shown. | 47 | \\ as in muxweb); with none, the saved wall is shown. |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -2479,16 +2479,12 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) ! | |||
| 2479 | tiles[z].ask.store(@intFromEnum(client.SwitchIntent.prev), .release); | 2479 | tiles[z].ask.store(@intFromEnum(client.SwitchIntent.prev), .release); |
| 2480 | ring(&tiles[z]); | 2480 | ring(&tiles[z]); |
| 2481 | }, | 2481 | }, |
| 2482 | .last_session => { | 2482 | .focus_dir, |
| 2483 | // Back to where the focus was before the last move. | 2483 | .split_right, |
| 2484 | if (last_focus) |back| { | 2484 | .split_below, |
| 2485 | if (back < live and present[back] and back != z) { | 2485 | .fullscreen, |
| 2486 | const prev = z; | 2486 | .resize, |
| 2487 | last_focus = prev; | 2487 | => {}, |
| 2488 | setFocus(tiles[0..live], &shared, back); | ||
| 2489 | } | ||
| 2490 | } | ||
| 2491 | }, | ||
| 2492 | .focus => |idx| { | 2488 | .focus => |idx| { |
| 2493 | // `Ctrl-\ 1-9` focuses tile N (one-based). | 2489 | // `Ctrl-\ 1-9` focuses tile N (one-based). |
| 2494 | if (idx > 0 and idx <= live and present[idx - 1] and idx - 1 != z) { | 2490 | if (idx > 0 and idx <= live and present[idx - 1] and idx - 1 != z) { |