detached 6633cd4

feat(picker): show focused pane titles and truncate long labels

Compose session picker tab labels the same way as the top bar
(name + focused pane title), and clip rendered list rows to the
list column width with an ellipsis so long composed labels don't
overflow into the preview pane.
commit
6633cd4f84b640690601bd147b61f1a2ae65a833
author
brice · · 2026-07-11 08:13 UTC
parent
857767d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +37 -1
M internal/usecase/daemon/picker.go +1 -1
@@ -70,7 +70,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7070 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
7171 sessionAttention := false
7272 for i, tb := range s.tabs {
73- label := tabDisplayName(tb, i)
73+ label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitleLocked())
7474 if tb.attention {
7575 label = attentionSuffix(label)
7676 sessionAttention = true
@@ -70,7 +70,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
70 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}70 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
71 sessionAttention := false71 sessionAttention := false
72 for i, tb := range s.tabs {72 for i, tb := range s.tabs {
73- label := tabDisplayName(tb, i)73+ label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitleLocked())
74 if tb.attention {74 if tb.attention {
75 label = attentionSuffix(label)75 label = attentionSuffix(label)
76 sessionAttention = true76 sessionAttention = true
M internal/usecase/daemon/picker_test.go +24 -0
@@ -66,6 +66,30 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
6666 require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)
6767 }
6868
69+func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
70+ p1, releasePTY1 := newBlockingPTY(t)
71+ p2, releasePTY2 := newBlockingPTY(t)
72+ defer releasePTY1()
73+ defer releasePTY2()
74+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
75+ sess.tabs[1].name = "logs"
76+
77+ pane0 := sess.tabs[0].focusedPane()
78+ pane0.mu.Lock()
79+ pane0.title.processName = "vim"
80+ pane0.title.processNameValid = true
81+ pane0.mu.Unlock()
82+
83+ sess.mu.Lock()
84+ sess.tabs[1].attention = true
85+ sess.mu.Unlock()
86+
87+ views, _ := d.pickerViews(sess)
88+
89+ require.Len(t, views, 1)
90+ require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs)
91+}
92+
6993 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
7094 p1, release1 := newBlockingPTY(t)
7195 p2, release2 := newBlockingPTY(t)
@@ -66,6 +66,30 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
66 require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)66 require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)
67 }67 }
68 68
69+func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
70+ p1, releasePTY1 := newBlockingPTY(t)
71+ p2, releasePTY2 := newBlockingPTY(t)
72+ defer releasePTY1()
73+ defer releasePTY2()
74+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
75+ sess.tabs[1].name = "logs"
76+
77+ pane0 := sess.tabs[0].focusedPane()
78+ pane0.mu.Lock()
79+ pane0.title.processName = "vim"
80+ pane0.title.processNameValid = true
81+ pane0.mu.Unlock()
82+
83+ sess.mu.Lock()
84+ sess.tabs[1].attention = true
85+ sess.mu.Unlock()
86+
87+ views, _ := d.pickerViews(sess)
88+
89+ require.Len(t, views, 1)
90+ require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs)
91+}
92+
69 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {93 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
70 p1, release1 := newBlockingPTY(t)94 p1, release1 := newBlockingPTY(t)
71 p2, release2 := newBlockingPTY(t)95 p2, release2 := newBlockingPTY(t)
M internal/usecase/picker/model.go +1 -0
@@ -204,6 +204,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection ren
204204 if !r.header {
205205 label = " " + label
206206 }
207+ label = ui.TruncateText(label, rect.Width)
207208 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
208209 ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style)
209210 }
@@ -204,6 +204,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection ren
204 if !r.header {204 if !r.header {
205 label = " " + label205 label = " " + label
206 }206 }
207+ label = ui.TruncateText(label, rect.Width)
207 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})208 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
208 ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style)209 ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style)
209 }210 }
M internal/usecase/picker/model_test.go +11 -0
@@ -143,6 +143,17 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
143143 require.True(t, frame.At(0, 3).Style.Inverse)
144144 }
145145
146+func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
147+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a-really-long-focused-pane-tab-label"}, Active: 0}}, "s1", 0)
148+
149+ frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
150+
151+ layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
152+ require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
153+ require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge")
154+ require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing should be drawn past the list width")
155+}
156+
146157 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
147158 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
148159 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
@@ -143,6 +143,17 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
143 require.True(t, frame.At(0, 3).Style.Inverse)143 require.True(t, frame.At(0, 3).Style.Inverse)
144 }144 }
145 145
146+func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
147+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a-really-long-focused-pane-tab-label"}, Active: 0}}, "s1", 0)
148+
149+ frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{})
150+
151+ layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5})
152+ require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column")
153+ require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge")
154+ require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing should be drawn past the list width")
155+}
156+
146 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {157 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
147 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)158 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
148 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}159 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}

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