detached 8eb2673

Merge pull request #148 from bnema/feat/picker-ordering

feat(picker): MRU session ordering, grouped sort mode, and post-kill selection
commit
8eb26739735b64e2bc157921b81db39aaf1eddfc
author
bnema · · 2026-07-29 15:55 UTC
committer
GitHub · · 2026-07-29 15:55 UTC
merge of
6a69f26 + 1521b5b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +517 -58
M internal/usecase/daemon/daemon.go +3 -0
@@ -79,6 +79,9 @@ type Daemon struct {
7979 // reusing a lifecycle identity.
8080 lastAllocatedCreatedAt int64
8181 mruSeq atomic.Uint64
82+ // pickerSort is the picker ordering mode for this daemon's lifetime
83+ // (pickerSortMode); not persisted across restarts.
84+ pickerSort atomic.Uint32
8285 // closing marks that shutdown has irreversibly begun. It is set under mu,
8386 // atomically with the event that makes shutdown inevitable (the registry
8487 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -79,6 +79,9 @@ type Daemon struct {
79 // reusing a lifecycle identity.79 // reusing a lifecycle identity.
80 lastAllocatedCreatedAt int6480 lastAllocatedCreatedAt int64
81 mruSeq atomic.Uint6481 mruSeq atomic.Uint64
82+ // pickerSort is the picker ordering mode for this daemon's lifetime
83+ // (pickerSortMode); not persisted across restarts.
84+ pickerSort atomic.Uint32
82 // closing marks that shutdown has irreversibly begun. It is set under mu,85 // closing marks that shutdown has irreversibly begun. It is set under mu,
83 // atomically with the event that makes shutdown inevitable (the registry86 // atomically with the event that makes shutdown inevitable (the registry
84 // emptying in killSession, or shutdownAll starting), and checked by route87 // emptying in killSession, or shutdownAll starting), and checked by route
M internal/usecase/daemon/move_picker.go +17 -2
@@ -137,7 +137,16 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
137137 return sess, sess.tabs[target.TabIndex]
138138 }
139139
140-func (d *Daemon) refreshPicker(ac *attachedClient) {
140+type pickerRefreshOptions struct {
141+ // preserveSelection keeps the currently selected target selected across the
142+ // rebuild even for navigate intent (used by the sort toggle).
143+ preserveSelection bool
144+ // nearestRow, when >= 0, selects the row occupying this index after the
145+ // rebuild (used after deletes). -1 disables it.
146+ nearestRow int
147+}
148+
149+func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
141150 sess := ac.currentSession()
142151 if sess == nil {
143152 return
@@ -150,7 +159,7 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
150159 }
151160 intent, source := rt.pickerIntent, rt.pickerSource
152161 current := picker.SourceFilter{}
153- if intent != pickerNavigate {
162+ if intent != pickerNavigate || opts.preserveSelection {
154163 selected, _ := rt.picker.Selected()
155164 current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
156165 }
@@ -162,10 +171,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
162171 return
163172 }
164173 }
174+ // Navigate-only: move intents keep the selection-or-close logic above
175+ // authoritative, so a stale row index can never override it.
176+ if intent == pickerNavigate && opts.nearestRow >= 0 {
177+ model.SelectNearestRow(opts.nearestRow)
178+ }
165179 rt.pickerMu.Lock()
166180 updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
167181 if updated {
168182 rt.picker = model
183+ rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
169184 }
170185 rt.pickerMu.Unlock()
171186 if updated {
@@ -137,7 +137,16 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
137 return sess, sess.tabs[target.TabIndex]137 return sess, sess.tabs[target.TabIndex]
138 }138 }
139 139
140-func (d *Daemon) refreshPicker(ac *attachedClient) {140+type pickerRefreshOptions struct {
141+ // preserveSelection keeps the currently selected target selected across the
142+ // rebuild even for navigate intent (used by the sort toggle).
143+ preserveSelection bool
144+ // nearestRow, when >= 0, selects the row occupying this index after the
145+ // rebuild (used after deletes). -1 disables it.
146+ nearestRow int
147+}
148+
149+func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
141 sess := ac.currentSession()150 sess := ac.currentSession()
142 if sess == nil {151 if sess == nil {
143 return152 return
@@ -150,7 +159,7 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
150 }159 }
151 intent, source := rt.pickerIntent, rt.pickerSource160 intent, source := rt.pickerIntent, rt.pickerSource
152 current := picker.SourceFilter{}161 current := picker.SourceFilter{}
153- if intent != pickerNavigate {162+ if intent != pickerNavigate || opts.preserveSelection {
154 selected, _ := rt.picker.Selected()163 selected, _ := rt.picker.Selected()
155 current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}164 current = picker.SourceFilter{Session: selected.Session, Incarnation: selected.Incarnation, TabID: selected.TabID}
156 }165 }
@@ -162,10 +171,16 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
162 return171 return
163 }172 }
164 }173 }
174+ // Navigate-only: move intents keep the selection-or-close logic above
175+ // authoritative, so a stale row index can never override it.
176+ if intent == pickerNavigate && opts.nearestRow >= 0 {
177+ model.SelectNearestRow(opts.nearestRow)
178+ }
165 rt.pickerMu.Lock()179 rt.pickerMu.Lock()
166 updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source180 updated := rt.picker != nil && rt.pickerIntent == intent && rt.pickerSource == source
167 if updated {181 if updated {
168 rt.picker = model182 rt.picker = model
183+ rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
169 }184 }
170 rt.pickerMu.Unlock()185 rt.pickerMu.Unlock()
171 if updated {186 if updated {
M internal/usecase/daemon/overlay_runtime.go +3 -0
@@ -18,6 +18,7 @@ type overlayRuntime struct {
1818
1919 pickerMu sync.Mutex
2020 picker *picker.Model
21+ pickerTitle string
2122 pickerIntent pickerIntent
2223 pickerSource moveSourceLocator
2324 pickerPreview *tab
@@ -381,6 +382,7 @@ type overlayRenderSnapshot struct {
381382
382383 pickerActive bool
383384 pickerModel *picker.Model
385+ pickerTitle string
384386 previewTab *tab
385387
386388 noticesOverlayActive bool
@@ -451,6 +453,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
451453 rt.pickerMu.Lock()
452454 snap.pickerActive = rt.picker != nil
453455 snap.pickerModel = rt.picker.Clone()
456+ snap.pickerTitle = rt.pickerTitle
454457 snap.previewTab = rt.pickerPreview
455458 rt.pickerMu.Unlock()
456459
@@ -18,6 +18,7 @@ type overlayRuntime struct {
18 18
19 pickerMu sync.Mutex19 pickerMu sync.Mutex
20 picker *picker.Model20 picker *picker.Model
21+ pickerTitle string
21 pickerIntent pickerIntent22 pickerIntent pickerIntent
22 pickerSource moveSourceLocator23 pickerSource moveSourceLocator
23 pickerPreview *tab24 pickerPreview *tab
@@ -381,6 +382,7 @@ type overlayRenderSnapshot struct {
381 382
382 pickerActive bool383 pickerActive bool
383 pickerModel *picker.Model384 pickerModel *picker.Model
385+ pickerTitle string
384 previewTab *tab386 previewTab *tab
385 387
386 noticesOverlayActive bool388 noticesOverlayActive bool
@@ -451,6 +453,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
451 rt.pickerMu.Lock()453 rt.pickerMu.Lock()
452 snap.pickerActive = rt.picker != nil454 snap.pickerActive = rt.picker != nil
453 snap.pickerModel = rt.picker.Clone()455 snap.pickerModel = rt.picker.Clone()
456+ snap.pickerTitle = rt.pickerTitle
454 snap.previewTab = rt.pickerPreview457 snap.previewTab = rt.pickerPreview
455 rt.pickerMu.Unlock()458 rt.pickerMu.Unlock()
456 459
M internal/usecase/daemon/picker.go +83 -4
@@ -16,6 +16,38 @@ import (
1616
1717 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
1818
19+// pickerSortMode orders the picker's live sessions. It lives for the daemon's
20+// lifetime only and is never persisted.
21+type pickerSortMode uint32
22+
23+const (
24+ pickerSortRecent pickerSortMode = iota
25+ pickerSortGrouped
26+)
27+
28+func pickerTitle(mode pickerSortMode) string {
29+ if mode == pickerSortGrouped {
30+ return " Sessions · grouped "
31+ }
32+ return " Sessions · recent "
33+}
34+
35+// togglePickerSort flips the daemon's picker sort mode between the two known
36+// modes with a CompareAndSwap loop, so a concurrent double-press can't lose
37+// an update the way a bare Load-then-Store XOR can.
38+func (d *Daemon) togglePickerSort() {
39+ for {
40+ cur := pickerSortMode(d.pickerSort.Load())
41+ next := pickerSortRecent
42+ if cur == pickerSortRecent {
43+ next = pickerSortGrouped
44+ }
45+ if d.pickerSort.CompareAndSwap(uint32(cur), uint32(next)) {
46+ return
47+ }
48+ }
49+}
50+
1951 // enterPicker preserves the existing navigation entry point. Navigation always
2052 // publishes its model, including an empty one; only move entry can fail for a
2153 // missing destination.
@@ -32,6 +64,7 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
3264 rt.pickerPreviewSession = nil
3365 rt.pickerPreview = nil
3466 rt.picker = model
67+ rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
3568 rt.pickerIntent = intent
3669 rt.pickerSource = source
3770 rt.pickerPending = nil
@@ -54,8 +87,42 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
5487 }
5588 }
5689 d.mu.Unlock()
57- sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
58- sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
90+ // Snapshot mruAt, name, and ephemeral once: comparators must not observe
91+ // concurrent touchMRU updates or a renameSession mutation (which mutates
92+ // name/ephemeral under sess.mu) mid-sort.
93+ type pickerSortSnapshot struct {
94+ mruAt uint64
95+ name string
96+ ephemeral bool
97+ }
98+ snap := make(map[*session]pickerSortSnapshot, len(sessions))
99+ for _, s := range sessions {
100+ mruAt := s.mruAt.Load()
101+ s.mu.Lock()
102+ name := s.name
103+ ephemeral := s.ephemeral
104+ s.mu.Unlock()
105+ snap[s] = pickerSortSnapshot{mruAt: mruAt, name: name, ephemeral: ephemeral}
106+ }
107+ sort.Slice(sessions, func(i, j int) bool {
108+ if snap[sessions[i]].mruAt != snap[sessions[j]].mruAt {
109+ return snap[sessions[i]].mruAt > snap[sessions[j]].mruAt
110+ }
111+ return snap[sessions[i]].name < snap[sessions[j]].name
112+ })
113+ sort.Slice(stopped, func(i, j int) bool {
114+ if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
115+ return stopped[i].lastUsedSeq > stopped[j].lastUsedSeq
116+ }
117+ return stopped[i].name < stopped[j].name
118+ })
119+ if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
120+ // Stable partition: named sessions first, ephemeral after, each keeping
121+ // its MRU order from the sort above.
122+ sort.SliceStable(sessions, func(i, j int) bool {
123+ return !snap[sessions[i]].ephemeral && snap[sessions[j]].ephemeral
124+ })
125+ }
59126
60127 for _, s := range sessions {
61128 d.refreshSessionFocusedTitles(s)
@@ -114,7 +181,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
114181
115182 func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
116183 views, attachedCurrent := d.pickerViews(cur)
117- if intent == pickerNavigate {
184+ if intent == pickerNavigate && current == (picker.SourceFilter{}) {
118185 current = attachedCurrent
119186 }
120187 return picker.New(views, picker.SelectionConfig{
@@ -173,6 +240,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
173240 switch b {
174241 case 'x':
175242 return listInputResult{action: b, stop: true}
243+ case 's':
244+ return listInputResult{action: b, stop: true}
176245 case '\r', '\n':
177246 return listInputResult{action: b, exit: true}
178247 default:
@@ -183,12 +252,21 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
183252 var ok bool
184253 var intent pickerIntent
185254 var source moveSourceLocator
255+ // prevIdx is the row the victim occupied; -1 means "no post-delete hint".
256+ prevIdx := -1
186257 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
187258 target, ok = rt.picker.Selected()
259+ prevIdx = rt.picker.SelectedIndex()
188260 }
189261 intent, source = rt.pickerIntent, rt.pickerSource
190262 rt.pickerMu.Unlock()
191263
264+ if result.action == 's' {
265+ d.togglePickerSort()
266+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
267+ d.invalidateRender(sess, ac, true, "picker.go")
268+ return
269+ }
192270 if result.action == 'x' {
193271 var effect *roleEffectTicket
194272 if len(effects) != 0 {
@@ -213,7 +291,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
213291 }
214292 defer fresh.End()
215293 }
216- d.refreshPicker(ac)
294+ d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
217295 d.invalidateRender(sess, ac, true, "picker.go")
218296 return
219297 }
@@ -405,6 +483,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
405483 func (d *Daemon) closePicker(ac *attachedClient) {
406484 ac.overlays.pickerMu.Lock()
407485 ac.overlays.picker = nil
486+ ac.overlays.pickerTitle = ""
408487 ac.overlays.pickerIntent = pickerNavigate
409488 ac.overlays.pickerSource = moveSourceLocator{}
410489 ac.overlays.pickerPending = nil
@@ -16,6 +16,38 @@ import (
16 16
17 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}17 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
18 18
19+// pickerSortMode orders the picker's live sessions. It lives for the daemon's
20+// lifetime only and is never persisted.
21+type pickerSortMode uint32
22+
23+const (
24+ pickerSortRecent pickerSortMode = iota
25+ pickerSortGrouped
26+)
27+
28+func pickerTitle(mode pickerSortMode) string {
29+ if mode == pickerSortGrouped {
30+ return " Sessions · grouped "
31+ }
32+ return " Sessions · recent "
33+}
34+
35+// togglePickerSort flips the daemon's picker sort mode between the two known
36+// modes with a CompareAndSwap loop, so a concurrent double-press can't lose
37+// an update the way a bare Load-then-Store XOR can.
38+func (d *Daemon) togglePickerSort() {
39+ for {
40+ cur := pickerSortMode(d.pickerSort.Load())
41+ next := pickerSortRecent
42+ if cur == pickerSortRecent {
43+ next = pickerSortGrouped
44+ }
45+ if d.pickerSort.CompareAndSwap(uint32(cur), uint32(next)) {
46+ return
47+ }
48+ }
49+}
50+
19 // enterPicker preserves the existing navigation entry point. Navigation always51 // enterPicker preserves the existing navigation entry point. Navigation always
20 // publishes its model, including an empty one; only move entry can fail for a52 // publishes its model, including an empty one; only move entry can fail for a
21 // missing destination.53 // missing destination.
@@ -32,6 +64,7 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
32 rt.pickerPreviewSession = nil64 rt.pickerPreviewSession = nil
33 rt.pickerPreview = nil65 rt.pickerPreview = nil
34 rt.picker = model66 rt.picker = model
67+ rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
35 rt.pickerIntent = intent68 rt.pickerIntent = intent
36 rt.pickerSource = source69 rt.pickerSource = source
37 rt.pickerPending = nil70 rt.pickerPending = nil
@@ -54,8 +87,42 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
54 }87 }
55 }88 }
56 d.mu.Unlock()89 d.mu.Unlock()
57- sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })90+ // Snapshot mruAt, name, and ephemeral once: comparators must not observe
58- sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })91+ // concurrent touchMRU updates or a renameSession mutation (which mutates
92+ // name/ephemeral under sess.mu) mid-sort.
93+ type pickerSortSnapshot struct {
94+ mruAt uint64
95+ name string
96+ ephemeral bool
97+ }
98+ snap := make(map[*session]pickerSortSnapshot, len(sessions))
99+ for _, s := range sessions {
100+ mruAt := s.mruAt.Load()
101+ s.mu.Lock()
102+ name := s.name
103+ ephemeral := s.ephemeral
104+ s.mu.Unlock()
105+ snap[s] = pickerSortSnapshot{mruAt: mruAt, name: name, ephemeral: ephemeral}
106+ }
107+ sort.Slice(sessions, func(i, j int) bool {
108+ if snap[sessions[i]].mruAt != snap[sessions[j]].mruAt {
109+ return snap[sessions[i]].mruAt > snap[sessions[j]].mruAt
110+ }
111+ return snap[sessions[i]].name < snap[sessions[j]].name
112+ })
113+ sort.Slice(stopped, func(i, j int) bool {
114+ if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
115+ return stopped[i].lastUsedSeq > stopped[j].lastUsedSeq
116+ }
117+ return stopped[i].name < stopped[j].name
118+ })
119+ if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
120+ // Stable partition: named sessions first, ephemeral after, each keeping
121+ // its MRU order from the sort above.
122+ sort.SliceStable(sessions, func(i, j int) bool {
123+ return !snap[sessions[i]].ephemeral && snap[sessions[j]].ephemeral
124+ })
125+ }
59 126
60 for _, s := range sessions {127 for _, s := range sessions {
61 d.refreshSessionFocusedTitles(s)128 d.refreshSessionFocusedTitles(s)
@@ -114,7 +181,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, picker.SourceF
114 181
115 func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {182 func (d *Daemon) newPickerModel(cur *session, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
116 views, attachedCurrent := d.pickerViews(cur)183 views, attachedCurrent := d.pickerViews(cur)
117- if intent == pickerNavigate {184+ if intent == pickerNavigate && current == (picker.SourceFilter{}) {
118 current = attachedCurrent185 current = attachedCurrent
119 }186 }
120 return picker.New(views, picker.SelectionConfig{187 return picker.New(views, picker.SelectionConfig{
@@ -173,6 +240,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
173 switch b {240 switch b {
174 case 'x':241 case 'x':
175 return listInputResult{action: b, stop: true}242 return listInputResult{action: b, stop: true}
243+ case 's':
244+ return listInputResult{action: b, stop: true}
176 case '\r', '\n':245 case '\r', '\n':
177 return listInputResult{action: b, exit: true}246 return listInputResult{action: b, exit: true}
178 default:247 default:
@@ -183,12 +252,21 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
183 var ok bool252 var ok bool
184 var intent pickerIntent253 var intent pickerIntent
185 var source moveSourceLocator254 var source moveSourceLocator
255+ // prevIdx is the row the victim occupied; -1 means "no post-delete hint".
256+ prevIdx := -1
186 if result.action == 'x' || result.action == '\r' || result.action == '\n' {257 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
187 target, ok = rt.picker.Selected()258 target, ok = rt.picker.Selected()
259+ prevIdx = rt.picker.SelectedIndex()
188 }260 }
189 intent, source = rt.pickerIntent, rt.pickerSource261 intent, source = rt.pickerIntent, rt.pickerSource
190 rt.pickerMu.Unlock()262 rt.pickerMu.Unlock()
191 263
264+ if result.action == 's' {
265+ d.togglePickerSort()
266+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
267+ d.invalidateRender(sess, ac, true, "picker.go")
268+ return
269+ }
192 if result.action == 'x' {270 if result.action == 'x' {
193 var effect *roleEffectTicket271 var effect *roleEffectTicket
194 if len(effects) != 0 {272 if len(effects) != 0 {
@@ -213,7 +291,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
213 }291 }
214 defer fresh.End()292 defer fresh.End()
215 }293 }
216- d.refreshPicker(ac)294+ d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
217 d.invalidateRender(sess, ac, true, "picker.go")295 d.invalidateRender(sess, ac, true, "picker.go")
218 return296 return
219 }297 }
@@ -405,6 +483,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
405 func (d *Daemon) closePicker(ac *attachedClient) {483 func (d *Daemon) closePicker(ac *attachedClient) {
406 ac.overlays.pickerMu.Lock()484 ac.overlays.pickerMu.Lock()
407 ac.overlays.picker = nil485 ac.overlays.picker = nil
486+ ac.overlays.pickerTitle = ""
408 ac.overlays.pickerIntent = pickerNavigate487 ac.overlays.pickerIntent = pickerNavigate
409 ac.overlays.pickerSource = moveSourceLocator{}488 ac.overlays.pickerSource = moveSourceLocator{}
410 ac.overlays.pickerPending = nil489 ac.overlays.pickerPending = nil
M internal/usecase/daemon/picker_test.go +243 -46
@@ -118,6 +118,163 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {
118118 require.Equal(t, "1", ephemeralView.Name)
119119 }
120120
121+func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
122+ d := newTestDaemon(t, nil, stubClock{})
123+ old := &session{id: "old", name: "alpha", tabs: []*tab{{}}}
124+ old.mruAt.Store(1)
125+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
126+ eph.mruAt.Store(3)
127+ recent := &session{id: "recent", name: "zeta", tabs: []*tab{{}}}
128+ recent.mruAt.Store(2)
129+ d.sessions[old.id] = old
130+ d.sessions[eph.id] = eph
131+ d.sessions[recent.id] = recent
132+ d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}
133+ d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}
134+
135+ views, _ := d.pickerViews(recent)
136+
137+ names := make([]string, 0, len(views))
138+ for _, v := range views {
139+ names = append(names, v.Name)
140+ }
141+ // Live MRU-desc (ephemeral "1" is most recent), then stopped block MRU-desc.
142+ require.Equal(t, []string{"1", "zeta", "alpha", "halted-new", "halted-old"}, names)
143+ require.True(t, views[3].Stopped)
144+ require.True(t, views[4].Stopped)
145+}
146+
147+func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
148+ d := newTestDaemon(t, nil, stubClock{})
149+ b := &session{id: "b", name: "bravo", tabs: []*tab{{}}}
150+ a := &session{id: "a", name: "alpha", tabs: []*tab{{}}}
151+ // Equal mruAt (both zero) must yield a stable alphabetical order.
152+ d.sessions[b.id] = b
153+ d.sessions[a.id] = a
154+
155+ views, _ := d.pickerViews(a)
156+
157+ require.Equal(t, "alpha", views[0].Name)
158+ require.Equal(t, "bravo", views[1].Name)
159+}
160+
161+func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
162+ d := newTestDaemon(t, nil, stubClock{})
163+ d.pickerSort.Store(uint32(pickerSortGrouped))
164+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
165+ eph.mruAt.Store(5)
166+ named := &session{id: "named", name: "work", tabs: []*tab{{}}}
167+ named.mruAt.Store(2)
168+ named2 := &session{id: "named2", name: "notes", tabs: []*tab{{}}}
169+ named2.mruAt.Store(3)
170+ d.sessions[eph.id] = eph
171+ d.sessions[named.id] = named
172+ d.sessions[named2.id] = named2
173+ d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
174+
175+ views, _ := d.pickerViews(named)
176+
177+ names := make([]string, 0, len(views))
178+ for _, v := range views {
179+ names = append(names, v.Name)
180+ }
181+ // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the
182+ // ephemeral session has the highest mruAt.
183+ require.Equal(t, []string{"notes", "work", "1", "halted"}, names)
184+}
185+
186+func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
187+ // Rows in recent mode are: ephemeral "1" (header + tab), "work" (header +
188+ // two tabs), then the stopped block. Navigation starts on "work"'s active
189+ // tab, so "j" walks down from there.
190+ cases := []struct {
191+ name string
192+ navigate []byte
193+ wantStopped bool
194+ }{
195+ {name: "live tab selection", navigate: []byte("j")},
196+ {name: "stopped session selection", navigate: []byte("jj"), wantStopped: true},
197+ }
198+ for _, tc := range cases {
199+ t.Run(tc.name, func(t *testing.T) {
200+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
201+ defer releaseAll(releases)
202+ sess.mu.Lock()
203+ sess.client = ac
204+ secondTabID := domain.TabStableID(sess.tabs[1].stableID)
205+ sess.mu.Unlock()
206+ sess.mruAt.Store(1)
207+ // A second live session, ephemeral and more recently used: recent mode
208+ // lists it first, grouped mode moves it below the named session.
209+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
210+ eph.mruAt.Store(5)
211+ d.mu.Lock()
212+ d.sessions[eph.id] = eph
213+ d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
214+ d.mu.Unlock()
215+
216+ d.enterPicker(sess, ac)
217+ awaitFrame(t, sends, ports.MsgOutput)
218+ // Move off the attached session's active tab: a navigate rebuild snaps
219+ // back to it unless the toggle preserves the selection.
220+ d.handleInput(sess, ac, tc.navigate)
221+ awaitFrame(t, sends, ports.MsgOutput)
222+ ac.overlays.pickerMu.Lock()
223+ before, ok := ac.overlays.picker.Selected()
224+ ac.overlays.pickerMu.Unlock()
225+ require.True(t, ok)
226+ require.Equal(t, tc.wantStopped, before.Stopped)
227+ if tc.wantStopped {
228+ require.Equal(t, "halted", before.Name)
229+ } else {
230+ require.Equal(t, secondTabID, before.TabID)
231+ }
232+
233+ d.handleInput(sess, ac, []byte("s"))
234+ awaitFrame(t, sends, ports.MsgOutput)
235+
236+ require.Equal(t, uint32(pickerSortGrouped), d.pickerSort.Load())
237+ require.True(t, ac.overlays.pickerActive())
238+ ac.overlays.pickerMu.Lock()
239+ selected, ok := ac.overlays.picker.Selected()
240+ title := ac.overlays.pickerTitle
241+ ac.overlays.pickerMu.Unlock()
242+ require.True(t, ok)
243+ require.Equal(t, before, selected)
244+ require.Equal(t, " Sessions · grouped ", title)
245+
246+ d.handleInput(sess, ac, []byte("s"))
247+ awaitFrame(t, sends, ports.MsgOutput)
248+
249+ require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load())
250+ require.True(t, ac.overlays.pickerActive())
251+ ac.overlays.pickerMu.Lock()
252+ selected, ok = ac.overlays.picker.Selected()
253+ title = ac.overlays.pickerTitle
254+ ac.overlays.pickerMu.Unlock()
255+ require.True(t, ok)
256+ require.Equal(t, before, selected)
257+ require.Equal(t, " Sessions · recent ", title)
258+ })
259+ }
260+}
261+
262+func TestPickerTitleReflectsSortMode(t *testing.T) {
263+ tests := []struct {
264+ name string
265+ mode pickerSortMode
266+ want string
267+ }{
268+ {name: "recent", mode: pickerSortRecent, want: " Sessions · recent "},
269+ {name: "grouped", mode: pickerSortGrouped, want: " Sessions · grouped "},
270+ }
271+ for _, tc := range tests {
272+ t.Run(tc.name, func(t *testing.T) {
273+ require.Equal(t, tc.want, pickerTitle(tc.mode))
274+ })
275+ }
276+}
277+
121278 func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
122279 d := newTestDaemon(t, nil, stubClock{})
123280 ctx, cancel := context.WithCancel(d.serveCtx)
@@ -363,7 +520,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
363520 destination.mu.Lock()
364521 destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0]
365522 destination.mu.Unlock()
366- d.refreshPicker(ac)
523+ d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1})
367524
368525 ac.overlays.pickerMu.Lock()
369526 after, ok := ac.overlays.picker.Selected()
@@ -412,7 +569,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
412569 require.True(t, ok)
413570 require.Equal(t, domain.SessionID("selected"), before.Session)
414571
415- d.refreshPicker(ac)
572+ d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1})
416573
417574 ac.overlays.pickerMu.Lock()
418575 after, ok := ac.overlays.picker.Selected()
@@ -1459,51 +1616,90 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t
14591616 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
14601617 }
14611618
1462-func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) {
1463- d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1464- defer releaseAll(currentReleases)
1465- current.id, current.name, current.active = "current", "z-current", 1
1466- current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1467- delete(d.sessions, domain.SessionID("manual"))
1468- d.sessions[current.id] = current
1469-
1470- otherPTY, releaseOther := newBlockingPTY(t)
1471- defer releaseOther()
1472- otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1473- otherTab.stableID = "other-tab"
1474- other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1475- d.sessions[other.id] = other
1476-
1477- targetPTY, releaseTarget := newBlockingPTY(t)
1478- defer releaseTarget()
1479- targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1480- targetTab.stableID = "target-tab"
1481- target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1482- d.sessions[target.id] = target
1483-
1484- d.enterPicker(current, ac)
1485- d.handlePickerInput(ac, []byte("k"))
1486- d.handlePickerInput(ac, []byte("k"))
1487- ac.overlays.pickerMu.Lock()
1488- selected, ok := ac.overlays.picker.Selected()
1489- ac.overlays.pickerMu.Unlock()
1490- require.True(t, ok)
1491- require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
1492-
1493- d.handlePickerInput(ac, []byte("x"))
1619+// TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow pins the post-kill
1620+// contract: the cursor stays on the row index the killed session occupied, so
1621+// it lands on whatever session took its place — clamped to the new last
1622+// selectable row when the bottom session was killed. Session names drive the
1623+// picker's alphabetical tie-break (every session here shares mruAt 0), which is
1624+// what puts the victim in the middle or at the end of the list.
1625+func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
1626+ tests := []struct {
1627+ name string
1628+ currentName string
1629+ otherName string
1630+ targetName string
1631+ nav []byte // keys walked from the attached session's active tab onto the victim
1632+ wantSession domain.SessionID
1633+ wantTabID domain.TabStableID
1634+ wantTabIndex int
1635+ }{
1636+ {
1637+ // Rows: [a-other, other-tab, m-target, target-tab, z-current, current-first, current-active].
1638+ // Killing target-tab (row 3) promotes z-current's header to row 2, so
1639+ // row 3 becomes current-first — not the attached active tab.
1640+ name: "middle session killed selects the row taking its place",
1641+ currentName: "z-current", otherName: "a-other", targetName: "m-target",
1642+ nav: []byte("kk"),
1643+ wantSession: "current", wantTabID: "current-first", wantTabIndex: 0,
1644+ },
1645+ {
1646+ // Rows: [a-current, current-first, current-active, m-other, other-tab, z-target, target-tab].
1647+ // Killing the bottom row (6) clamps to the new last selectable row.
1648+ name: "last session killed selects the new last row",
1649+ currentName: "a-current", otherName: "m-other", targetName: "z-target",
1650+ nav: []byte("jj"),
1651+ wantSession: "other", wantTabID: "other-tab", wantTabIndex: 0,
1652+ },
1653+ }
1654+ for _, tt := range tests {
1655+ t.Run(tt.name, func(t *testing.T) {
1656+ d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1657+ defer releaseAll(currentReleases)
1658+ current.id, current.name, current.active = "current", tt.currentName, 1
1659+ current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1660+ delete(d.sessions, domain.SessionID("manual"))
1661+ d.sessions[current.id] = current
1662+
1663+ otherPTY, releaseOther := newBlockingPTY(t)
1664+ defer releaseOther()
1665+ otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1666+ otherTab.stableID = "other-tab"
1667+ other := &session{id: "other", name: tt.otherName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1668+ d.sessions[other.id] = other
1669+
1670+ targetPTY, releaseTarget := newBlockingPTY(t)
1671+ defer releaseTarget()
1672+ targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1673+ targetTab.stableID = "target-tab"
1674+ target := &session{id: "target", name: tt.targetName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1675+ d.sessions[target.id] = target
1676+
1677+ d.enterPicker(current, ac)
1678+ for _, key := range tt.nav {
1679+ d.handlePickerInput(ac, []byte{key})
1680+ }
1681+ ac.overlays.pickerMu.Lock()
1682+ selected, ok := ac.overlays.picker.Selected()
1683+ ac.overlays.pickerMu.Unlock()
1684+ require.True(t, ok)
1685+ require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
14941686
1495- d.mu.Lock()
1496- _, targetStillLive := d.sessions[target.id]
1497- d.mu.Unlock()
1498- require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")
1687+ d.handlePickerInput(ac, []byte("x"))
14991688
1500- ac.overlays.pickerMu.Lock()
1501- selected, ok = ac.overlays.picker.Selected()
1502- ac.overlays.pickerMu.Unlock()
1503- require.True(t, ok)
1504- require.Equal(t, current.id, selected.Session)
1505- require.Equal(t, domain.TabStableID("current-active"), selected.TabID)
1506- require.Equal(t, 1, selected.TabIndex)
1689+ d.mu.Lock()
1690+ _, targetStillLive := d.sessions[target.id]
1691+ d.mu.Unlock()
1692+ require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")
1693+
1694+ ac.overlays.pickerMu.Lock()
1695+ selected, ok = ac.overlays.picker.Selected()
1696+ ac.overlays.pickerMu.Unlock()
1697+ require.True(t, ok)
1698+ require.Equal(t, tt.wantSession, selected.Session)
1699+ require.Equal(t, tt.wantTabID, selected.TabID)
1700+ require.Equal(t, tt.wantTabIndex, selected.TabIndex)
1701+ })
1702+ }
15071703 }
15081704
15091705 func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
@@ -1563,7 +1759,8 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
15631759
15641760 d.enterPicker(from, ac)
15651761 awaitFrame(t, sends, ports.MsgOutput)
1566- d.handleInput(from, ac, []byte("k"))
1762+ // MRU-desc order is from(current), recent, older; "j" moves onto "recent".
1763+ d.handleInput(from, ac, []byte("j"))
15671764 awaitFrame(t, sends, ports.MsgOutput)
15681765 d.handleInput(from, ac, []byte("x"))
15691766
@@ -118,6 +118,163 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {
118 require.Equal(t, "1", ephemeralView.Name)118 require.Equal(t, "1", ephemeralView.Name)
119 }119 }
120 120
121+func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
122+ d := newTestDaemon(t, nil, stubClock{})
123+ old := &session{id: "old", name: "alpha", tabs: []*tab{{}}}
124+ old.mruAt.Store(1)
125+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
126+ eph.mruAt.Store(3)
127+ recent := &session{id: "recent", name: "zeta", tabs: []*tab{{}}}
128+ recent.mruAt.Store(2)
129+ d.sessions[old.id] = old
130+ d.sessions[eph.id] = eph
131+ d.sessions[recent.id] = recent
132+ d.stopped["halted-old"] = stoppedSession{name: "halted-old", createdAt: 10, lastUsedSeq: 1}
133+ d.stopped["halted-new"] = stoppedSession{name: "halted-new", createdAt: 11, lastUsedSeq: 2}
134+
135+ views, _ := d.pickerViews(recent)
136+
137+ names := make([]string, 0, len(views))
138+ for _, v := range views {
139+ names = append(names, v.Name)
140+ }
141+ // Live MRU-desc (ephemeral "1" is most recent), then stopped block MRU-desc.
142+ require.Equal(t, []string{"1", "zeta", "alpha", "halted-new", "halted-old"}, names)
143+ require.True(t, views[3].Stopped)
144+ require.True(t, views[4].Stopped)
145+}
146+
147+func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
148+ d := newTestDaemon(t, nil, stubClock{})
149+ b := &session{id: "b", name: "bravo", tabs: []*tab{{}}}
150+ a := &session{id: "a", name: "alpha", tabs: []*tab{{}}}
151+ // Equal mruAt (both zero) must yield a stable alphabetical order.
152+ d.sessions[b.id] = b
153+ d.sessions[a.id] = a
154+
155+ views, _ := d.pickerViews(a)
156+
157+ require.Equal(t, "alpha", views[0].Name)
158+ require.Equal(t, "bravo", views[1].Name)
159+}
160+
161+func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
162+ d := newTestDaemon(t, nil, stubClock{})
163+ d.pickerSort.Store(uint32(pickerSortGrouped))
164+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
165+ eph.mruAt.Store(5)
166+ named := &session{id: "named", name: "work", tabs: []*tab{{}}}
167+ named.mruAt.Store(2)
168+ named2 := &session{id: "named2", name: "notes", tabs: []*tab{{}}}
169+ named2.mruAt.Store(3)
170+ d.sessions[eph.id] = eph
171+ d.sessions[named.id] = named
172+ d.sessions[named2.id] = named2
173+ d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
174+
175+ views, _ := d.pickerViews(named)
176+
177+ names := make([]string, 0, len(views))
178+ for _, v := range views {
179+ names = append(names, v.Name)
180+ }
181+ // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the
182+ // ephemeral session has the highest mruAt.
183+ require.Equal(t, []string{"notes", "work", "1", "halted"}, names)
184+}
185+
186+func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
187+ // Rows in recent mode are: ephemeral "1" (header + tab), "work" (header +
188+ // two tabs), then the stopped block. Navigation starts on "work"'s active
189+ // tab, so "j" walks down from there.
190+ cases := []struct {
191+ name string
192+ navigate []byte
193+ wantStopped bool
194+ }{
195+ {name: "live tab selection", navigate: []byte("j")},
196+ {name: "stopped session selection", navigate: []byte("jj"), wantStopped: true},
197+ }
198+ for _, tc := range cases {
199+ t.Run(tc.name, func(t *testing.T) {
200+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
201+ defer releaseAll(releases)
202+ sess.mu.Lock()
203+ sess.client = ac
204+ secondTabID := domain.TabStableID(sess.tabs[1].stableID)
205+ sess.mu.Unlock()
206+ sess.mruAt.Store(1)
207+ // A second live session, ephemeral and more recently used: recent mode
208+ // lists it first, grouped mode moves it below the named session.
209+ eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
210+ eph.mruAt.Store(5)
211+ d.mu.Lock()
212+ d.sessions[eph.id] = eph
213+ d.stopped["halted"] = stoppedSession{name: "halted", createdAt: 9, lastUsedSeq: 9}
214+ d.mu.Unlock()
215+
216+ d.enterPicker(sess, ac)
217+ awaitFrame(t, sends, ports.MsgOutput)
218+ // Move off the attached session's active tab: a navigate rebuild snaps
219+ // back to it unless the toggle preserves the selection.
220+ d.handleInput(sess, ac, tc.navigate)
221+ awaitFrame(t, sends, ports.MsgOutput)
222+ ac.overlays.pickerMu.Lock()
223+ before, ok := ac.overlays.picker.Selected()
224+ ac.overlays.pickerMu.Unlock()
225+ require.True(t, ok)
226+ require.Equal(t, tc.wantStopped, before.Stopped)
227+ if tc.wantStopped {
228+ require.Equal(t, "halted", before.Name)
229+ } else {
230+ require.Equal(t, secondTabID, before.TabID)
231+ }
232+
233+ d.handleInput(sess, ac, []byte("s"))
234+ awaitFrame(t, sends, ports.MsgOutput)
235+
236+ require.Equal(t, uint32(pickerSortGrouped), d.pickerSort.Load())
237+ require.True(t, ac.overlays.pickerActive())
238+ ac.overlays.pickerMu.Lock()
239+ selected, ok := ac.overlays.picker.Selected()
240+ title := ac.overlays.pickerTitle
241+ ac.overlays.pickerMu.Unlock()
242+ require.True(t, ok)
243+ require.Equal(t, before, selected)
244+ require.Equal(t, " Sessions · grouped ", title)
245+
246+ d.handleInput(sess, ac, []byte("s"))
247+ awaitFrame(t, sends, ports.MsgOutput)
248+
249+ require.Equal(t, uint32(pickerSortRecent), d.pickerSort.Load())
250+ require.True(t, ac.overlays.pickerActive())
251+ ac.overlays.pickerMu.Lock()
252+ selected, ok = ac.overlays.picker.Selected()
253+ title = ac.overlays.pickerTitle
254+ ac.overlays.pickerMu.Unlock()
255+ require.True(t, ok)
256+ require.Equal(t, before, selected)
257+ require.Equal(t, " Sessions · recent ", title)
258+ })
259+ }
260+}
261+
262+func TestPickerTitleReflectsSortMode(t *testing.T) {
263+ tests := []struct {
264+ name string
265+ mode pickerSortMode
266+ want string
267+ }{
268+ {name: "recent", mode: pickerSortRecent, want: " Sessions · recent "},
269+ {name: "grouped", mode: pickerSortGrouped, want: " Sessions · grouped "},
270+ }
271+ for _, tc := range tests {
272+ t.Run(tc.name, func(t *testing.T) {
273+ require.Equal(t, tc.want, pickerTitle(tc.mode))
274+ })
275+ }
276+}
277+
121 func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {278 func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
122 d := newTestDaemon(t, nil, stubClock{})279 d := newTestDaemon(t, nil, stubClock{})
123 ctx, cancel := context.WithCancel(d.serveCtx)280 ctx, cancel := context.WithCancel(d.serveCtx)
@@ -363,7 +520,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
363 destination.mu.Lock()520 destination.mu.Lock()
364 destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0]521 destination.tabs[0], destination.tabs[1] = destination.tabs[1], destination.tabs[0]
365 destination.mu.Unlock()522 destination.mu.Unlock()
366- d.refreshPicker(ac)523+ d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1})
367 524
368 ac.overlays.pickerMu.Lock()525 ac.overlays.pickerMu.Lock()
369 after, ok := ac.overlays.picker.Selected()526 after, ok := ac.overlays.picker.Selected()
@@ -412,7 +569,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
412 require.True(t, ok)569 require.True(t, ok)
413 require.Equal(t, domain.SessionID("selected"), before.Session)570 require.Equal(t, domain.SessionID("selected"), before.Session)
414 571
415- d.refreshPicker(ac)572+ d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: -1})
416 573
417 ac.overlays.pickerMu.Lock()574 ac.overlays.pickerMu.Lock()
418 after, ok := ac.overlays.picker.Selected()575 after, ok := ac.overlays.picker.Selected()
@@ -1459,51 +1616,90 @@ func TestPickerEnterOnStoppedSessionRestoreFailureSurfacesNoticeAndStaysPut(t *t
1459 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")1616 require.Same(t, from, ac.currentSession(), "a failed restore must leave the client on its origin session")
1460 }1617 }
1461 1618
1462-func TestPickerNavigationRefreshAfterSuccessfulDeleteSelectsAttachedActiveStableTab(t *testing.T) {1619+// TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow pins the post-kill
1463- d, current, ac, _, currentReleases := newManualTabSession(t, 2)1620+// contract: the cursor stays on the row index the killed session occupied, so
1464- defer releaseAll(currentReleases)1621+// it lands on whatever session took its place — clamped to the new last
1465- current.id, current.name, current.active = "current", "z-current", 11622+// selectable row when the bottom session was killed. Session names drive the
1466- current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"1623+// picker's alphabetical tie-break (every session here shares mruAt 0), which is
1467- delete(d.sessions, domain.SessionID("manual"))1624+// what puts the victim in the middle or at the end of the list.
1468- d.sessions[current.id] = current1625+func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
1469-1626+ tests := []struct {
1470- otherPTY, releaseOther := newBlockingPTY(t)1627+ name string
1471- defer releaseOther()1628+ currentName string
1472- otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)1629+ otherName string
1473- otherTab.stableID = "other-tab"1630+ targetName string
1474- other := &session{id: "other", name: "a-other", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}1631+ nav []byte // keys walked from the attached session's active tab onto the victim
1475- d.sessions[other.id] = other1632+ wantSession domain.SessionID
1476-1633+ wantTabID domain.TabStableID
1477- targetPTY, releaseTarget := newBlockingPTY(t)1634+ wantTabIndex int
1478- defer releaseTarget()1635+ }{
1479- targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)1636+ {
1480- targetTab.stableID = "target-tab"1637+ // Rows: [a-other, other-tab, m-target, target-tab, z-current, current-first, current-active].
1481- target := &session{id: "target", name: "m-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}1638+ // Killing target-tab (row 3) promotes z-current's header to row 2, so
1482- d.sessions[target.id] = target1639+ // row 3 becomes current-first — not the attached active tab.
1483-1640+ name: "middle session killed selects the row taking its place",
1484- d.enterPicker(current, ac)1641+ currentName: "z-current", otherName: "a-other", targetName: "m-target",
1485- d.handlePickerInput(ac, []byte("k"))1642+ nav: []byte("kk"),
1486- d.handlePickerInput(ac, []byte("k"))1643+ wantSession: "current", wantTabID: "current-first", wantTabIndex: 0,
1487- ac.overlays.pickerMu.Lock()1644+ },
1488- selected, ok := ac.overlays.picker.Selected()1645+ {
1489- ac.overlays.pickerMu.Unlock()1646+ // Rows: [a-current, current-first, current-active, m-other, other-tab, z-target, target-tab].
1490- require.True(t, ok)1647+ // Killing the bottom row (6) clamps to the new last selectable row.
1491- require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")1648+ name: "last session killed selects the new last row",
1492-1649+ currentName: "a-current", otherName: "m-other", targetName: "z-target",
1493- d.handlePickerInput(ac, []byte("x"))1650+ nav: []byte("jj"),
1651+ wantSession: "other", wantTabID: "other-tab", wantTabIndex: 0,
1652+ },
1653+ }
1654+ for _, tt := range tests {
1655+ t.Run(tt.name, func(t *testing.T) {
1656+ d, current, ac, _, currentReleases := newManualTabSession(t, 2)
1657+ defer releaseAll(currentReleases)
1658+ current.id, current.name, current.active = "current", tt.currentName, 1
1659+ current.tabs[0].stableID, current.tabs[1].stableID = "current-first", "current-active"
1660+ delete(d.sessions, domain.SessionID("manual"))
1661+ d.sessions[current.id] = current
1662+
1663+ otherPTY, releaseOther := newBlockingPTY(t)
1664+ defer releaseOther()
1665+ otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1666+ otherTab.stableID = "other-tab"
1667+ other := &session{id: "other", name: tt.otherName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1668+ d.sessions[other.id] = other
1669+
1670+ targetPTY, releaseTarget := newBlockingPTY(t)
1671+ defer releaseTarget()
1672+ targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1673+ targetTab.stableID = "target-tab"
1674+ target := &session{id: "target", name: tt.targetName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1675+ d.sessions[target.id] = target
1676+
1677+ d.enterPicker(current, ac)
1678+ for _, key := range tt.nav {
1679+ d.handlePickerInput(ac, []byte{key})
1680+ }
1681+ ac.overlays.pickerMu.Lock()
1682+ selected, ok := ac.overlays.picker.Selected()
1683+ ac.overlays.pickerMu.Unlock()
1684+ require.True(t, ok)
1685+ require.Equal(t, target.id, selected.Session, "test must delete the selected non-attached session")
1494 1686
1495- d.mu.Lock()1687+ d.handlePickerInput(ac, []byte("x"))
1496- _, targetStillLive := d.sessions[target.id]
1497- d.mu.Unlock()
1498- require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")
1499 1688
1500- ac.overlays.pickerMu.Lock()1689+ d.mu.Lock()
1501- selected, ok = ac.overlays.picker.Selected()1690+ _, targetStillLive := d.sessions[target.id]
1502- ac.overlays.pickerMu.Unlock()1691+ d.mu.Unlock()
1503- require.True(t, ok)1692+ require.False(t, targetStillLive, "ordinary picker deletion must remove the selected session")
1504- require.Equal(t, current.id, selected.Session)1693+
1505- require.Equal(t, domain.TabStableID("current-active"), selected.TabID)1694+ ac.overlays.pickerMu.Lock()
1506- require.Equal(t, 1, selected.TabIndex)1695+ selected, ok = ac.overlays.picker.Selected()
1696+ ac.overlays.pickerMu.Unlock()
1697+ require.True(t, ok)
1698+ require.Equal(t, tt.wantSession, selected.Session)
1699+ require.Equal(t, tt.wantTabID, selected.TabID)
1700+ require.Equal(t, tt.wantTabIndex, selected.TabIndex)
1701+ })
1702+ }
1507 }1703 }
1508 1704
1509 func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {1705 func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
@@ -1563,7 +1759,8 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
1563 1759
1564 d.enterPicker(from, ac)1760 d.enterPicker(from, ac)
1565 awaitFrame(t, sends, ports.MsgOutput)1761 awaitFrame(t, sends, ports.MsgOutput)
1566- d.handleInput(from, ac, []byte("k"))1762+ // MRU-desc order is from(current), recent, older; "j" moves onto "recent".
1763+ d.handleInput(from, ac, []byte("j"))
1567 awaitFrame(t, sends, ports.MsgOutput)1764 awaitFrame(t, sends, ports.MsgOutput)
1568 d.handleInput(from, ac, []byte("x"))1765 d.handleInput(from, ac, []byte("x"))
1569 1766
M internal/usecase/daemon/render_pipeline.go +9 -2
@@ -230,8 +230,15 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
230230 }
231231 if snap.pickerActive && snap.pickerModel != nil {
232232 presentation := pickerModal.Resolve(size)
233- o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true}
234- renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator}
233+ title := snap.pickerTitle
234+ if title == "" {
235+ title = pickerModal.Title
236+ }
237+ o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true}
238+ stoppedStyle := styles.PickerName
239+ stoppedStyle.Attrs |= renderer.AttrDim
240+ stoppedStyle.Italic = true
241+ renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle}
235242 o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
236243 }
237244 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
@@ -230,8 +230,15 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
230 }230 }
231 if snap.pickerActive && snap.pickerModel != nil {231 if snap.pickerActive && snap.pickerModel != nil {
232 presentation := pickerModal.Resolve(size)232 presentation := pickerModal.Resolve(size)
233- o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true}233+ title := snap.pickerTitle
234- renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator}234+ if title == "" {
235+ title = pickerModal.Title
236+ }
237+ o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true}
238+ stoppedStyle := styles.PickerName
239+ stoppedStyle.Attrs |= renderer.AttrDim
240+ stoppedStyle.Italic = true
241+ renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle}
235 o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)242 o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
236 }243 }
237 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {244 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
M internal/usecase/daemon/render_test.go +2 -2
@@ -107,7 +107,7 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
107107
108108 d.enterPicker(sess, ac)
109109 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
110- pickerTitle := client.Frame.At(36, 2)
110+ pickerTitle := client.Frame.At(31, 2)
111111 require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")
112112 undimmedPane := client.Frame.At(0, 1)
113113
@@ -115,7 +115,7 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
115115 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
116116 dimmedPickerTitle := pickerTitle
117117 dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style)
118- require.Equal(t, dimmedPickerTitle, client.Frame.At(36, 2), "the lower-priority picker must be part of the palette backdrop")
118+ require.Equal(t, dimmedPickerTitle, client.Frame.At(31, 2), "the lower-priority picker must be part of the palette backdrop")
119119 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
120120 }
121121
@@ -107,7 +107,7 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
107 107
108 d.enterPicker(sess, ac)108 d.enterPicker(sess, ac)
109 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))109 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
110- pickerTitle := client.Frame.At(36, 2)110+ pickerTitle := client.Frame.At(31, 2)
111 require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")111 require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")
112 undimmedPane := client.Frame.At(0, 1)112 undimmedPane := client.Frame.At(0, 1)
113 113
@@ -115,7 +115,7 @@ func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
115 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))115 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
116 dimmedPickerTitle := pickerTitle116 dimmedPickerTitle := pickerTitle
117 dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style)117 dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style)
118- require.Equal(t, dimmedPickerTitle, client.Frame.At(36, 2), "the lower-priority picker must be part of the palette backdrop")118+ require.Equal(t, dimmedPickerTitle, client.Frame.At(31, 2), "the lower-priority picker must be part of the palette backdrop")
119 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")119 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
120 }120 }
121 121
M internal/usecase/picker/model.go +52 -2
@@ -71,6 +71,7 @@ type RenderStyles struct {
7171 // rows, preserving a distinct inactive surface from modal chrome.
7272 Background renderer.Style
7373 Base renderer.Style // non-selected row fill + suffixes
74+ Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix)
7475 Separator renderer.Style // preview separator
7576 }
7677
@@ -80,7 +81,10 @@ func defaultRenderStyles() RenderStyles {
8081 base := renderer.DefaultStyle()
8182 separator := renderer.DefaultStyle()
8283 separator.Attrs = renderer.AttrDim
83- return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator}
84+ stopped := renderer.DefaultStyle()
85+ stopped.Attrs = renderer.AttrDim
86+ stopped.Italic = true
87+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped}
8488 }
8589
8690 type Target struct {
@@ -252,7 +256,17 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b
252256 if mode == SelectMoveTabSession {
253257 return pickerRow.kind == rowSession
254258 }
255- return pickerRow.kind == rowTab && current.TabID != "" && pickerRow.tabID == current.TabID
259+ if pickerRow.kind != rowTab {
260+ return false
261+ }
262+ // A stopped session contributes one synthetic tab row carrying no stable tab
263+ // identity, so it matches on session identity alone. Its session ID is
264+ // namespaced ("stopped:<name>"), and move modes drop stopped sessions
265+ // entirely, so no live row can be reached this way.
266+ if current.TabID == "" {
267+ return pickerRow.stopped
268+ }
269+ return pickerRow.tabID == current.TabID
256270 }
257271
258272 func int64Value(value *int64) (int64, bool) {
@@ -313,6 +327,39 @@ func (m *Model) Selected() (Target, bool) {
313327 }, true
314328 }
315329
330+// SelectedIndex reports the raw selected row index. It is -1 only when the
331+// model is nil or has no rows at all; otherwise it is a real row index even
332+// when that row is not selectable (see Selected). Row indices are only
333+// meaningful against this exact model.
334+func (m *Model) SelectedIndex() int {
335+ if m == nil {
336+ return -1
337+ }
338+ return m.selected
339+}
340+
341+// SelectNearestRow selects the first selectable row at or after idx, falling
342+// back to the last selectable row before it. Callers use it to keep the
343+// cursor on the row that takes a removed item's place.
344+func (m *Model) SelectNearestRow(idx int) {
345+ if m == nil || len(m.rows) == 0 {
346+ return
347+ }
348+ idx = clamp(idx, 0, len(m.rows)-1)
349+ for i := idx; i < len(m.rows); i++ {
350+ if m.rows[i].kind.selectable(m.mode) {
351+ m.selected = i
352+ return
353+ }
354+ }
355+ for i := idx - 1; i >= 0; i-- {
356+ if m.rows[i].kind.selectable(m.mode) {
357+ m.selected = i
358+ return
359+ }
360+ }
361+}
362+
316363 func (m *Model) Clone() *Model {
317364 if m == nil {
318365 return nil
@@ -396,6 +443,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
396443 if idx == m.selected {
397444 base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
398445 }
446+ if r.stopped && idx != m.selected {
447+ base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
448+ }
399449 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
400450
401451 name := r.dispName
@@ -71,6 +71,7 @@ type RenderStyles struct {
71 // rows, preserving a distinct inactive surface from modal chrome.71 // rows, preserving a distinct inactive surface from modal chrome.
72 Background renderer.Style72 Background renderer.Style
73 Base renderer.Style // non-selected row fill + suffixes73 Base renderer.Style // non-selected row fill + suffixes
74+ Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix)
74 Separator renderer.Style // preview separator75 Separator renderer.Style // preview separator
75 }76 }
76 77
@@ -80,7 +81,10 @@ func defaultRenderStyles() RenderStyles {
80 base := renderer.DefaultStyle()81 base := renderer.DefaultStyle()
81 separator := renderer.DefaultStyle()82 separator := renderer.DefaultStyle()
82 separator.Attrs = renderer.AttrDim83 separator.Attrs = renderer.AttrDim
83- return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator}84+ stopped := renderer.DefaultStyle()
85+ stopped.Attrs = renderer.AttrDim
86+ stopped.Italic = true
87+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped}
84 }88 }
85 89
86 type Target struct {90 type Target struct {
@@ -252,7 +256,17 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b
252 if mode == SelectMoveTabSession {256 if mode == SelectMoveTabSession {
253 return pickerRow.kind == rowSession257 return pickerRow.kind == rowSession
254 }258 }
255- return pickerRow.kind == rowTab && current.TabID != "" && pickerRow.tabID == current.TabID259+ if pickerRow.kind != rowTab {
260+ return false
261+ }
262+ // A stopped session contributes one synthetic tab row carrying no stable tab
263+ // identity, so it matches on session identity alone. Its session ID is
264+ // namespaced ("stopped:<name>"), and move modes drop stopped sessions
265+ // entirely, so no live row can be reached this way.
266+ if current.TabID == "" {
267+ return pickerRow.stopped
268+ }
269+ return pickerRow.tabID == current.TabID
256 }270 }
257 271
258 func int64Value(value *int64) (int64, bool) {272 func int64Value(value *int64) (int64, bool) {
@@ -313,6 +327,39 @@ func (m *Model) Selected() (Target, bool) {
313 }, true327 }, true
314 }328 }
315 329
330+// SelectedIndex reports the raw selected row index. It is -1 only when the
331+// model is nil or has no rows at all; otherwise it is a real row index even
332+// when that row is not selectable (see Selected). Row indices are only
333+// meaningful against this exact model.
334+func (m *Model) SelectedIndex() int {
335+ if m == nil {
336+ return -1
337+ }
338+ return m.selected
339+}
340+
341+// SelectNearestRow selects the first selectable row at or after idx, falling
342+// back to the last selectable row before it. Callers use it to keep the
343+// cursor on the row that takes a removed item's place.
344+func (m *Model) SelectNearestRow(idx int) {
345+ if m == nil || len(m.rows) == 0 {
346+ return
347+ }
348+ idx = clamp(idx, 0, len(m.rows)-1)
349+ for i := idx; i < len(m.rows); i++ {
350+ if m.rows[i].kind.selectable(m.mode) {
351+ m.selected = i
352+ return
353+ }
354+ }
355+ for i := idx - 1; i >= 0; i-- {
356+ if m.rows[i].kind.selectable(m.mode) {
357+ m.selected = i
358+ return
359+ }
360+ }
361+}
362+
316 func (m *Model) Clone() *Model {363 func (m *Model) Clone() *Model {
317 if m == nil {364 if m == nil {
318 return nil365 return nil
@@ -396,6 +443,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
396 if idx == m.selected {443 if idx == m.selected {
397 base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted444 base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
398 }445 }
446+ if r.stopped && idx != m.selected {
447+ base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
448+ }
399 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})449 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
400 450
401 name := r.dispName451 name := r.dispName
M internal/usecase/picker/model_test.go +105 -0
@@ -406,6 +406,90 @@ func TestSelectedMapping(t *testing.T) {
406406 require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got)
407407 }
408408
409+func TestSelectNearestRow(t *testing.T) {
410+ // Three single-tab sessions: rows are [hdrA, tabA, hdrB, tabB, hdrC, tabC].
411+ sessions := []SessionView{
412+ {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}},
413+ {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}},
414+ {ID: "c", Name: "c", Tabs: []TabEntry{{TabID: "tc", Name: "tc"}}},
415+ }
416+ tests := []struct {
417+ name string
418+ idx int
419+ cfg SelectionConfig
420+ // wantStart, when set, pins the selection New produced before
421+ // SelectNearestRow runs, so a case cannot silently degrade into one a
422+ // no-op implementation would also pass.
423+ wantStart domain.SessionID
424+ want domain.SessionID
425+ }{
426+ {name: "exact selectable row", idx: 3, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "b"},
427+ {name: "header row snaps to its tab", idx: 2, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "b"},
428+ {name: "past end clamps to last selectable", idx: 40, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "c"},
429+ {
430+ // Starts selection on "c" (via Current) so a no-op SelectNearestRow
431+ // would leave the selection on "c" and this case would fail; the
432+ // plain SelectNavigationTab config used above starts on "a" already,
433+ // which can't tell a real clamp-to-first from a no-op.
434+ name: "negative clamps to first selectable",
435+ idx: -2,
436+ cfg: SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "c", TabID: "tc"}},
437+ wantStart: "c",
438+ want: "a",
439+ },
440+ {name: "last row is a tab and stays put", idx: 5, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "c"},
441+ }
442+ for _, tc := range tests {
443+ t.Run(tc.name, func(t *testing.T) {
444+ m := New(sessions, tc.cfg)
445+ if tc.wantStart != "" {
446+ pre, ok := m.Selected()
447+ require.True(t, ok)
448+ require.Equal(t, tc.wantStart, pre.Session, "guard: starting selection must not already be the expected result")
449+ }
450+ m.SelectNearestRow(tc.idx)
451+ target, ok := m.Selected()
452+ require.True(t, ok)
453+ require.Equal(t, tc.want, target.Session)
454+ })
455+ }
456+}
457+
458+func TestSelectNearestRowFallsBackBackwardAndToleratesEmptyModels(t *testing.T) {
459+ // Move-tab mode makes only session headers selectable, so the trailing tab
460+ // row has no selectable row at or after it.
461+ m := New([]SessionView{
462+ {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}},
463+ {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}},
464+ }, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{Session: "zzz"}})
465+ require.Equal(t, 0, m.SelectedIndex(), "guard: the backward fallback below must move the selection off this starting row")
466+ m.SelectNearestRow(3)
467+ target, ok := m.Selected()
468+ require.True(t, ok)
469+ require.Equal(t, domain.SessionID("b"), target.Session)
470+ require.Equal(t, 2, m.SelectedIndex())
471+
472+ empty := New(nil, SelectionConfig{Mode: SelectNavigationTab})
473+ empty.SelectNearestRow(0)
474+ _, ok = empty.Selected()
475+ require.False(t, ok)
476+ require.Equal(t, -1, empty.SelectedIndex())
477+
478+ var nilModel *Model
479+ nilModel.SelectNearestRow(3)
480+ require.Equal(t, -1, nilModel.SelectedIndex())
481+}
482+
483+func TestSelectedIndexReportsRawSelectedRow(t *testing.T) {
484+ m := New([]SessionView{
485+ {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}, {TabID: "two", Name: "two"}}},
486+ }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "alpha", TabID: "two"}})
487+
488+ require.Equal(t, 2, m.SelectedIndex())
489+ m.Up()
490+ require.Equal(t, 1, m.SelectedIndex())
491+}
492+
409493 func TestStoppedSessionSelectableAndRendered(t *testing.T) {
410494 m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
411495 got, ok := m.Selected()
@@ -416,6 +500,27 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) {
416500 require.Equal(t, '(', frame.At(5, 0).Rune)
417501 }
418502
503+func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
504+ live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}}
505+ halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true, Tabs: []TabEntry{{}}}
506+ m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab})
507+
508+ stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim}
509+ styles := defaultRenderStyles()
510+ require.Equal(t, stoppedStyle, styles.Stopped)
511+
512+ frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
513+ // Rows: 0 "work" header, 1 " tab" (selected), 2 "old (stopped)" header, 3 its tab row.
514+ require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic")
515+ require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic")
516+ require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style")
517+
518+ selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}})
519+ selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
520+ require.NotEqual(t, stoppedStyle, selectedFrame.Row(3)[0].Style, "selected stopped row keeps selection style, not Stopped")
521+ require.True(t, selectedFrame.Row(3)[0].Style.Inverse, "selected stopped row still shows selection")
522+}
523+
419524 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
420525 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
421526 preview := Preview{
@@ -406,6 +406,90 @@ func TestSelectedMapping(t *testing.T) {
406 require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got)406 require.Equal(t, Target{Session: "beta", TabID: "three", TabIndex: 1}, got)
407 }407 }
408 408
409+func TestSelectNearestRow(t *testing.T) {
410+ // Three single-tab sessions: rows are [hdrA, tabA, hdrB, tabB, hdrC, tabC].
411+ sessions := []SessionView{
412+ {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}},
413+ {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}},
414+ {ID: "c", Name: "c", Tabs: []TabEntry{{TabID: "tc", Name: "tc"}}},
415+ }
416+ tests := []struct {
417+ name string
418+ idx int
419+ cfg SelectionConfig
420+ // wantStart, when set, pins the selection New produced before
421+ // SelectNearestRow runs, so a case cannot silently degrade into one a
422+ // no-op implementation would also pass.
423+ wantStart domain.SessionID
424+ want domain.SessionID
425+ }{
426+ {name: "exact selectable row", idx: 3, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "b"},
427+ {name: "header row snaps to its tab", idx: 2, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "b"},
428+ {name: "past end clamps to last selectable", idx: 40, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "c"},
429+ {
430+ // Starts selection on "c" (via Current) so a no-op SelectNearestRow
431+ // would leave the selection on "c" and this case would fail; the
432+ // plain SelectNavigationTab config used above starts on "a" already,
433+ // which can't tell a real clamp-to-first from a no-op.
434+ name: "negative clamps to first selectable",
435+ idx: -2,
436+ cfg: SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "c", TabID: "tc"}},
437+ wantStart: "c",
438+ want: "a",
439+ },
440+ {name: "last row is a tab and stays put", idx: 5, cfg: SelectionConfig{Mode: SelectNavigationTab}, want: "c"},
441+ }
442+ for _, tc := range tests {
443+ t.Run(tc.name, func(t *testing.T) {
444+ m := New(sessions, tc.cfg)
445+ if tc.wantStart != "" {
446+ pre, ok := m.Selected()
447+ require.True(t, ok)
448+ require.Equal(t, tc.wantStart, pre.Session, "guard: starting selection must not already be the expected result")
449+ }
450+ m.SelectNearestRow(tc.idx)
451+ target, ok := m.Selected()
452+ require.True(t, ok)
453+ require.Equal(t, tc.want, target.Session)
454+ })
455+ }
456+}
457+
458+func TestSelectNearestRowFallsBackBackwardAndToleratesEmptyModels(t *testing.T) {
459+ // Move-tab mode makes only session headers selectable, so the trailing tab
460+ // row has no selectable row at or after it.
461+ m := New([]SessionView{
462+ {ID: "a", Name: "a", Tabs: []TabEntry{{TabID: "ta", Name: "ta"}}},
463+ {ID: "b", Name: "b", Tabs: []TabEntry{{TabID: "tb", Name: "tb"}}},
464+ }, SelectionConfig{Mode: SelectMoveTabSession, Source: SourceFilter{Session: "zzz"}})
465+ require.Equal(t, 0, m.SelectedIndex(), "guard: the backward fallback below must move the selection off this starting row")
466+ m.SelectNearestRow(3)
467+ target, ok := m.Selected()
468+ require.True(t, ok)
469+ require.Equal(t, domain.SessionID("b"), target.Session)
470+ require.Equal(t, 2, m.SelectedIndex())
471+
472+ empty := New(nil, SelectionConfig{Mode: SelectNavigationTab})
473+ empty.SelectNearestRow(0)
474+ _, ok = empty.Selected()
475+ require.False(t, ok)
476+ require.Equal(t, -1, empty.SelectedIndex())
477+
478+ var nilModel *Model
479+ nilModel.SelectNearestRow(3)
480+ require.Equal(t, -1, nilModel.SelectedIndex())
481+}
482+
483+func TestSelectedIndexReportsRawSelectedRow(t *testing.T) {
484+ m := New([]SessionView{
485+ {ID: "alpha", Name: "alpha", Tabs: []TabEntry{{TabID: "one", Name: "one"}, {TabID: "two", Name: "two"}}},
486+ }, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "alpha", TabID: "two"}})
487+
488+ require.Equal(t, 2, m.SelectedIndex())
489+ m.Up()
490+ require.Equal(t, 1, m.SelectedIndex())
491+}
492+
409 func TestStoppedSessionSelectableAndRendered(t *testing.T) {493 func TestStoppedSessionSelectableAndRendered(t *testing.T) {
410 m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})494 m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
411 got, ok := m.Selected()495 got, ok := m.Selected()
@@ -416,6 +500,27 @@ func TestStoppedSessionSelectableAndRendered(t *testing.T) {
416 require.Equal(t, '(', frame.At(5, 0).Rune)500 require.Equal(t, '(', frame.At(5, 0).Rune)
417 }501 }
418 502
503+func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
504+ live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}}
505+ halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true, Tabs: []TabEntry{{}}}
506+ m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab})
507+
508+ stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim}
509+ styles := defaultRenderStyles()
510+ require.Equal(t, stoppedStyle, styles.Stopped)
511+
512+ frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
513+ // Rows: 0 "work" header, 1 " tab" (selected), 2 "old (stopped)" header, 3 its tab row.
514+ require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic")
515+ require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic")
516+ require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style")
517+
518+ selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}})
519+ selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
520+ require.NotEqual(t, stoppedStyle, selectedFrame.Row(3)[0].Style, "selected stopped row keeps selection style, not Stopped")
521+ require.True(t, selectedFrame.Row(3)[0].Style.Inverse, "selected stopped row still shows selection")
522+}
523+
419 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {524 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
420 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})525 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
421 preview := Preview{526 preview := Preview{

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