detached db8c344

docs(panes): document consume and expel commands

commit
db8c344884c2fa73204b287e50811060e8586319
author
brice · · 2026-07-27 14:20 UTC
parent
5cc817b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +132 -1
M README.md +1 -1
@@ -50,7 +50,7 @@ No prefix key; everything is Alt.
5050 | Alt+h/j/k/l, Alt+Arrow | focus pane |
5151 | Alt+a | jump to a session needing attention |
5252
53-The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
53+The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
5454
5555 ## Remote attach
5656
@@ -50,7 +50,7 @@ No prefix key; everything is Alt.
50 | Alt+h/j/k/l, Alt+Arrow | focus pane |50 | Alt+h/j/k/l, Alt+Arrow | focus pane |
51 | Alt+a | jump to a session needing attention |51 | Alt+a | jump to a session needing attention |
52 52
53-The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.53+The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
54 54
55 ## Remote attach55 ## Remote attach
56 56
M docs/configuration.md +13 -0
@@ -34,6 +34,9 @@ focus-pane-left = alt+h
3434 focus-pane-right = alt+l
3535 focus-pane-up = alt+k
3636 focus-pane-down = alt+j
37+# Optional pane rearrangement actions are unbound by default.
38+# consume-or-expel-pane-left = alt+H
39+# consume-or-expel-pane-right = alt+L
3740 switch-tab-1 = alt+1
3841 # ... through switch-tab-9 = alt+9
3942
@@ -53,6 +56,8 @@ code.split-right = SPR
5356 code.split-left = SPL
5457 code.split-up = SPU
5558 code.split-down = SPD
59+code.consume-or-expel-pane-left = CEL
60+code.consume-or-expel-pane-right = CER
5661 code.stack-pane = STP
5762 code.toggle-stack = TST
5863 code.close-pane = CLP
@@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b
96101
97102 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.
98103
104+`alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev.
105+
106+## Pane consume or expel
107+
108+The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`.
109+
110+A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported.
111+
99112 ## Navigation overflow
100113
101114 Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.
@@ -34,6 +34,9 @@ focus-pane-left = alt+h
34 focus-pane-right = alt+l34 focus-pane-right = alt+l
35 focus-pane-up = alt+k35 focus-pane-up = alt+k
36 focus-pane-down = alt+j36 focus-pane-down = alt+j
37+# Optional pane rearrangement actions are unbound by default.
38+# consume-or-expel-pane-left = alt+H
39+# consume-or-expel-pane-right = alt+L
37 switch-tab-1 = alt+140 switch-tab-1 = alt+1
38 # ... through switch-tab-9 = alt+941 # ... through switch-tab-9 = alt+9
39 42
@@ -53,6 +56,8 @@ code.split-right = SPR
53 code.split-left = SPL56 code.split-left = SPL
54 code.split-up = SPU57 code.split-up = SPU
55 code.split-down = SPD58 code.split-down = SPD
59+code.consume-or-expel-pane-left = CEL
60+code.consume-or-expel-pane-right = CER
56 code.stack-pane = STP61 code.stack-pane = STP
57 code.toggle-stack = TST62 code.toggle-stack = TST
58 code.close-pane = CLP63 code.close-pane = CLP
@@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b
96 101
97 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.102 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.
98 103
104+`alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev.
105+
106+## Pane consume or expel
107+
108+The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`.
109+
110+A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported.
111+
99 ## Navigation overflow112 ## Navigation overflow
100 113
101 Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.114 Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.
M internal/usecase/daemon/snapshot_restore_test.go +118 -0
@@ -257,6 +257,124 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
257257 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")
258258 }
259259
260+func TestSnapshotCaptureRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) {
261+ initial := &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{
262+ {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40},
263+ {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{
264+ {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10},
265+ {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20},
266+ }},
267+ {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{
268+ {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7},
269+ {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3},
270+ }},
271+ }}, Focus: "pane-4"}
272+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, initial)
273+ h.session.createdAt = 77
274+ h.session.incarnation = domain.IncarnationID{7}
275+ h.tab.mu.Lock()
276+ h.tab.stableID = "tab-stable-rearranged"
277+ h.tab.nextPaneID = 6
278+ paneStableIDs := map[layout.PaneID]string{
279+ "pane-1": "pane-stable-1",
280+ "pane-2": "pane-stable-2",
281+ "pane-3": "pane-stable-3",
282+ "pane-4": "pane-stable-4",
283+ "pane-5": "pane-stable-5",
284+ }
285+ for id, stableID := range paneStableIDs {
286+ h.tab.panes[id].mu.Lock()
287+ h.tab.panes[id].stableID = stableID
288+ h.tab.panes[id].mu.Unlock()
289+ }
290+ h.tab.mu.Unlock()
291+
292+ operations := []struct {
293+ name string
294+ pane layout.PaneID
295+ direction layout.Direction
296+ }{
297+ {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right},
298+ {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right},
299+ }
300+ for _, operation := range operations {
301+ t.Run(operation.name, func(t *testing.T) {
302+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
303+ kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction,
304+ })
305+ require.NoError(t, err)
306+ })
307+ }
308+
309+ h.tab.mu.Lock()
310+ wantTree := h.tab.tree.Clone()
311+ h.tab.mu.Unlock()
312+ require.Equal(t, layout.Horizontal, wantTree.Root.Dir)
313+ require.Len(t, wantTree.Root.Children, 3)
314+ require.Equal(t, layout.Vertical, wantTree.Root.Children[0].Dir)
315+ require.Equal(t, layout.Leaf, wantTree.Root.Children[1].Kind)
316+ require.Equal(t, layout.Stack, wantTree.Root.Children[2].Kind)
317+ require.Equal(t, layout.PaneID("pane-5"), wantTree.Root.Children[2].Expanded)
318+ require.Equal(t, layout.PaneID("pane-3"), wantTree.Focus)
319+
320+ capture, ok := h.daemon.captureSnapshotState(h.session, 7)
321+ require.True(t, ok)
322+ publication, err := h.daemon.incrementalPublication(capture)
323+ require.NoError(t, err)
324+ objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
325+ for _, object := range publication.Objects {
326+ objects[object.Digest] = append([]byte(nil), object.Data...)
327+ }
328+ generation := ports.SnapshotGeneration{
329+ IncarnationID: publication.IncarnationID,
330+ Name: publication.Name,
331+ Generation: publication.Generation,
332+ Manifest: append([]byte(nil), publication.Manifest...),
333+ Objects: objects,
334+ }
335+ decoded, err := sessionFromGeneration(generation)
336+ require.NoError(t, err)
337+ require.Len(t, decoded.Tabs, 1)
338+ decodedTab := decoded.Tabs[0]
339+ require.Equal(t, "tab-stable-rearranged", decodedTab.StableID)
340+ require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus)
341+ require.Equal(t, wantTree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree")
342+ require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(decodedTab.Tree.Root))
343+ require.Equal(t, layout.PaneID("pane-5"), decodedTab.Tree.Root.Children[2].Expanded)
344+ decodedStableIDs := make(map[layout.PaneID]string, len(decodedTab.Panes))
345+ for _, restoredPane := range decodedTab.Panes {
346+ decodedStableIDs[restoredPane.ID] = restoredPane.StableID
347+ }
348+ require.Equal(t, paneStableIDs, decodedStableIDs)
349+
350+ restorePTY := &paneRearrangePTY{}
351+ restoreDaemon := newTestDaemon(t, newFactory(t, restorePTY), stubClock{})
352+ restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, decoded)
353+ require.NoError(t, err)
354+ require.Len(t, restoredTabs, 1)
355+ defer closeRestoredTabs(restoredTabs)
356+ restoredTab := restoredTabs[0]
357+ require.Equal(t, "tab-stable-rearranged", restoredTab.stableID)
358+ require.Equal(t, wantTree, restoredTab.tree, "runtime restore must preserve the complete rearranged tree")
359+ require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(restoredTab.tree.Root))
360+ require.Equal(t, layout.PaneID("pane-3"), restoredTab.tree.Focus)
361+ require.Equal(t, layout.PaneID("pane-5"), restoredTab.tree.Root.Children[2].Expanded)
362+ for id, stableID := range paneStableIDs {
363+ require.Equal(t, stableID, restoredTab.panes[id].stableID, "pane %s stable ID", id)
364+ }
365+}
366+
367+func snapshotLayoutWeights(node *layout.Node) []float64 {
368+ if node == nil {
369+ return nil
370+ }
371+ weights := []float64{node.Weight}
372+ for _, child := range node.Children {
373+ weights = append(weights, snapshotLayoutWeights(child)...)
374+ }
375+ return weights
376+}
377+
260378 func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
261379 snapshot := restoreAcceptanceSession(t, "restored")
262380 generation := acceptanceGeneration(t, snapshot, 9)
@@ -257,6 +257,124 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
257 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")257 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")
258 }258 }
259 259
260+func TestSnapshotCaptureRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) {
261+ initial := &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{
262+ {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40},
263+ {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{
264+ {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10},
265+ {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20},
266+ }},
267+ {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{
268+ {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7},
269+ {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3},
270+ }},
271+ }}, Focus: "pane-4"}
272+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, initial)
273+ h.session.createdAt = 77
274+ h.session.incarnation = domain.IncarnationID{7}
275+ h.tab.mu.Lock()
276+ h.tab.stableID = "tab-stable-rearranged"
277+ h.tab.nextPaneID = 6
278+ paneStableIDs := map[layout.PaneID]string{
279+ "pane-1": "pane-stable-1",
280+ "pane-2": "pane-stable-2",
281+ "pane-3": "pane-stable-3",
282+ "pane-4": "pane-stable-4",
283+ "pane-5": "pane-stable-5",
284+ }
285+ for id, stableID := range paneStableIDs {
286+ h.tab.panes[id].mu.Lock()
287+ h.tab.panes[id].stableID = stableID
288+ h.tab.panes[id].mu.Unlock()
289+ }
290+ h.tab.mu.Unlock()
291+
292+ operations := []struct {
293+ name string
294+ pane layout.PaneID
295+ direction layout.Direction
296+ }{
297+ {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right},
298+ {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right},
299+ }
300+ for _, operation := range operations {
301+ t.Run(operation.name, func(t *testing.T) {
302+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
303+ kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction,
304+ })
305+ require.NoError(t, err)
306+ })
307+ }
308+
309+ h.tab.mu.Lock()
310+ wantTree := h.tab.tree.Clone()
311+ h.tab.mu.Unlock()
312+ require.Equal(t, layout.Horizontal, wantTree.Root.Dir)
313+ require.Len(t, wantTree.Root.Children, 3)
314+ require.Equal(t, layout.Vertical, wantTree.Root.Children[0].Dir)
315+ require.Equal(t, layout.Leaf, wantTree.Root.Children[1].Kind)
316+ require.Equal(t, layout.Stack, wantTree.Root.Children[2].Kind)
317+ require.Equal(t, layout.PaneID("pane-5"), wantTree.Root.Children[2].Expanded)
318+ require.Equal(t, layout.PaneID("pane-3"), wantTree.Focus)
319+
320+ capture, ok := h.daemon.captureSnapshotState(h.session, 7)
321+ require.True(t, ok)
322+ publication, err := h.daemon.incrementalPublication(capture)
323+ require.NoError(t, err)
324+ objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
325+ for _, object := range publication.Objects {
326+ objects[object.Digest] = append([]byte(nil), object.Data...)
327+ }
328+ generation := ports.SnapshotGeneration{
329+ IncarnationID: publication.IncarnationID,
330+ Name: publication.Name,
331+ Generation: publication.Generation,
332+ Manifest: append([]byte(nil), publication.Manifest...),
333+ Objects: objects,
334+ }
335+ decoded, err := sessionFromGeneration(generation)
336+ require.NoError(t, err)
337+ require.Len(t, decoded.Tabs, 1)
338+ decodedTab := decoded.Tabs[0]
339+ require.Equal(t, "tab-stable-rearranged", decodedTab.StableID)
340+ require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus)
341+ require.Equal(t, wantTree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree")
342+ require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(decodedTab.Tree.Root))
343+ require.Equal(t, layout.PaneID("pane-5"), decodedTab.Tree.Root.Children[2].Expanded)
344+ decodedStableIDs := make(map[layout.PaneID]string, len(decodedTab.Panes))
345+ for _, restoredPane := range decodedTab.Panes {
346+ decodedStableIDs[restoredPane.ID] = restoredPane.StableID
347+ }
348+ require.Equal(t, paneStableIDs, decodedStableIDs)
349+
350+ restorePTY := &paneRearrangePTY{}
351+ restoreDaemon := newTestDaemon(t, newFactory(t, restorePTY), stubClock{})
352+ restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, decoded)
353+ require.NoError(t, err)
354+ require.Len(t, restoredTabs, 1)
355+ defer closeRestoredTabs(restoredTabs)
356+ restoredTab := restoredTabs[0]
357+ require.Equal(t, "tab-stable-rearranged", restoredTab.stableID)
358+ require.Equal(t, wantTree, restoredTab.tree, "runtime restore must preserve the complete rearranged tree")
359+ require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(restoredTab.tree.Root))
360+ require.Equal(t, layout.PaneID("pane-3"), restoredTab.tree.Focus)
361+ require.Equal(t, layout.PaneID("pane-5"), restoredTab.tree.Root.Children[2].Expanded)
362+ for id, stableID := range paneStableIDs {
363+ require.Equal(t, stableID, restoredTab.panes[id].stableID, "pane %s stable ID", id)
364+ }
365+}
366+
367+func snapshotLayoutWeights(node *layout.Node) []float64 {
368+ if node == nil {
369+ return nil
370+ }
371+ weights := []float64{node.Weight}
372+ for _, child := range node.Children {
373+ weights = append(weights, snapshotLayoutWeights(child)...)
374+ }
375+ return weights
376+}
377+
260 func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {378 func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
261 snapshot := restoreAcceptanceSession(t, "restored")379 snapshot := restoreAcceptanceSession(t, "restored")
262 generation := acceptanceGeneration(t, snapshot, 9)380 generation := acceptanceGeneration(t, snapshot, 9)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close