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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 70 | 70 | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))} | |
| 71 | 71 | sessionAttention := false | |
| 72 | 72 | for i, tb := range s.tabs { | |
| 73 | - | label := tabDisplayName(tb, i) | |
| 73 | + | label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitleLocked()) | |
| 74 | 74 | if tb.attention { | |
| 75 | 75 | label = attentionSuffix(label) | |
| 76 | 76 | 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 := false | 71 | 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 = true | 76 | sessionAttention = true | ||
M internal/usecase/daemon/picker_test.go +24 -0
| @@ -66,6 +66,30 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 66 | 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 | 93 | func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |
| 70 | 94 | p1, release1 := newBlockingPTY(t) | |
| 71 | 95 | 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 | |||
| 204 | 204 | if !r.header { | |
| 205 | 205 | label = " " + label | |
| 206 | 206 | } | |
| 207 | + | label = ui.TruncateText(label, rect.Width) | |
| 207 | 208 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 208 | 209 | ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style) | |
| 209 | 210 | } | |
| @@ -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 = " " + label | 205 | 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) { | |||
| 143 | 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 | 157 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |
| 147 | 158 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |
| 148 | 159 | 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')}}} | ||