a73x

a5fc3918

feat: align native split bindings with sway

a73x   2026-09-07 12:32

Commit message
feat: align native split bindings with sway

README.md
Old New
@@ -148,7 +148,7 @@ Open the saved workspace, or an explicit target in a temporary workspace:
148 ./zig-out/bin/muxg alpha --session work 148 ./zig-out/bin/muxg alpha --session work
149 ``` 149 ```
150 150
151 Press `Ctrl+\`, then **v** for side by side or **b** for above/below. 151 Press `Ctrl+\`, then **v** for a pane below or **b** for a pane beside it.
152 This shows the intended split. Press the prefix again, then **Enter**, to open 152 This shows the intended split. Press the prefix again, then **Enter**, to open
153 the host picker. Arrows or **j/k** select a row; Enter chooses it. Select a host, 153 the host picker. Arrows or **j/k** select a row; Enter chooses it. Select a host,
154 then an existing session or **New session...**, which asks for a name. **Add host...** 154 then an existing session or **New session...**, which asks for a name. **Add host...**
docs/superpowers/specs/2026-09-05-native-tiling-design.md
Old New
@@ -73,8 +73,8 @@ they are not prerequisites for the two-pane deliverable.
73 73
74 The defaults below are proposals to trial, rather than additional requirements. 74 The defaults below are proposals to trial, rather than additional requirements.
75 75
76 Use side-by-side and above/below in the interface: `v` names the vertical 76 Use side-by-side and above/below in the interface: `v` puts the new pane below,
77 divider, with the new pane to the right; `b` puts the new pane below. Show the 77 matching Sway's vertical split, while `b` puts the new pane beside the focus. Show the
78 pending direction at its originating pane so the orientation is unambiguous. 78 pending direction at its originating pane so the orientation is unambiguous.
79 79
80 Direction selection arms the focused pane for one insertion, immediately opens 80 Direction selection arms the focused pane for one insertion, immediately opens
@@ -125,8 +125,8 @@ The prefix opens a small command hint strip; its next key is consumed by the GUI
125 125
126 | After prefix | Action | 126 | After prefix | Action |
127 | --- | --- | 127 | --- | --- |
128 | `v` | Open host picker for side-by-side insertion (vertical divider) | 128 | `v` | Open host picker for above/below insertion (new pane below) |
129 | `b` | Open host picker for above/below insertion (new pane below) | 129 | `b` | Open host picker for side-by-side insertion (new pane beside) |
130 | Enter | Open host picker for insertion | 130 | Enter | Open host picker for insertion |
131 | `h` / `j` / `k` / `l`, or arrows | Focus left / down / up / right | 131 | `h` / `j` / `k` / `l`, or arrows | Focus left / down / up / right |
132 | `r` | Enter resize mode; `h/j/k/l` or arrows resize, Esc/Enter exits | 132 | `r` | Enter resize mode; `h/j/k/l` or arrows resize, Esc/Enter exits |
@@ -135,8 +135,9 @@ The prefix opens a small command hint strip; its next key is consumed by the GUI
135 | `p` | Open Pane actions (Retry, Choose session, Detach) for the focused pane | 135 | `p` | Open Pane actions (Retry, Choose session, Detach) for the focused pane |
136 | Esc | Cancel the command and pending split | 136 | Esc | Cancel the command and pending split |
137 137
138 The split and movement bindings above were agreed on 2026-09-05: reserve 138 The split and movement bindings above reserve `h/j/k/l` for directional
139 `h/j/k/l` for directional movement, with `v` and `b` selecting split direction. 139 movement, with `v` and `b` selecting split direction. A 2026-09-07 hands-on
140 trial aligned their meanings with Sway: `v` below and `b` beside.
140 The subsequent hands-on ergonomic trial makes `v`/`b` open host selection 141 The subsequent hands-on ergonomic trial makes `v`/`b` open host selection
141 immediately; a second prefix + Enter is unnecessary. Cancelling the host picker 142 immediately; a second prefix + Enter is unnecessary. Cancelling the host picker
142 retains the armed direction, and Esc outside the picker cancels it. 143 retains the armed direction, and Esc outside the picker cancels it.
src/gui/frame.zig
Old New
@@ -1023,7 +1023,7 @@ const Header = struct {
1023 const focused = events.ui.rt.workspace.tab().focus == p.id; 1023 const focused = events.ui.rt.workspace.tab().focus == p.id;
1024 const pending = events.ui.rt.workspace.tab().pending; 1024 const pending = events.ui.rt.workspace.tab().pending;
1025 const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false; 1025 const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false;
1026 const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: v/b split, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (events.ui.picker != null and events.ui.picker.?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else events.ui.notice; 1026 const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: v below, b beside, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (events.ui.picker != null and events.ui.picker.?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else events.ui.notice;
1027 const label = events.ui.rt.workspace.pane(p.id).?.identity.label; 1027 const label = events.ui.rt.workspace.pane(p.id).?.identity.label;
1028 var status_buf: [48]u8 = undefined; 1028 var status_buf: [48]u8 = undefined;
1029 const status: []const u8 = switch (live.status.phase) { 1029 const status: []const u8 = switch (live.status.phase) {
src/gui/interaction.zig
Old New
@@ -207,7 +207,7 @@ pub const Controller = struct {
207 self.intent_dirty = self.intent_dirty or before != ws.tab().focus; 207 self.intent_dirty = self.intent_dirty or before != ws.tab().focus;
208 } else switch (key) { 208 } else switch (key) {
209 .v, .b, .enter, .keypad_enter => { 209 .v, .b, .enter, .keypad_enter => {
210 if (key == .v or key == .b) ws.arm(if (key == .v) .beside else .stacked); 210 if (key == .v or key == .b) ws.arm(if (key == .v) .stacked else .beside);
211 self.openPicker(.insert) catch |err| { 211 self.openPicker(.insert) catch |err| {
212 self.notice = @errorName(err); 212 self.notice = @errorName(err);
213 self.dirty = true; 213 self.dirty = true;
@@ -222,7 +222,7 @@ pub const Controller = struct {
222 self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText()); 222 self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText());
223 }, 223 },
224 .escape => ws.cancel(), 224 .escape => ws.cancel(),
225 else => self.notice = "v/b split, hjkl focus, r resize, d detach, x end, p actions, Enter add", 225 else => self.notice = "v below, b beside, hjkl focus, r resize, d detach, x end, p actions, Enter add",
226 } 226 }
227 self.dirty = true; 227 self.dirty = true;
228 } 228 }
@@ -833,14 +833,14 @@ test "controller modal input takes precedence over the prefix" {
833 try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text); 833 try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text);
834 } 834 }
835 835
836 test "v/b opens insertion picker and Esc preserves then cancels pending direction" { 836 test "v opens below and b opens beside while Esc preserves then cancels direction" {
837 var rt = runtime.Runtime.init(std.testing.allocator, .{}); 837 var rt = runtime.Runtime.init(std.testing.allocator, .{});
838 defer rt.deinit(); 838 defer rt.deinit();
839 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 }; 839 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
840 defer ui.deinit(); 840 defer ui.deinit();
841 841
842 _ = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics); 842 _ = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics);
843 try ui.command(.b); 843 try ui.command(.v);
844 try std.testing.expect(ui.picker != null); 844 try std.testing.expect(ui.picker != null);
845 try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction); 845 try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction);
846 try ui.keyDown(.{ .code = 27, .kind = .escape }); 846 try ui.keyDown(.{ .code = 27, .kind = .escape });
@@ -852,7 +852,7 @@ test "v/b opens insertion picker and Esc preserves then cancels pending directio
852 ui.keyUp(27); 852 ui.keyUp(27);
853 try std.testing.expect(rt.workspace.tab().pending == null); 853 try std.testing.expect(rt.workspace.tab().pending == null);
854 854
855 try ui.command(.v); 855 try ui.command(.b);
856 try std.testing.expectEqual(model.Direction.beside, rt.workspace.tab().pending.?.direction); 856 try std.testing.expectEqual(model.Direction.beside, rt.workspace.tab().pending.?.direction);
857 try ui.keyDown(.{ .code = 27, .kind = .escape }); 857 try ui.keyDown(.{ .code = 27, .kind = .escape });
858 ui.keyUp(27); 858 ui.keyUp(27);
test/native_journey.py
Old New
@@ -57,9 +57,9 @@ def basic_workspace(rig):
57 'v/b did not produce one below and one beside split') 57 'v/b did not produce one below and one beside split')
58 left_rect, right_rect, lower_rect = [by_id(state)[pane]['outer'] for pane in panes] 58 left_rect, right_rect, lower_rect = [by_id(state)[pane]['outer'] for pane in panes]
59 require(right_rect['x'] >= left_rect['x'] + left_rect['w'], 59 require(right_rect['x'] >= left_rect['x'] + left_rect['w'],
60 'v pane is not beside the original pane') 60 'b pane is not beside the original pane')
61 require(lower_rect['y'] >= right_rect['y'] + right_rect['h'], 61 require(lower_rect['y'] >= right_rect['y'] + right_rect['h'],
62 'b pane is not below the focused pane') 62 'v pane is not below the focused pane')
63 pty_sizes(rig, refs, [(p['id'], p['generation']) for p in state['panes']]) 63 pty_sizes(rig, refs, [(p['id'], p['generation']) for p in state['panes']])
64 64
65 rig.focus(right) 65 rig.focus(right)
test/native_lifecycle.py
Old New
@@ -182,10 +182,10 @@ def start_workspace(rig, *, quic=False, picker_checks=False):
182 rig.new_session('left') 182 rig.new_session('left')
183 rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached') 183 rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached')
184 if picker_checks: 184 if picker_checks:
185 rig.chord('v') 185 rig.chord('b')
186 armed = rig.picker('hosts') 186 armed = rig.picker('hosts')
187 require(armed['pending']['direction'] == 'beside', 187 require(armed['pending']['direction'] == 'beside',
188 'v did not arm a split beside the focused pane') 188 'b did not arm a split beside the focused pane')
189 stats = rig.command('d', 'stats', '--sock', b).stdout 189 stats = rig.command('d', 'stats', '--sock', b).stdout
190 require('attaches=0' in stats, 'picker attached its target before insertion: ' + stats) 190 require('attaches=0' in stats, 'picker attached its target before insertion: ' + stats)
191 require(rig.command('a', 'status', '--sock', b, '--session', 'right', 191 require(rig.command('a', 'status', '--sock', b, '--session', 'right',
@@ -201,10 +201,10 @@ def start_workspace(rig, *, quic=False, picker_checks=False):
201 rig.new_session('right') 201 rig.new_session('right')
202 rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) 202 rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes']))
203 if picker_checks: 203 if picker_checks:
204 rig.chord('b') 204 rig.chord('v')
205 armed = rig.picker('hosts') 205 armed = rig.picker('hosts')
206 require(armed['pending']['direction'] == 'stacked', 206 require(armed['pending']['direction'] == 'stacked',
207 'b did not arm a split below the focused pane') 207 'v did not arm a split below the focused pane')
208 rig.key('escape') 208 rig.key('escape')
209 rig.key('escape') 209 rig.key('escape')
210 rig.wait_state(lambda s: s['pending'] is None) 210 rig.wait_state(lambda s: s['pending'] is None)
test/native_tiling.py
Old New
@@ -18,7 +18,7 @@ import time
18 18
19 19
20 DEADLINE = 5.0 20 DEADLINE = 5.0
21 SPLIT_BINDINGS = {"beside": "v", "stacked": "b"} 21 SPLIT_BINDINGS = {"beside": "b", "stacked": "v"}
22 22
23 23
24 def require(condition, message): 24 def require(condition, message):