d903a815
fix: align the native command prefix with mux
a73x 2026-09-05 07:47
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -54,7 +54,7 @@ The first native tiling sprint supports one staged second target: | |||
| 54 | # Local second daemon: --next-target '--sock /tmp/second.sock' --next-session logs | 54 | # Local second daemon: --next-target '--sock /tmp/second.sock' --next-session logs |
| 55 | ``` | 55 | ``` |
| 56 | 56 | ||
| 57 | Press **Ctrl+Shift+Space**, then **v** for side by side or **b** for above/below. | 57 | Press `Ctrl+\`, then **v** for side by side or **b** for above/below. |
| 58 | This shows the intended split. Press the prefix again, then **Enter**, to insert | 58 | This shows the intended split. Press the prefix again, then **Enter**, to insert |
| 59 | the staged target. Cancellation creates no second pane or session. The split stays | 59 | the staged target. Cancellation creates no second pane or session. The split stays |
| 60 | on the pane where it was armed, even if focus moves before insertion. | 60 | on the pane where it was armed, even if focus moves before insertion. |
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,60 @@ | |||
| 1 | # Sprint retrospectives | ||
| 2 | |||
| 3 | ## Native tiling — Sprint 1, 2026-09-05 | ||
| 4 | |||
| 5 | Delivered two independent terminal panes, split selection, keyboard/mouse focus, | ||
| 6 | per-pane PTY sizing, and failure isolation. Refactoring and adversarial review | ||
| 7 | completed before acceptance. Native integration, 200% Wayland, and real SSH/QUIC | ||
| 8 | tests passed; the working demo is ready for ergonomic trial. | ||
| 9 | |||
| 10 | ### What worked | ||
| 11 | |||
| 12 | - Two real panes exercised ownership, rendering, input, and failure isolation | ||
| 13 | before adding picker complexity. The deliverable was concrete and testable. | ||
| 14 | - Reusing one implementer and one adversarial reviewer kept responsibility clear. | ||
| 15 | Review found command-repeat leakage and the PTY signal-handling requirement for | ||
| 16 | testing Ctrl+\ passthrough. | ||
| 17 | - Mandatory cleanup consolidated geometry and ownership before final validation. | ||
| 18 | - Automated checks established correctness; the user's trial exposed ergonomic | ||
| 19 | questions that those checks could not settle. | ||
| 20 | |||
| 21 | ### What to change | ||
| 22 | |||
| 23 | - The implementation chose Ctrl+Shift+Space without explicitly settling the | ||
| 24 | prefix with the user. The trial selected `Ctrl+\` for consistency with terminal | ||
| 25 | mux. Nesting mux is not a design requirement. Make unresolved interaction | ||
| 26 | choices explicit before implementing them. | ||
| 27 | - Explain temporary demo controls at handoff. `--next-target` and `--next-session` | ||
| 28 | stage one host/session pair; they are scaffolding until the picker exists. | ||
| 29 | - Keep delegation bounded: reuse the pair, assign narrow ownership, and exchange | ||
| 30 | concrete findings. Avoid repeated broad reviews or automatic skill ceremony. | ||
| 31 | |||
| 32 | ### Actions for Sprint 2 | ||
| 33 | |||
| 34 | Owner: the sprint lead, with implementation and review delegated as appropriate. | ||
| 35 | At the end of Sprint 2, mark completed items and record evidence or carry-forward | ||
| 36 | reasons below. Passing a test does not by itself complete an ergonomic action. | ||
| 37 | |||
| 38 | - [ ] Settle unresolved picker interactions before implementation. Record the | ||
| 39 | agreed keys, back/cancel behavior, and existing/new session flow in the spec. | ||
| 40 | - [ ] Demo host → existing/new session → insertion with the user. Verify that | ||
| 41 | the picker supplies the target without requiring staged CLI flags, and capture | ||
| 42 | the user's feedback on direction selection, cancellation, and focus. | ||
| 43 | - [ ] Make connection setup and discovery cancellable. Verify stalled dialing, | ||
| 44 | slow replies, cancellation, shutdown, and stale results after target changes; | ||
| 45 | inherited synchronous Unix connect and DNS resolution need explicit treatment. | ||
| 46 | - [ ] Resolve attach-existing versus create semantics. Test a session vanishing | ||
| 47 | between listing and attachment, and document behavior with older daemons. | ||
| 48 | - [ ] Keep the implementation/reviewer pair bounded and complete the refactoring | ||
| 49 | package before final acceptance. Record concrete findings resolved, duplicated | ||
| 50 | or obsolete code removed, and any remaining debt with its next owner/package. | ||
| 51 | |||
| 52 | ### Sprint 2 follow-up | ||
| 53 | |||
| 54 | To complete at the next retrospective: | ||
| 55 | |||
| 56 | - Which actions were completed, and what evidence demonstrates that? | ||
| 57 | - Which actions remain open, why, and where will they be addressed? | ||
| 58 | - Did the user trial expose another assumption that should have been settled | ||
| 59 | before implementation? | ||
| 60 | - Did delegation and cleanup improve the result enough to justify their cost? | ||
docs/superpowers/plans/2026-09-05-native-tiling.md
| Old | New | ||
|---|---|---|---|
| @@ -131,7 +131,7 @@ This is a usable substitute for session selection in sprint 1; | |||
| 131 | sprint 2 replaces target acquisition with the picker while retaining insertion. | 131 | sprint 2 replaces target acquisition with the picker while retaining insertion. |
| 132 | The target should be selectable from real CLI arguments, not only a test FIFO. | 132 | The target should be selectable from real CLI arguments, not only a test FIFO. |
| 133 | 133 | ||
| 134 | Implement Ctrl+Shift+Space command mode, visible pending direction, `h/j/k/l` | 134 | Implement Ctrl+\ command mode, visible pending direction, `h/j/k/l` |
| 135 | and arrow focus, click focus, cancellation, and prefix passthrough. Consume both | 135 | and arrow focus, click focus, cancellation, and prefix passthrough. Consume both |
| 136 | key events and associated SDL text events correctly: commands must not leak bytes | 136 | key events and associated SDL text events correctly: commands must not leak bytes |
| 137 | into a terminal or suppress subsequent ordinary text. Window resize remains live. | 137 | into a terminal or suppress subsequent ordinary text. Window resize remains live. |
| @@ -269,7 +269,9 @@ Validation completed: | |||
| 269 | Unix/Unix and Unix/QUIC pane pairs are covered. The measured quiet-pane input | 269 | Unix/Unix and Unix/QUIC pane pairs are covered. The measured quiet-pane input |
| 270 | reached an actual painted frame in 81 ms during a neighbouring flood; window | 270 | reached an actual painted frame in 81 ms during a neighbouring flood; window |
| 271 | p99 was 72 microseconds against the 20 ms budget. Prefix passthrough produced | 271 | p99 was 72 microseconds against the 20 ms budget. Prefix passthrough produced |
| 272 | exactly NUL followed by A through a real PTY (`od`: `00 41`). | 272 | exactly NUL followed by A through a real PTY (`od`: `00 41`) with the original |
| 273 | trial prefix. The ergonomic follow-up uses `Ctrl+\`; its repeated prefix sends | ||
| 274 | `0x1c`, verified with `od` as `1c 41` with PTY signal processing disabled. | ||
| 273 | - Native Wayland: 1698×2760 framebuffer at 849×1380 logical size, 200% density; | 275 | - Native Wayland: 1698×2760 framebuffer at 849×1380 logical size, 200% density; |
| 274 | both daemon sizes, independent coloured pixels, and a non-origin pane click | 276 | both daemon sizes, independent coloured pixels, and a non-origin pane click |
| 275 | converted from physical pixels to logical input coordinates passed. | 277 | converted from physical pixels to logical input coordinates passed. |
| @@ -296,6 +298,16 @@ running. Logs: `/tmp/muxg-tiling-real-remote.log`; local frame artifacts: | |||
| 296 | Sprint commit: `feat: add independent native terminal panes`. The next gate is | 298 | Sprint commit: `feat: add independent native terminal panes`. The next gate is |
| 297 | the user's ergonomic trial before Sprint 2 picker work. | 299 | the user's ergonomic trial before Sprint 2 picker work. |
| 298 | 300 | ||
| 301 | Ergonomic follow-up: the user selected `Ctrl+\` to match terminal mux. Updated | ||
| 302 | the handler, event hook, documentation, and existing passthrough checks; the | ||
| 303 | implementer and reviewer agreed with no outstanding findings. ReleaseSafe native | ||
| 304 | tests (25), native integration checkpoints (10 + 14), and `make ci` passed. | ||
| 305 | The first CI attempt hit an intermittent existing CLI pager-wheel assertion; | ||
| 306 | the isolated mouse group and full rerun passed without CLI changes. Logs: | ||
| 307 | `/tmp/muxg-prefix-native.log`, `/tmp/muxg-prefix-e2e.log`, and | ||
| 308 | `/tmp/muxg-prefix-ci-recheck.log`. Sprint lessons and checkable next-sprint | ||
| 309 | actions live separately in [RETRO.md](../../../RETRO.md). | ||
| 310 | |||
| 299 | Retained debt for package 2A: transport opening still has inherited synchronous | 311 | Retained debt for package 2A: transport opening still has inherited synchronous |
| 300 | Unix connect and DNS resolution before cancellable waits. Broadcast cancellation | 312 | Unix connect and DNS resolution before cancellable waits. Broadcast cancellation |
| 301 | improves multi-pane shutdown but does not make those stages interruptible. Extend | 313 | improves multi-pane shutdown but does not make those stages interruptible. Extend |
docs/superpowers/specs/2026-09-05-native-tiling-design.md
| Old | New | ||
|---|---|---|---|
| @@ -83,8 +83,9 @@ Initially, split the armed leaf into two equally sized children. Other panes | |||
| 83 | retain their allocated space. Repeated insertion semantics can be adjusted after | 83 | retain their allocated space. Repeated insertion semantics can be adjusted after |
| 84 | the first ergonomic trial; do not silently rebalance unrelated branches. | 84 | the first ergonomic trial; do not silently rebalance unrelated branches. |
| 85 | 85 | ||
| 86 | Keyboard prefix for the ergonomic trial: **Ctrl+Shift+Space**. It avoids Sway's usual Super | 86 | Keyboard prefix for the ergonomic trial: `Ctrl+\`, matching terminal mux. |
| 87 | bindings and the existing terminal mux prefix, including mux nested in a pane. | 87 | Press it twice to forward `Ctrl+\` to a terminal application. The native client |
| 88 | attaches directly to daemon sessions; nesting terminal mux is not a design requirement. | ||
| 88 | The prefix opens a small command hint strip; its next key is consumed by the GUI. | 89 | The prefix opens a small command hint strip; its next key is consumed by the GUI. |
| 89 | 90 | ||
| 90 | | After prefix | Action | | 91 | | After prefix | Action | |
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -94,7 +94,7 @@ pub fn parseHook(line: []const u8) ?Hook { | |||
| 94 | if (std.mem.startsWith(u8, line, "text:")) return .{ .text = line["text:".len..] }; | 94 | if (std.mem.startsWith(u8, line, "text:")) return .{ .text = line["text:".len..] }; |
| 95 | if (std.mem.startsWith(u8, line, "key:")) { | 95 | if (std.mem.startsWith(u8, line, "key:")) { |
| 96 | const name = line["key:".len..]; | 96 | const name = line["key:".len..]; |
| 97 | if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_SPACE, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } }; | 97 | if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } }; |
| 98 | if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvb", name[0]) != null) return .{ .key = .{ .code = name[0] } }; | 98 | if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvb", name[0]) != null) return .{ .key = .{ .code = name[0] } }; |
| 99 | inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| { | 99 | inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| { |
| 100 | if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } }; | 100 | if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } }; |
| @@ -308,7 +308,7 @@ const Events = struct { | |||
| 308 | self.suppress_text = true; | 308 | self.suppress_text = true; |
| 309 | return true; | 309 | return true; |
| 310 | } | 310 | } |
| 311 | const prefix = key == c.SDLK_SPACE and ev.key.mod & c.SDL_KMOD_CTRL != 0 and ev.key.mod & c.SDL_KMOD_SHIFT != 0 and ev.key.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0; | 311 | const prefix = key == c.SDLK_BACKSLASH and ev.key.mod & c.SDL_KMOD_CTRL != 0 and ev.key.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0; |
| 312 | if (prefix) { | 312 | if (prefix) { |
| 313 | self.suppress_text = true; | 313 | self.suppress_text = true; |
| 314 | if (!ev.key.repeat) { | 314 | if (!ev.key.repeat) { |
| @@ -748,12 +748,18 @@ test "command key text is consumed and subsequent ordinary text is not suppresse | |||
| 748 | var events: Events = .{ .rt = &rt, .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 960, .fb_h = 600 }; | 748 | var events: Events = .{ .rt = &rt, .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 960, .fb_h = 600 }; |
| 749 | var event = std.mem.zeroes(c.SDL_Event); | 749 | var event = std.mem.zeroes(c.SDL_Event); |
| 750 | event.key.type = c.SDL_EVENT_KEY_DOWN; | 750 | event.key.type = c.SDL_EVENT_KEY_DOWN; |
| 751 | event.key.key = c.SDLK_SPACE; | 751 | event.key.key = c.SDLK_BACKSLASH; |
| 752 | event.key.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT; | 752 | for ([_]u16{ 0, c.SDL_KMOD_SHIFT, c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT, c.SDL_KMOD_CTRL | c.SDL_KMOD_GUI, c.SDL_KMOD_CTRL | c.SDL_KMOD_MODE }) |mods| { |
| 753 | event.key.mod = mods; | ||
| 754 | try std.testing.expect(try events.handle(event)); | ||
| 755 | try std.testing.expect(!events.command_mode); | ||
| 756 | } | ||
| 757 | // Bare Ctrl activates the prefix; Shift is also accepted below. | ||
| 758 | event.key.mod = c.SDL_KMOD_CTRL; | ||
| 753 | try std.testing.expect(try events.handle(event)); | 759 | try std.testing.expect(try events.handle(event)); |
| 754 | try std.testing.expect(events.command_mode and events.suppress_text); | 760 | try std.testing.expect(events.command_mode and events.suppress_text); |
| 755 | event.text.type = c.SDL_EVENT_TEXT_INPUT; | 761 | event.text.type = c.SDL_EVENT_TEXT_INPUT; |
| 756 | event.text.text = " "; | 762 | event.text.text = "\\"; |
| 757 | try std.testing.expect(try events.handle(event)); | 763 | try std.testing.expect(try events.handle(event)); |
| 758 | event = std.mem.zeroes(c.SDL_Event); | 764 | event = std.mem.zeroes(c.SDL_Event); |
| 759 | event.key.type = c.SDL_EVENT_KEY_DOWN; | 765 | event.key.type = c.SDL_EVENT_KEY_DOWN; |
| @@ -778,16 +784,16 @@ test "command key text is consumed and subsequent ordinary text is not suppresse | |||
| 778 | event.key.key = c.SDLK_X; | 784 | event.key.key = c.SDLK_X; |
| 779 | try std.testing.expect(try events.handle(event)); | 785 | try std.testing.expect(try events.handle(event)); |
| 780 | try std.testing.expect(!events.suppress_text); | 786 | try std.testing.expect(!events.suppress_text); |
| 781 | // Repeated prefix exits mode; its shared keymap encoding is NUL. | 787 | // Repeated prefix exits mode; its shared keymap encoding is 0x1c. |
| 782 | // The integration fixture verifies the emitted byte count with a real PTY. | 788 | // The integration fixture verifies the emitted byte count with a real PTY. |
| 783 | event.key.key = c.SDLK_SPACE; | 789 | event.key.key = c.SDLK_BACKSLASH; |
| 784 | event.key.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT; | 790 | event.key.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT; |
| 785 | try std.testing.expect(try events.handle(event)); | 791 | try std.testing.expect(try events.handle(event)); |
| 786 | try std.testing.expect(events.command_mode); | 792 | try std.testing.expect(events.command_mode); |
| 787 | try std.testing.expect(try events.handle(event)); | 793 | try std.testing.expect(try events.handle(event)); |
| 788 | try std.testing.expect(!events.command_mode); | 794 | try std.testing.expect(!events.command_mode); |
| 789 | var bytes: [keymap.max_seq_len]u8 = undefined; | 795 | var bytes: [keymap.max_seq_len]u8 = undefined; |
| 790 | try std.testing.expectEqualSlices(u8, &.{0}, keymap.encode(keyEvent(c.SDLK_SPACE, event.key.mod).?, &bytes)); | 796 | try std.testing.expectEqualSlices(u8, &.{0x1c}, keymap.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes)); |
| 791 | } | 797 | } |
| 792 | 798 | ||
| 793 | test "later pane atlas growth precedes earlier pane UV generation" { | 799 | test "later pane atlas growth precedes earlier pane UV generation" { |
test/native_tiling.py
| Old | New | ||
|---|---|---|---|
| @@ -321,12 +321,12 @@ def normal_scenario(rig, axis): | |||
| 321 | rig.ok("window resize, neighbour-safe clearing, and mouse focus work") | 321 | rig.ok("window resize, neighbour-safe clearing, and mouse focus work") |
| 322 | 322 | ||
| 323 | if axis == "v": | 323 | if axis == "v": |
| 324 | rig.shell("stty -echo -icanon min 1 time 0; printf 'RAW-%s\\n' READY; " | 324 | rig.shell("stty -echo -icanon -isig min 1 time 0; printf 'RAW-%s\\n' READY; " |
| 325 | "od -An -tx1 -N2; stty sane; printf 'RAW-%s\\n' DONE") | 325 | "od -An -tx1 -N2; stty sane; printf 'RAW-%s\\n' DONE") |
| 326 | rig.wait_marker(second, "right", "RAW-READY") | 326 | rig.wait_marker(second, "right", "RAW-READY") |
| 327 | rig.send("key:prefix", "key:prefix", "text:A") | 327 | rig.send("key:prefix", "key:prefix", "text:A") |
| 328 | rig.wait_marker(second, "right", "RAW-DONE") | 328 | rig.wait_marker(second, "right", "RAW-DONE") |
| 329 | require("00 41" in rig.dump(second, "right"), "prefix passthrough did not send one NUL then A") | 329 | require("1c 41" in rig.dump(second, "right"), "prefix passthrough did not send one 0x1c then A") |
| 330 | rig.ok("double prefix sends the terminal encoding exactly once; ordinary input follows") | 330 | rig.ok("double prefix sends the terminal encoding exactly once; ordinary input follows") |
| 331 | # A bounded flood with an external stop signal. During the frame-count | 331 | # A bounded flood with an external stop signal. During the frame-count |
| 332 | # interval only nonforcing state observations are allowed, never captures. | 332 | # interval only nonforcing state observations are allowed, never captures. |