tabs: mark a pane here, pull it into any tab there (C40) — and a rename caret that shoved the name around - #156
Merged
Merged
Conversation
C28's `Alt+Shift+i`/`Alt+Shift+m` carry the focused pane one tab at a time. On a three-tab workspace that is the whole feature; on a ten-tab one it is a walk, and the walk is not free — every step re-parents the pane into a tab it is not staying in, splitting and re-splitting layouts on the way past. C40 is the same verb with an arbitrary destination. - `Alt+Shift+x` marks the focused pane. Marking the marked pane unmarks. - `Alt+Shift+v` pulls the marked pane into whatever tab is active *then*, landing it exactly as C28 lands one: same host, same widest-way split, same refusal below C25's floor, focus following the pane, source tab removed if the pane was its last. **Nothing moves on the mark, and that is not a compromise.** A pane is a live process; it cannot sit in a clipboard between two tabs. This is tmux's `select-pane -m`, not a cut — the mark is a *name* roost holds, and the pull is the whole move, atomic, with C28's refusals intact. `carry_pane_to_tab` is now shared by both verbs rather than copied. The interesting half of that function is the *refusal* — the fit check before anything is removed, the emptied-source rule, the destination index fixup when the source tab goes with it — and a second copy would have been a second set of edge cases to keep in step. It answers whether the pane actually moved, which is what decides the mark's fate. **The mark is visible or it does not exist.** By the time it matters the marked pane is usually in another tab, so a mark with no chrome would be a mode the user cannot see. While one is pending, C9's hint bar *leads* with `Alt+Shift+v pull marked pane` — leading because pairs drop whole from the right, and this is the one that has to survive a narrow terminal. The mark flash names the pull chord, resolved (C34), never spelled. The mark clears on every outcome that resolves the gesture — the pull, "already in this tab", a marked pane that has since gone — and is *kept* when the destination refuses for want of room, the one refusal the user can fix and retry. It also clears when the marked pane closes: `next_pane_id` is max+1, so pane ids are recycled, and a stale mark would not merely be dead, it would eventually name a stranger. Same hygiene as `raw` / `tab_focus`, in the same place, for the same reason. **Only the shifted forms are taken.** `x`/`v` come from §8's free pool, where they were reserved for clipboard vocabulary — this is that vocabulary, on panes rather than on text. Bare `Alt+x`/`Alt+v` stay unbound, so emacs' `M-x` and `M-v` keep reaching the pane through U5, which is the same live-regression argument that struck `b`/`d` out of the pool. And the shifted forms are where the verb belongs anyway: C28/C33's rule is that a shifted chord carries the *pane* where its unshifted sibling carries *you*. One bug found while testing, in the shared path rather than the new one: pulling into the tab you are standing on picked its host through `tab_focus_target`, and `remember_tab_focus` only stamps on the way *out* of a tab — so the pull split whatever pane the tree happened to list first instead of the one under your eyes. `pull_pane` stamps the live focus first; the pane you are on is where a pull lands, exactly like `Alt+n`. Six unit tests, plus both verbs added to the layout fuzzer's action table — the mark is the one pane-carrying verb whose *source* tab is arbitrary rather than the active one, so it reaches tree/map states the walk-based move cannot. 200 seeds x 400 steps, invariant clean.
Reported from live use: in the rename dialog, moving the cursor back "starts to displace the characters". It did, exactly. U16 put the `▏` caret *at* the insertion point instead of always at the end, and it did that by **inserting the glyph into the string**. A glyph costs a real cell, so the field was one column wider than its own text and everything after the point sat one column right of where it belongs. Walking the point left walked that displacement left with it: pressing a key that is supposed to move only the cursor visibly shoved the tail of the name around. With the point at the end — where it always was before U16 — nothing followed the caret, which is why this shipped unnoticed. The caret now rides the character at the point (`theme::attention()`, reverse and nothing else, §2's own attention idiom) rather than sitting before it. That is what a terminal's block cursor is, and it is the reason terminals draw cursors that way: a reversed cell costs no columns, so the text cannot move. Past the last character there is nothing to reverse, so the caret is a reversed space — appended, which displaces nothing. `rename_field` returns spans rather than a `String` now, since one cell of the field carries its own style. Its three other callers come along for free and needed the same fix: the C32 pane editor's name row and note rows, and C36's broadcast composer, all of which have a movable point. The name row's edge-to-edge underline moves to the row's own style so the caret's reversal patches on top of it instead of replacing it, and its padding is measured with `spans_width` — display columns (D1), so a wide glyph in a name still pads correctly. The append-only carets are deliberately left as glyphs: the P21 search prompt and the picker/roster type-aheads have no point to move, so nothing there can be displaced by a caret that takes a cell. Verified through the real binary, not just the unit seam. Typing `abcdef` into the rename dialog and pressing Left three times leaves `mainabcdef` in place, byte for byte, where it used to become `mainabc▏def` — a column wider, with `def` displaced — and typing `Z` there gives `mainabcZdef`. The new unit gate pins the property rather than the picture: the field's width must not depend on where the point is, at any of the five positions in a four-character buffer.
navbytes
added a commit
that referenced
this pull request
Aug 27, 2026
…e request (#157) One change landed since v0.1.11 (#156), and it is two things: **C40 — mark a pane here, pull it into any tab there.** `Alt+Shift+x` marks the focused pane, `Alt+Shift+v` pulls it into whatever tab is active then. C28's `Alt+Shift+i`/`Alt+Shift+m` already carried a pane between *neighbouring* tabs; on a ten-tab workspace that is a walk, and the walk re-parents the pane into every tab it passes through. This is the same verb with an arbitrary destination — same host, same widest-way split, same refusal below C25's floor, focus following the pane. Nothing moves on the mark: a pane is a live process and cannot sit in a clipboard between two tabs, so this is tmux's `select-pane -m`, not a cut. While a mark is pending the hint bar leads with the pull chord — the marked pane is usually in another tab, so without that it would be a mode you cannot see. Bare `Alt+x`/`Alt+v` stay unbound, so emacs' `M-x` and `M-v` keep reaching the pane through U5. Found in the shared path while testing it: pulling into the tab you are standing on picked its host from `tab_focus`, which is only stamped on the way *out* of a tab — so it split whatever pane the tree listed first rather than the one under your eyes. **The rename caret displaced the text it moved through.** U16's `▏` was inserted into the string, so it cost a real cell: everything after the point sat one column right, and walking the point left visibly shoved the tail of the name around. It now rides the character at the point as a reversed cell — what a terminal's own block cursor is — which costs no columns, so the text cannot move. The C32 pane editor and C36 broadcast composer shared the helper and had the same bug; they are fixed with it. The landing page's keymap gains the pair and its count goes 18 -> 19. Touching `.github/release-request` dispatches Release, which builds four targets, creates the `v0.1.12` tag from Cargo.toml itself, and publishes with SHA256SUMS.txt. Verified before cutting: 981 unit tests and all 35 integration binaries green, `cargo +1.96.1 clippy --all-targets -- -D warnings` and `fmt --check` clean. Note for after the merge: `HOMEBREW_TAP_TOKEN` is still unset, so release.yml will skip the tap sync again (it did for v0.1.10 and v0.1.11). The formula needs the usual hand-sync — `RENDER_ONLY=1 RELEASE_TAG=v0.1.12 scripts/update-homebrew-formula.sh`, PR'd to navbytes/homebrew-tap.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent changes, one commit each.
1. C40 — mark and pull (
Alt+Shift+x/Alt+Shift+v)C28's
Alt+Shift+i/Alt+Shift+mcarry the focused pane one tab at a time. On a three-tab workspace that is the whole feature; on a ten-tab one it is a walk, and the walk re-parents the pane into every tab it passes through. C40 is the same verb with an arbitrary destination:Alt+Shift+xmarks the focused pane (marking the marked one unmarks).Alt+Shift+vpulls it into whatever tab is active then — same host, same widest-way split, same refusal below C25's floor, focus following the pane, source tab removed if it was its last.Nothing moves on the mark, and that is deliberate. A pane is a live process; it cannot sit in a clipboard between two tabs. This is tmux's
select-pane -m, not a cut.carry_pane_to_tabis now shared by both verbs rather than copied — the interesting half is the refusal (fit check before anything is removed, emptied-source rule, destination index fixup), and a second copy would be a second set of edge cases.Chrome: while a mark is pending, C9's hint bar leads with
Alt+Shift+v pull marked pane— the marked pane is usually in another tab, so without it this is a mode you cannot see. Pairs drop whole from the right, hence leading.Mark lifecycle: cleared by the pull, by "already in this tab", by a pane that has since gone; kept on a "no room" refusal, the one outcome you can fix and retry. Also cleared when the marked pane closes —
next_pane_idis max+1, so ids are recycled and a stale mark would eventually name a stranger.Chord choice:
x/vcome from §8's free pool, reserved there for clipboard vocabulary — this is that vocabulary, on panes rather than text. Only the shifted forms are taken, so bareAlt+x/Alt+vkeep reaching the pane through U5 (emacsM-x/M-v) — the same argument that struckb/dout of the pool.One bug found in the shared path while testing: pulling into the tab you are standing on picked its host through
tab_focus_target, whichremember_tab_focusonly stamps on the way out of a tab — so the pull split whatever pane the tree listed first, not the one under your eyes.pull_panestamps live focus first.Docs: C40 contract + key-table row 13d + the spent
v/xreservation inDESIGN-ui.md, one row in the README keymap.2. The rename caret displaced the text it moved through
Reported from live use: moving the cursor back in the rename dialog "starts to displace the characters". It did. U16's
▏caret was inserted into the string, so it cost a real cell — the field was one column wider than its text, and everything after the point sat one column right. Walking the point left walked that displacement with it.The caret now rides the character at the point as a reversed cell (
theme::attention()— what a terminal's own block cursor is), so it costs no columns and the text cannot move. At end-of-buffer it is a reversed space, appended, displacing nothing.rename_fieldreturns spans rather than aString, which fixes its three other callers for free — they had the same bug: the C32 pane editor's name row and note rows, and C36's broadcast composer. The append-only carets (P21 search prompt, picker/roster type-aheads) keep the glyph on purpose: no point to move, nothing to displace.Review notes
hint_pairsgained amarked: boolparameter; the render test shim passesfalse, so every existing expectation is unchanged.src/ui/**design-supervisor audit CLAUDE.md asks for has not been run on this branch.Verification
981 unit tests + all 35 integration binaries green;
cargo +1.96.1 clippy --all-targets -- -D warningsandfmt --checkclean.The caret fix was verified through the real binary, not just the unit seam: typing
abcdefinto the rename dialog and pressing Left three times leavesmainabcdefin place where it used to becomemainabc▏def, and typingZthere givesmainabcZdef.A third reported bug — text escaping the floating pane's border — is not in this PR. Driven through the real binary at 120x40 and at the 80x24 floor, filled edge to edge, with ZWJ emoji / CJK / powerline glyphs, and across hide-resize-show, the float's output stayed inside its border every time;
blit_screenclips toinner.width.min(cols)/inner.height.min(rows), so roost cannot paint a pane's text outside its own box. Parked until it reproduces.