a73x

6c8c34de

feat: the prefix table learns splits, hjkl, and a resize mode

a73x   2026-08-24 18:06

Commit message
feat: the prefix table learns splits, hjkl, and a resize mode

`l` was last-session; `n`/`p` cover cycling, so `l` becomes focus-right
and `h`/`j`/`k` join it as directional focus. `|` and `\` split right,
`-` splits below, `f` fullscreen, `r` resize mode — the filter's first
sticky state. Esc leaves resize silently; any other byte leaves and is
processed normally so typing your way out never eats prose.

The wall's action switch gains no-op arms for the new variants; Tasks
6-8 wire them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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) {