d01b5506
feat: streamline native split chords and End feedback
a73x 2026-09-05 17:02
Commit message
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -712,3 +712,43 @@ documentation handoff, and previous demo acceptance remains pending. | |||
| 712 | start internal daemon and GUI tasks in separate worktrees with these briefs. | 712 | start internal daemon and GUI tasks in separate worktrees with these briefs. |
| 713 | - [ ] Record user acceptance of the existing recordings separately. Continuing | 713 | - [ ] Record user acceptance of the existing recordings separately. Continuing |
| 714 | the ownership handoff does not imply their acceptance. | 714 | the ownership handoff does not imply their acceptance. |
| 715 | |||
| 716 | ## Native chord ergonomics — 2026-09-05 | ||
| 717 | |||
| 718 | Hands-on feedback identified an unnecessary second chord after choosing split | ||
| 719 | direction, and a progress menu too brief to read on End. Prefix + v/b now opens | ||
| 720 | the host picker directly, keeping the selected direction and cancellation | ||
| 721 | semantics. End progress uses the originating pane header; shared-session refusal | ||
| 722 | still offers explicit force with Cancel selected first. Existing save warnings | ||
| 723 | retain header priority. The [delivery record](docs/superpowers/plans/2026-09-05-native-chord-ergonomics.md) | ||
| 724 | contains the validation and recording evidence. | ||
| 725 | |||
| 726 | The existing controller boundary made this a GUI-only change. Opening cleanup | ||
| 727 | was unnecessary; closing cleanup removed the obsolete progress-menu variant and | ||
| 728 | combined the shared v/b/Enter picker path. Video inspection caught the remaining | ||
| 729 | extra-chord header hint; review caught the distinction between insertion and | ||
| 730 | replacement pickers before final delivery. Stable attachment/request identity, | ||
| 731 | duplicate-End protection and late-reply modal ownership remain intact. | ||
| 732 | |||
| 733 | Luna implemented interaction/frame changes; Terra reviewed them adversarially. | ||
| 734 | Recorded metadata confirmed the models. Root updated integration fixtures, | ||
| 735 | independently observed delayed daemon replies and framebuffer pixels, and ran | ||
| 736 | the delivery gates. Final native units (50), native integration, full CI, | ||
| 737 | Wayland lifecycle/scale checks and raw-output stress passed. The stress result | ||
| 738 | remains close to the existing frame budget; it is not evidence of a speedup. | ||
| 739 | Available cumulative and start-snapshot-difference counters are retained with | ||
| 740 | cached input identified; they are not monetary costs. | ||
| 741 | |||
| 742 | The 23.8-second GUI recording shows the actual revised interactions. The user | ||
| 743 | explicitly approved its tailnet publication and confirmed ownership after | ||
| 744 | automatic review rejected the new payload; existing routes were preserved. | ||
| 745 | Playback and range responses were verified. Publication is separate from | ||
| 746 | acceptance of the ergonomic changes. | ||
| 747 | |||
| 748 | ### Next actions | ||
| 749 | |||
| 750 | - [ ] Record acceptance or further hands-on feedback for these chord changes. | ||
| 751 | - [ ] When revisiting header layout, consider showing save failure and pending | ||
| 752 | End together; keep save errors prominent in narrow panes. | ||
| 753 | - [ ] A future recorder package should include its PTY registration and absolute | ||
| 754 | compositor address setup, avoiding the two fixture mistakes caught here. | ||
docs/superpowers/plans/2026-09-05-native-chord-ergonomics.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,73 @@ | |||
| 1 | # Native split chords and End feedback | ||
| 2 | |||
| 3 | The user reported that selecting a split direction is normally followed by host | ||
| 4 | selection, and that prefix + x flashes an unidentifiable menu. This hands-on | ||
| 5 | feedback authorizes a bounded ergonomic trial; it does not accept prior recordings. | ||
| 6 | |||
| 7 | ## Scope and acceptance | ||
| 8 | |||
| 9 | - Prefix + v/b arms the focused pane for side-by-side/below insertion and opens | ||
| 10 | the host picker immediately. No geometry or session changes before selection. | ||
| 11 | - Esc in the host picker closes it while retaining the pending direction; | ||
| 12 | another Esc outside the picker cancels it. Prefix + Enter remains available. | ||
| 13 | - End uses a status on its originating pane while awaiting the daemon. It opens | ||
| 14 | no progress modal. A shared-session refusal still offers Cancel first and | ||
| 15 | explicit End for all clients, bound to the original attachment. | ||
| 16 | - Pending requests cannot be duplicated; late replies cannot steal an active | ||
| 17 | picker, command, resize, drag or recovery menu. The daemon protocol is unchanged. | ||
| 18 | |||
| 19 | Opening inspection found the existing controller and pending request identity | ||
| 20 | sufficient; no preparatory refactor is needed. Closing cleanup removes the | ||
| 21 | obsolete progress-menu variant instead of introducing a popup delay or timer. | ||
| 22 | Existing save-failure visibility remains a priority in narrow pane headers. | ||
| 23 | |||
| 24 | Luna owns controller/frame changes and unit coverage; Terra reviews the input | ||
| 25 | and request-lifetime contracts. Root owns integration fixtures, independent | ||
| 26 | validation, the recording and delivery records. Reused session metadata confirms | ||
| 27 | the selected models. Test helpers must exercise the direct v/b-to-picker path | ||
| 28 | rather than sending an extra prefix + Enter afterward. | ||
| 29 | |||
| 30 | ## Validation and delivery | ||
| 31 | |||
| 32 | Implemented, independently reviewed and validated; recorded-demo acceptance is | ||
| 33 | pending. Both binaries were built together in ReleaseSafe at | ||
| 34 | `dist/native-chord-release`. The final GUI is also installed in `zig-out/bin/muxg` | ||
| 35 | for the user's next launch; an already running window keeps its earlier image. | ||
| 36 | |||
| 37 | - Native unit tests passed: 22 core and 28 frame/painter tests. | ||
| 38 | - Full `make ci` passed, including ten agent scenarios and throughput. | ||
| 39 | - `make native-e2e` passed on the final frozen source. The real delayed-daemon | ||
| 40 | cases assert no progress popup in modal state or centre framebuffer pixels, | ||
| 41 | preserve request identity, and exercise command, focus, picker, resize, drag, | ||
| 42 | replacement, detach and accepted-End transitions. | ||
| 43 | - Separate real Wayland lifecycle checks and 200%/100%/150%/200% scale transitions | ||
| 44 | passed, including independent kernel PTY dimensions and passive pixels. | ||
| 45 | - `make native-stress` passed after CI and recording completed. Over 30.2 seconds, | ||
| 46 | it read 104.8 MB, with frame p99 19.920 ms before reopen and 19.388 ms afterward. | ||
| 47 | Input-to-painted maxima were 55.5 ms on the separate daemon and 55.4 ms on the | ||
| 48 | shared daemon, 95 samples each. The 5 ms polling and observation overhead make | ||
| 49 | these upper bounds. No budget changed and no speed improvement is claimed. | ||
| 50 | |||
| 51 | The final 23.8-second actual GUI recording has one continuous clip, no cuts or | ||
| 52 | audio, at 200% scale. It shows both direct split chords, cancellation, fast End, | ||
| 53 | a delayed End header and Cancel-first shared-session confirmation. Input is | ||
| 54 | ordinary SDL events; this is not a new compositor mouse-path demonstration. | ||
| 55 | Video review caught an obsolete extra-chord hint; it now shows only the split | ||
| 56 | direction while the insertion picker is open. A replacement picker retains the | ||
| 57 | pending-arm hint, so it cannot be mislabeled as insertion. | ||
| 58 | |||
| 59 | Two fixture setup mistakes were corrected without product changes: the first | ||
| 60 | shell-suite Wayland launch lost its compositor address when the harness isolated | ||
| 61 | its runtime directory, and the first recorder omitted PTY observer registration. | ||
| 62 | The complete native suite used its standard offscreen setup; separate Python | ||
| 63 | Wayland fixtures preserve the compositor address correctly. Initial and final | ||
| 64 | logs are retained. The owned fixtures and compositor are cleaned up separately | ||
| 65 | from the user's sessions and desktop. No new macOS or SSH transport claim is made. | ||
| 66 | |||
| 67 | Artifacts, final source/binary hashes and model metadata are in `chord-evidence/` | ||
| 68 | beside this thread's visualization files. The retained video is | ||
| 69 | `native-chord-trial.mp4`. User approval explicitly confirmed tailnet ownership | ||
| 70 | and publication of this recording after automatic approval review blocked the | ||
| 71 | new payload. The verified page is | ||
| 72 | <https://charizard.folk-amberjack.ts.net/chord-trial/>; all prior routes remain. | ||
| 73 | Publication authorization does not imply demo acceptance. | ||
docs/superpowers/specs/2026-09-05-native-tiling-design.md
| Old | New | ||
|---|---|---|---|
| @@ -77,8 +77,8 @@ Use side-by-side and above/below in the interface: `v` names the vertical | |||
| 77 | divider, with the new pane to the right; `b` puts the new pane below. Show the | 77 | divider, with the new pane to the right; `b` puts the new pane below. 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 and changes no | 80 | Direction selection arms the focused pane for one insertion, immediately opens |
| 81 | geometry. Opening the picker captures that pane and direction. Host selection | 81 | the host picker and changes no geometry. The picker captures that pane and direction. Host selection |
| 82 | opens its sessions; New session requests a name and creates it explicitly. | 82 | opens its sessions; New session requests a name and creates it explicitly. |
| 83 | Only committing a session selection inserts the pane and focuses it. Cancelling | 83 | Only committing a session selection inserts the pane and focuses it. Cancelling |
| 84 | either level before committing creates no pane or session. Esc in sessions returns to hosts; | 84 | either level before committing creates no pane or session. Esc in sessions returns to hosts; |
| @@ -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` | Arm side-by-side insertion (vertical divider) | | 128 | | `v` | Open host picker for side-by-side insertion (vertical divider) | |
| 129 | | `b` | Arm above/below insertion (new pane below) | | 129 | | `b` | Open host picker for above/below insertion (new pane below) | |
| 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 | |
| @@ -137,6 +137,9 @@ The prefix opens a small command hint strip; its next key is consumed by the GUI | |||
| 137 | 137 | ||
| 138 | The split and movement bindings above were agreed on 2026-09-05: reserve | 138 | The split and movement bindings above were agreed on 2026-09-05: reserve |
| 139 | `h/j/k/l` for directional movement, with `v` and `b` selecting split direction. | 139 | `h/j/k/l` for directional movement, with `v` and `b` selecting split direction. |
| 140 | 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 | retains the armed direction, and Esc outside the picker cancels it. | ||
| 140 | 143 | ||
| 141 | Pressing the prefix twice forwards its normal terminal keymap encoding once and | 144 | Pressing the prefix twice forwards its normal terminal keymap encoding once and |
| 142 | exits command mode. Ordinary input | 145 | exits command mode. Ordinary input |
| @@ -192,6 +195,10 @@ A lost or timed-out reply reports an unknown outcome. Retry establishes a fresh | |||
| 192 | attachment before another End may be issued. Recovery and confirmation retain | 195 | attachment before another End may be issued. Recovery and confirmation retain |
| 193 | their originating pane and attachment generation; confirmations identify the | 196 | their originating pane and attachment generation; confirmations identify the |
| 194 | session and target and cannot follow a later focus change to a different pane. | 197 | session and target and cannot follow a later focus change to a different pane. |
| 198 | The chord-ergonomics trial shows a pending End in its originating pane header, | ||
| 199 | without a progress popup. Normal input and GUI controls remain available; repeat | ||
| 200 | End requests are blocked until the outcome arrives. Existing save-failure | ||
| 201 | warnings retain header priority, so they can temporarily hide the pending marker. | ||
| 195 | 202 | ||
| 196 | Use a separate, versioned GUI state file at | 203 | Use a separate, versioned GUI state file at |
| 197 | `$XDG_STATE_HOME/mux/native-workspace.json`. The initial schema has an ordered | 204 | `$XDG_STATE_HOME/mux/native-workspace.json`. The initial schema has an ordered |
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -824,6 +824,32 @@ test "held detach removes one pane and recovery Enter stays consumed after closi | |||
| 824 | try std.testing.expect(std.mem.startsWith(u8, header.bytes[0..header.len], "> Workspace not saved:")); | 824 | try std.testing.expect(std.mem.startsWith(u8, header.bytes[0..header.len], "> Workspace not saved:")); |
| 825 | } | 825 | } |
| 826 | 826 | ||
| 827 | test "pending End header stays with its origin and rejects stale generations" { | ||
| 828 | const a = std.testing.allocator; | ||
| 829 | var rt = runtime.Runtime.init(a, .{}); | ||
| 830 | defer rt.deinit(); | ||
| 831 | const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 }; | ||
| 832 | const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics); | ||
| 833 | const second = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics); | ||
| 834 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | ||
| 835 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } }; | ||
| 836 | defer events.deinit(); | ||
| 837 | try events.ui.relayout(); | ||
| 838 | const key = rt.get(first).?.key; | ||
| 839 | events.ui.pending_end = .{ .key = key, .request = 1 }; | ||
| 840 | _ = rt.workspace.focus(second); | ||
| 841 | var origin_header: Header = .{}; | ||
| 842 | origin_header.set(&events, events.ui.layout.get(first).?, rt.get(first).?); | ||
| 843 | try std.testing.expect(std.mem.indexOf(u8, origin_header.bytes[0..origin_header.len], "End requested") != null); | ||
| 844 | var other_header: Header = .{}; | ||
| 845 | other_header.set(&events, events.ui.layout.get(second).?, rt.get(second).?); | ||
| 846 | try std.testing.expect(std.mem.indexOf(u8, other_header.bytes[0..other_header.len], "End requested") == null); | ||
| 847 | events.ui.pending_end.?.key.generation += 1; | ||
| 848 | origin_header.set(&events, events.ui.layout.get(first).?, rt.get(first).?); | ||
| 849 | try std.testing.expect(std.mem.indexOf(u8, origin_header.bytes[0..origin_header.len], "End requested") == null); | ||
| 850 | events.ui.pending_end = null; | ||
| 851 | } | ||
| 852 | |||
| 827 | const Header = struct { | 853 | const Header = struct { |
| 828 | bytes: [512]u8 = undefined, | 854 | bytes: [512]u8 = undefined, |
| 829 | cells: [512]term.grid.Cell = undefined, | 855 | cells: [512]term.grid.Cell = undefined, |
| @@ -831,7 +857,8 @@ const Header = struct { | |||
| 831 | fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void { | 857 | fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void { |
| 832 | const focused = events.ui.rt.workspace.tab().focus == p.id; | 858 | const focused = events.ui.rt.workspace.tab().focus == p.id; |
| 833 | const pending = events.ui.rt.workspace.tab().pending; | 859 | const pending = events.ui.rt.workspace.tab().pending; |
| 834 | const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] 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 (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]") else events.ui.notice; | 860 | const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false; |
| 861 | 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; | ||
| 835 | const label = events.ui.rt.workspace.pane(p.id).?.identity.label; | 862 | const label = events.ui.rt.workspace.pane(p.id).?.identity.label; |
| 836 | var status_buf: [48]u8 = undefined; | 863 | var status_buf: [48]u8 = undefined; |
| 837 | const status: []const u8 = switch (live.status.phase) { | 864 | const status: []const u8 = switch (live.status.phase) { |
src/gui/interaction.zig
| Old | New | ||
|---|---|---|---|
| @@ -24,7 +24,7 @@ pub const KeyDown = struct { | |||
| 24 | pub const PendingEnd = struct { key: model.Attachment, request: u64 }; | 24 | pub const PendingEnd = struct { key: model.Attachment, request: u64 }; |
| 25 | 25 | ||
| 26 | pub const Recovery = struct { | 26 | pub const Recovery = struct { |
| 27 | kind: enum { recovery, ending, force_end }, | 27 | kind: enum { recovery, force_end }, |
| 28 | key: model.Attachment, | 28 | key: model.Attachment, |
| 29 | selected: usize = 0, | 29 | selected: usize = 0, |
| 30 | notice: [1024]u8 = @splat(0), | 30 | notice: [1024]u8 = @splat(0), |
| @@ -32,21 +32,18 @@ pub const Recovery = struct { | |||
| 32 | pub fn count(self: Recovery) usize { | 32 | pub fn count(self: Recovery) usize { |
| 33 | return switch (self.kind) { | 33 | return switch (self.kind) { |
| 34 | .recovery => 3, | 34 | .recovery => 3, |
| 35 | .ending => 1, | ||
| 36 | .force_end => 2, | 35 | .force_end => 2, |
| 37 | }; | 36 | }; |
| 38 | } | 37 | } |
| 39 | pub fn label(self: Recovery, index: usize) []const u8 { | 38 | pub fn label(self: Recovery, index: usize) []const u8 { |
| 40 | return switch (self.kind) { | 39 | return switch (self.kind) { |
| 41 | .recovery => ([_][]const u8{ "Retry", "Choose session", "Detach" })[index], | 40 | .recovery => ([_][]const u8{ "Retry", "Choose session", "Detach" })[index], |
| 42 | .ending => "Dismiss (End is already requested)", | ||
| 43 | .force_end => ([_][]const u8{ "Cancel", "End for all clients" })[index], | 41 | .force_end => ([_][]const u8{ "Cancel", "End for all clients" })[index], |
| 44 | }; | 42 | }; |
| 45 | } | 43 | } |
| 46 | pub fn title(self: Recovery) []const u8 { | 44 | pub fn title(self: Recovery) []const u8 { |
| 47 | return switch (self.kind) { | 45 | return switch (self.kind) { |
| 48 | .recovery => "Pane actions", | 46 | .recovery => "Pane actions", |
| 49 | .ending => "Ending session...", | ||
| 50 | .force_end => "Other clients are attached", | 47 | .force_end => "Other clients are attached", |
| 51 | }; | 48 | }; |
| 52 | } | 49 | } |
| @@ -104,8 +101,14 @@ pub const Controller = struct { | |||
| 104 | ws.moveFocus(&self.layout, direction); | 101 | ws.moveFocus(&self.layout, direction); |
| 105 | self.intent_dirty = self.intent_dirty or before != ws.tab().focus; | 102 | self.intent_dirty = self.intent_dirty or before != ws.tab().focus; |
| 106 | } else switch (key) { | 103 | } else switch (key) { |
| 107 | .v => ws.arm(.beside), | 104 | .v, .b, .enter, .keypad_enter => { |
| 108 | .b => ws.arm(.stacked), | 105 | if (key == .v or key == .b) ws.arm(if (key == .v) .beside else .stacked); |
| 106 | self.openPicker(.insert) catch |err| { | ||
| 107 | self.notice = @errorName(err); | ||
| 108 | self.dirty = true; | ||
| 109 | return; | ||
| 110 | }; | ||
| 111 | }, | ||
| 109 | .r => self.resize_mode = true, | 112 | .r => self.resize_mode = true, |
| 110 | .d => if (ws.tab().focus) |id| try self.detach(id), | 113 | .d => if (ws.tab().focus) |id| try self.detach(id), |
| 111 | .x => if (ws.tab().focus) |id| try self.beginEnd(self.rt.get(id).?.key, false), | 114 | .x => if (ws.tab().focus) |id| try self.beginEnd(self.rt.get(id).?.key, false), |
| @@ -114,13 +117,6 @@ pub const Controller = struct { | |||
| 114 | self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText()); | 117 | self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText()); |
| 115 | }, | 118 | }, |
| 116 | .escape => ws.cancel(), | 119 | .escape => ws.cancel(), |
| 117 | .enter, .keypad_enter => { | ||
| 118 | self.openPicker(.insert) catch |err| { | ||
| 119 | self.notice = @errorName(err); | ||
| 120 | self.dirty = true; | ||
| 121 | return; | ||
| 122 | }; | ||
| 123 | }, | ||
| 124 | else => self.notice = "v/b split, hjkl focus, r resize, d detach, x end, p actions, Enter add", | 120 | else => self.notice = "v/b split, hjkl focus, r resize, d detach, x end, p actions, Enter add", |
| 125 | } | 121 | } |
| 126 | self.dirty = true; | 122 | self.dirty = true; |
| @@ -180,7 +176,6 @@ pub const Controller = struct { | |||
| 180 | return; | 176 | return; |
| 181 | }; | 177 | }; |
| 182 | self.pending_end = .{ .key = key, .request = request }; | 178 | self.pending_end = .{ .key = key, .request = request }; |
| 183 | self.recovery = .{ .kind = .ending, .key = key }; | ||
| 184 | self.dirty = true; | 179 | self.dirty = true; |
| 185 | } | 180 | } |
| 186 | pub fn pollEnd(self: *Controller) !void { | 181 | pub fn pollEnd(self: *Controller) !void { |
| @@ -193,8 +188,6 @@ pub const Controller = struct { | |||
| 193 | const result = self.rt.get(pending.key.pane).?.status.ending; | 188 | const result = self.rt.get(pending.key.pane).?.status.ending; |
| 194 | if (result.request != pending.request or result.phase == .pending) return; | 189 | if (result.request != pending.request or result.phase == .pending) return; |
| 195 | self.pending_end = null; | 190 | self.pending_end = null; |
| 196 | const own_menu = if (self.recovery) |menu| menu.kind == .ending and std.meta.eql(menu.key, pending.key) else false; | ||
| 197 | if (own_menu) self.recovery = null; | ||
| 198 | switch (result.phase) { | 191 | switch (result.phase) { |
| 199 | .accepted => try self.detach(pending.key.pane), | 192 | .accepted => try self.detach(pending.key.pane), |
| 200 | .refused => { | 193 | .refused => { |
| @@ -218,7 +211,6 @@ pub const Controller = struct { | |||
| 218 | return; | 211 | return; |
| 219 | } | 212 | } |
| 220 | switch (menu.kind) { | 213 | switch (menu.kind) { |
| 221 | .ending => self.setNotice("End requested; waiting for its outcome"), | ||
| 222 | .force_end => if (menu.selected == 1) try self.beginEnd(menu.key, true), | 214 | .force_end => if (menu.selected == 1) try self.beginEnd(menu.key, true), |
| 223 | .recovery => switch (menu.selected) { | 215 | .recovery => switch (menu.selected) { |
| 224 | 0, 1 => { | 216 | 0, 1 => { |
| @@ -498,3 +490,44 @@ test "controller modal input takes precedence over the prefix" { | |||
| 498 | try ui.keyDown(.{ .code = 92, .prefix = true, .modified = true }); | 490 | try ui.keyDown(.{ .code = 92, .prefix = true, .modified = true }); |
| 499 | try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text); | 491 | try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text); |
| 500 | } | 492 | } |
| 493 | |||
| 494 | test "v/b opens insertion picker and Esc preserves then cancels pending direction" { | ||
| 495 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | ||
| 496 | defer rt.deinit(); | ||
| 497 | var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 }; | ||
| 498 | defer ui.deinit(); | ||
| 499 | |||
| 500 | _ = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics); | ||
| 501 | try ui.command(.b); | ||
| 502 | try std.testing.expect(ui.picker != null); | ||
| 503 | try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction); | ||
| 504 | try ui.keyDown(.{ .code = 27, .kind = .escape }); | ||
| 505 | ui.keyUp(27); | ||
| 506 | try std.testing.expect(ui.picker == null); | ||
| 507 | try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction); | ||
| 508 | |||
| 509 | try ui.keyDown(.{ .code = 27, .kind = .escape }); | ||
| 510 | ui.keyUp(27); | ||
| 511 | try std.testing.expect(rt.workspace.tab().pending == null); | ||
| 512 | |||
| 513 | try ui.command(.v); | ||
| 514 | try std.testing.expectEqual(model.Direction.beside, rt.workspace.tab().pending.?.direction); | ||
| 515 | try ui.keyDown(.{ .code = 27, .kind = .escape }); | ||
| 516 | ui.keyUp(27); | ||
| 517 | } | ||
| 518 | |||
| 519 | test "beginEnd records pending request without opening an ending modal" { | ||
| 520 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | ||
| 521 | defer rt.deinit(); | ||
| 522 | const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; | ||
| 523 | const id = try rt.add(.{ .via = "cat" }, "ending", 800, 600, metrics); | ||
| 524 | var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 }; | ||
| 525 | defer ui.deinit(); | ||
| 526 | rt.get(id).?.pump.mu.lock(); | ||
| 527 | rt.get(id).?.pump.status.phase = .attached; | ||
| 528 | rt.get(id).?.pump.mu.unlock(); | ||
| 529 | try ui.beginEnd(rt.get(id).?.key, false); | ||
| 530 | try std.testing.expect(ui.recovery == null); | ||
| 531 | try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request); | ||
| 532 | ui.pending_end = null; | ||
| 533 | } | ||
test/native_lifecycle.py
| Old | New | ||
|---|---|---|---|
| @@ -15,7 +15,7 @@ import time | |||
| 15 | sys.dont_write_bytecode = True | 15 | sys.dont_write_bytecode = True |
| 16 | from native_tiling import colour_counts, eventually, require | 16 | from native_tiling import colour_counts, eventually, require |
| 17 | from native_resize import ResizeRig, by_id, edge | 17 | from native_resize import ResizeRig, by_id, edge |
| 18 | from native_picker import create, end, sessions | 18 | from native_picker import create, end, region, sessions |
| 19 | 19 | ||
| 20 | 20 | ||
| 21 | class LifecycleRig(ResizeRig): | 21 | class LifecycleRig(ResizeRig): |
| @@ -501,12 +501,23 @@ def delayed_end_transitions(mux, muxg): | |||
| 501 | generation = by_id(state)[origin]['generation'] | 501 | generation = by_id(state)[origin]['generation'] |
| 502 | holder = None if transition == 'accepted' else rig.holder(a, 'left') | 502 | holder = None if transition == 'accepted' else rig.holder(a, 'left') |
| 503 | rig.focus(origin) | 503 | rig.focus(origin) |
| 504 | # A delayed reply makes any transient progress popup observable. | ||
| 505 | # Compare the quiet centre of the actual framebuffer, away from | ||
| 506 | # pane headers and shell cursors, as well as modal state. | ||
| 507 | state = rig.state() | ||
| 508 | centre = {'x': state['width'] // 2 - 40, 'y': state['height'] // 2 - 30, | ||
| 509 | 'w': 80, 'h': 60} | ||
| 510 | before_pixels = region(rig.pixels(), centre) | ||
| 504 | daemon.send_signal(signal.SIGSTOP) | 511 | daemon.send_signal(signal.SIGSTOP) |
| 505 | paused = daemon | 512 | paused = daemon |
| 506 | rig.chord('x') | 513 | rig.chord('x') |
| 507 | rig.wait_state(lambda s: s['pending_end'] is not None) | 514 | state = rig.wait_state(lambda s: s['pending_end'] is not None) |
| 508 | rig.key('escape') | 515 | require(state['recovery'] is None and state['picker'] is None, |
| 509 | rig.wait_state(lambda s: s['recovery'] is None) | 516 | 'pending End opened a progress popup') |
| 517 | require(state['pending_end']['key']['pane'] == origin, | ||
| 518 | 'pending End lost its original target') | ||
| 519 | require(region(rig.pixels(), centre) == before_pixels, | ||
| 520 | 'pending End painted a popup over the workspace') | ||
| 510 | 521 | ||
| 511 | if transition in ('focus', 'accepted'): | 522 | if transition in ('focus', 'accepted'): |
| 512 | rig.focus(neighbour) | 523 | rig.focus(neighbour) |
| @@ -560,6 +571,8 @@ def delayed_end_transitions(mux, muxg): | |||
| 560 | require(state['recovery'] is None, 'late refusal installed a force menu in another context') | 571 | require(state['recovery'] is None, 'late refusal installed a force menu in another context') |
| 561 | if transition == 'command': | 572 | if transition == 'command': |
| 562 | require(state['command_mode'], 'late refusal interrupted command mode') | 573 | require(state['command_mode'], 'late refusal interrupted command mode') |
| 574 | elif transition == 'focus': | ||
| 575 | require(state['focus'] == neighbour, 'late refusal stole focus from the neighbour') | ||
| 563 | elif transition == 'picker': | 576 | elif transition == 'picker': |
| 564 | require(state['picker'] is not None, 'late refusal dismissed the picker') | 577 | require(state['picker'] is not None, 'late refusal dismissed the picker') |
| 565 | elif transition == 'resize': | 578 | elif transition == 'resize': |
test/native_picker.py
| Old | New | ||
|---|---|---|---|
| @@ -222,7 +222,6 @@ def add_host(rig): | |||
| 222 | rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host") | 222 | rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host") |
| 223 | before = rig.pixels() | 223 | before = rig.pixels() |
| 224 | rig.chord("v") | 224 | rig.chord("v") |
| 225 | rig.chord("enter") | ||
| 226 | state = rig.picker("hosts") | 225 | state = rig.picker("hosts") |
| 227 | rect = state["picker"]["rect"] | 226 | rect = state["picker"]["rect"] |
| 228 | painted = rig.pixels() | 227 | painted = rig.pixels() |
| @@ -261,7 +260,6 @@ def slow_and_legacy(rig): | |||
| 261 | rig.catalogue([first_target, slow_target, legacy_target]) | 260 | rig.catalogue([first_target, slow_target, legacy_target]) |
| 262 | rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker") | 261 | rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker") |
| 263 | rig.chord("v") | 262 | rig.chord("v") |
| 264 | rig.chord("enter") | ||
| 265 | rig.picker("hosts") | 263 | rig.picker("hosts") |
| 266 | rig.choose(slow_target) | 264 | rig.choose(slow_target) |
| 267 | eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried") | 265 | eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried") |
test/native_tiling.py
| Old | New | ||
|---|---|---|---|
| @@ -176,7 +176,8 @@ class Rig: | |||
| 176 | def host(self, spelling, axis=None): | 176 | def host(self, spelling, axis=None): |
| 177 | if axis: | 177 | if axis: |
| 178 | self.chord(axis) | 178 | self.chord(axis) |
| 179 | self.chord("enter") | 179 | else: |
| 180 | self.chord("enter") | ||
| 180 | self.picker("hosts") | 181 | self.picker("hosts") |
| 181 | self.choose(spelling) | 182 | self.choose(spelling) |
| 182 | self.picker("sessions") | 183 | self.picker("sessions") |
| @@ -301,7 +302,9 @@ def normal_scenario(rig, axis): | |||
| 301 | rig.ok("retired staged-target flags are rejected; picker supplies insertion targets") | 302 | rig.ok("retired staged-target flags are rejected; picker supplies insertion targets") |
| 302 | before = rig.start_gui(first, second, "gui-" + axis) | 303 | before = rig.start_gui(first, second, "gui-" + axis) |
| 303 | rig.chord(axis) | 304 | rig.chord(axis) |
| 304 | armed = rig.state() | 305 | armed = rig.picker("hosts") |
| 306 | require(armed["pending"]["direction"] == ("beside" if axis == "v" else "stacked"), | ||
| 307 | "split chord opened picker with the wrong direction") | ||
| 305 | require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"], | 308 | require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"], |
| 306 | "arming split changed geometry") | 309 | "arming split changed geometry") |
| 307 | stats = rig.command("d", "stats", "--sock", second).stdout | 310 | stats = rig.command("d", "stats", "--sock", second).stdout |
| @@ -312,10 +315,13 @@ def normal_scenario(rig, axis): | |||
| 312 | require(rig.command("a", "status", "--sock", second, "--session", "right", | 315 | require(rig.command("a", "status", "--sock", second, "--session", "right", |
| 313 | "--timeout", "200", check=False).returncode != 0, | 316 | "--timeout", "200", check=False).returncode != 0, |
| 314 | "staged named session exists before insertion") | 317 | "staged named session exists before insertion") |
| 315 | rig.chord("escape") | 318 | rig.key("escape") |
| 316 | require(len(rig.state()["panes"]) == 1, "cancel inserted a pane") | 319 | cancelled = rig.wait_state(lambda s: s["picker"] is None) |
| 320 | require(len(cancelled["panes"]) == 1 and cancelled["pending"] == armed["pending"], | ||
| 321 | "closing host picker changed panes or forgot its armed split") | ||
| 322 | rig.key("escape") | ||
| 323 | rig.wait_state(lambda s: s["pending"] is None) | ||
| 317 | rig.chord(axis) | 324 | rig.chord(axis) |
| 318 | rig.chord("enter") | ||
| 319 | rig.picker("hosts") | 325 | rig.picker("hosts") |
| 320 | rig.choose(rig.targets.get(second, "--sock " + second)) | 326 | rig.choose(rig.targets.get(second, "--sock " + second)) |
| 321 | rig.picker("sessions") | 327 | rig.picker("sessions") |