detached 58a748a

fix(panes): align cursor with focused pane

commit
58a748aa6eb0d8b019fb6b5aa9c3dad338fdab35
author
brice · · 2026-07-04 08:14 UTC
parent
c4a18a9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +74 -6
M internal/usecase/daemon/render.go +19 -6
@@ -355,8 +355,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
355355 frame, damage = composePromptClientFrame(promptModel, frame, styles)
356356 ac.promptMu.Unlock()
357357 }
358+ cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
358359 p.mu.Lock()
359- desiredCursor := desiredCursorOut(p.screen, copyActive || pickerActive || paletteActive || promptActive)
360+ desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || copyActive || pickerActive || paletteActive || promptActive)
360361 p.mu.Unlock()
361362 data, err := ac.rend.Draw(frame, damage)
362363 var cursorTail []byte
@@ -388,17 +389,29 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
388389 }
389390 }
390391
392+func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) {
393+ if !layoutSnap.ok {
394+ return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0
395+ }
396+ for _, pl := range layoutSnap.placements {
397+ if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
398+ return pl.Content, true
399+ }
400+ }
401+ return domain.Rect{}, false
402+}
403+
391404 // desiredCursorOut computes the terminal cursor state that should be shown to
392-// the client for the current tab and overlay mode.
393-func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
394- if copyActive || !s.CursorVisible() {
405+// the client for the current pane placement and overlay mode.
406+func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut {
407+ if hide || !s.CursorVisible() {
395408 return cursorOut{hidden: true}
396409 }
397410 style, ok := s.CursorStyle()
398411 if !ok {
399412 style = 5
400413 }
401- return cursorOut{row: s.CursorRow() + 1, col: s.CursorCol(), style: style, hasStyle: true}
414+ return cursorOut{row: content.Y + s.CursorRow() + 1, col: content.X + s.CursorCol(), style: style, hasStyle: true}
402415 }
403416
404417 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
@@ -537,7 +550,7 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l
537550 }
538551 placements = []layout.Placement{{ID: fallback.id, Content: area}}
539552 }
540- drawDividers(frame, root, area, newThemeStyles(theme).border)
553+ drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
541554 var damage []renderer.Damage
542555 for _, pl := range placements {
543556 p := tb.panes[pl.ID]
@@ -355,8 +355,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
355 frame, damage = composePromptClientFrame(promptModel, frame, styles)355 frame, damage = composePromptClientFrame(promptModel, frame, styles)
356 ac.promptMu.Unlock()356 ac.promptMu.Unlock()
357 }357 }
358+ cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
358 p.mu.Lock()359 p.mu.Lock()
359- desiredCursor := desiredCursorOut(p.screen, copyActive || pickerActive || paletteActive || promptActive)360+ desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || copyActive || pickerActive || paletteActive || promptActive)
360 p.mu.Unlock()361 p.mu.Unlock()
361 data, err := ac.rend.Draw(frame, damage)362 data, err := ac.rend.Draw(frame, damage)
362 var cursorTail []byte363 var cursorTail []byte
@@ -388,17 +389,29 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
388 }389 }
389 }390 }
390 391
392+func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) {
393+ if !layoutSnap.ok {
394+ return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0
395+ }
396+ for _, pl := range layoutSnap.placements {
397+ if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
398+ return pl.Content, true
399+ }
400+ }
401+ return domain.Rect{}, false
402+}
403+
391 // desiredCursorOut computes the terminal cursor state that should be shown to404 // desiredCursorOut computes the terminal cursor state that should be shown to
392-// the client for the current tab and overlay mode.405+// the client for the current pane placement and overlay mode.
393-func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {406+func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut {
394- if copyActive || !s.CursorVisible() {407+ if hide || !s.CursorVisible() {
395 return cursorOut{hidden: true}408 return cursorOut{hidden: true}
396 }409 }
397 style, ok := s.CursorStyle()410 style, ok := s.CursorStyle()
398 if !ok {411 if !ok {
399 style = 5412 style = 5
400 }413 }
401- return cursorOut{row: s.CursorRow() + 1, col: s.CursorCol(), style: style, hasStyle: true}414+ return cursorOut{row: content.Y + s.CursorRow() + 1, col: content.X + s.CursorCol(), style: style, hasStyle: true}
402 }415 }
403 416
404 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {417 func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
@@ -537,7 +550,7 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l
537 }550 }
538 placements = []layout.Placement{{ID: fallback.id, Content: area}}551 placements = []layout.Placement{{ID: fallback.id, Content: area}}
539 }552 }
540- drawDividers(frame, root, area, newThemeStyles(theme).border)553+ drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
541 var damage []renderer.Damage554 var damage []renderer.Damage
542 for _, pl := range placements {555 for _, pl := range placements {
543 p := tb.panes[pl.ID]556 p := tb.panes[pl.ID]
M internal/usecase/daemon/render_test.go +55 -0
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "io"
88 "log/slog"
9+ "strconv"
910 "strings"
1011 "sync/atomic"
1112 "testing"
@@ -838,3 +839,57 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
838839 data = mustOutputData(t, sends)
839840 require.Contains(t, string(data), "\x1b[?25l")
840841 }
842+
843+func TestCursorTailUsesFocusedPanePlacement(t *testing.T) {
844+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
845+ win := sess.tabs[0]
846+ win.size = domain.Size{Cols: 80, Rows: 23}
847+ left := win.focusedPane()
848+ right := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})
849+ win.panes[right.id] = right
850+ win.tree = layout.NewTree(left.id)
851+ require.NoError(t, win.tree.Split(left.id, layout.Right, true, right.id, domain.Rect{Width: 80, Height: 23}))
852+ win.tree.Focus = right.id
853+ right.screen.Write([]byte("\x1b[2;3H"))
854+ placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23})
855+ require.True(t, ok)
856+ rightContent := placementContent(placements, right.id)
857+
858+ d.paint(sess, ac, true)
859+ data := mustOutputData(t, sends)
860+ want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1)
861+ require.Contains(t, string(data), want)
862+}
863+
864+func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) {
865+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
866+ win := sess.tabs[0]
867+ win.size = domain.Size{Cols: 80, Rows: 23}
868+ one := win.focusedPane()
869+ two := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 20})
870+ three := newPane("pane-3", nil, domain.Size{Cols: 80, Rows: 20})
871+ win.panes[two.id] = two
872+ win.panes[three.id] = three
873+ win.tree = &layout.Tree{
874+ Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{
875+ layout.NewLeaf(one.id),
876+ layout.NewLeaf(two.id),
877+ layout.NewLeaf(three.id),
878+ }, Expanded: two.id},
879+ Focus: two.id,
880+ }
881+ two.screen.Write([]byte("\x1b[2;3H"))
882+ placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23})
883+ require.True(t, ok)
884+ twoContent := placementContent(placements, two.id)
885+ require.Greater(t, twoContent.Y, 0, "stack title bars should offset content")
886+
887+ d.paint(sess, ac, true)
888+ data := mustOutputData(t, sends)
889+ want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1)
890+ require.Contains(t, string(data), want)
891+}
892+
893+func cursorCSI(row, col int) string {
894+ return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"
895+}
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9+ "strconv"
9 "strings"10 "strings"
10 "sync/atomic"11 "sync/atomic"
11 "testing"12 "testing"
@@ -838,3 +839,57 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
838 data = mustOutputData(t, sends)839 data = mustOutputData(t, sends)
839 require.Contains(t, string(data), "\x1b[?25l")840 require.Contains(t, string(data), "\x1b[?25l")
840 }841 }
842+
843+func TestCursorTailUsesFocusedPanePlacement(t *testing.T) {
844+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
845+ win := sess.tabs[0]
846+ win.size = domain.Size{Cols: 80, Rows: 23}
847+ left := win.focusedPane()
848+ right := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})
849+ win.panes[right.id] = right
850+ win.tree = layout.NewTree(left.id)
851+ require.NoError(t, win.tree.Split(left.id, layout.Right, true, right.id, domain.Rect{Width: 80, Height: 23}))
852+ win.tree.Focus = right.id
853+ right.screen.Write([]byte("\x1b[2;3H"))
854+ placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23})
855+ require.True(t, ok)
856+ rightContent := placementContent(placements, right.id)
857+
858+ d.paint(sess, ac, true)
859+ data := mustOutputData(t, sends)
860+ want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1)
861+ require.Contains(t, string(data), want)
862+}
863+
864+func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) {
865+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
866+ win := sess.tabs[0]
867+ win.size = domain.Size{Cols: 80, Rows: 23}
868+ one := win.focusedPane()
869+ two := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 20})
870+ three := newPane("pane-3", nil, domain.Size{Cols: 80, Rows: 20})
871+ win.panes[two.id] = two
872+ win.panes[three.id] = three
873+ win.tree = &layout.Tree{
874+ Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{
875+ layout.NewLeaf(one.id),
876+ layout.NewLeaf(two.id),
877+ layout.NewLeaf(three.id),
878+ }, Expanded: two.id},
879+ Focus: two.id,
880+ }
881+ two.screen.Write([]byte("\x1b[2;3H"))
882+ placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23})
883+ require.True(t, ok)
884+ twoContent := placementContent(placements, two.id)
885+ require.Greater(t, twoContent.Y, 0, "stack title bars should offset content")
886+
887+ d.paint(sess, ac, true)
888+ data := mustOutputData(t, sends)
889+ want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1)
890+ require.Contains(t, string(data), want)
891+}
892+
893+func cursorCSI(row, col int) string {
894+ return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"
895+}

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