detached d73dd26

feat(picker): add compact searchable session UI (#240)

* feat(picker): add compact searchable session UI

* fix(picker): harden search and refresh state

* fix(picker): preserve stable refresh identity

* fix(picker): bound compact title geometry

* fix(picker): address PR review findings
commit
d73dd26f6842c23fbb93855f22dfbed4acb28f02
author
bnema · · 2026-08-30 02:48 UTC
committer
GitHub · · 2026-08-30 02:48 UTC
parent
c8de770
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +1467 -135
M README.md +2 -0
@@ -65,6 +65,8 @@ Type a code or fuzzy-search the palette. Common default codes:
6565 | `MFP` / `MAT` | move pane / tab | `SSP` | session picker |
6666 | `DET` | detach | `NTC` | notifications |
6767
68+In the session picker, press `/` to fuzzy-search session names, hosts, tab names, and tab details; use Arrow keys or Ctrl+n/Ctrl+p to move between matches. Escape clears the query, and a second Escape leaves search.
69+
6870 When a client is attached to both a local and a remote daemon, `CNS` and `CNS <name>` list eligible daemon destinations in the existing palette. If two or more destinations exist, choose one explicitly with Up or Down before pressing Enter. The unqualified choice appears only when the client has an exact local route; remote choices remain host-qualified. A direct remote start without a proven local route never labels its home route as local.
6971
7072 Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md).
@@ -65,6 +65,8 @@ Type a code or fuzzy-search the palette. Common default codes:
65 | `MFP` / `MAT` | move pane / tab | `SSP` | session picker |65 | `MFP` / `MAT` | move pane / tab | `SSP` | session picker |
66 | `DET` | detach | `NTC` | notifications |66 | `DET` | detach | `NTC` | notifications |
67 67
68+In the session picker, press `/` to fuzzy-search session names, hosts, tab names, and tab details; use Arrow keys or Ctrl+n/Ctrl+p to move between matches. Escape clears the query, and a second Escape leaves search.
69+
68 When a client is attached to both a local and a remote daemon, `CNS` and `CNS <name>` list eligible daemon destinations in the existing palette. If two or more destinations exist, choose one explicitly with Up or Down before pressing Enter. The unqualified choice appears only when the client has an exact local route; remote choices remain host-qualified. A direct remote start without a proven local route never labels its home route as local.70 When a client is attached to both a local and a remote daemon, `CNS` and `CNS <name>` list eligible daemon destinations in the existing palette. If two or more destinations exist, choose one explicitly with Up or Down before pressing Enter. The unqualified choice appears only when the client has an exact local route; remote choices remain host-qualified. A direct remote start without a proven local route never labels its home route as local.
69 71
70 Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md).72 Scroll up to enter copy mode; use vim motions, `v` to select, and `y` to copy. All codes and bindings are configurable in [configuration](docs/configuration.md).
M internal/usecase/daemon/move_picker.go +8 -8
@@ -173,6 +173,8 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
173173 }
174174 intent, source := rt.pickerIntent, rt.pickerSource
175175 observedModel, observedGeneration := rt.picker, rt.pickerGeneration
176+ rt.pickerRefreshSequence++
177+ refreshSequence := rt.pickerRefreshSequence
176178 current := picker.SourceFilter{}
177179 if intent != pickerNavigate || opts.preserveSelection {
178180 cursor, _ := rt.picker.Cursor()
@@ -187,19 +189,17 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
187189 }
188190 if intent != pickerNavigate {
189191 if _, ok := model.Selected(); !ok {
190- d.closePickerIfCurrent(ac, observedModel, observedGeneration)
192+ d.closePickerIfCurrentRefresh(ac, observedModel, observedGeneration, refreshSequence)
191193 return
192194 }
193195 }
194- // Navigate-only: move intents keep the selection-or-close logic above
195- // authoritative, so a stale row index can never override it.
196- if intent == pickerNavigate && opts.nearestRow >= 0 {
197- model.SelectNearestRow(opts.nearestRow)
198- }
199196 rt.pickerMu.Lock()
200- updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerIntent == intent && rt.pickerSource == source
197+ updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerRefreshSequence == refreshSequence && rt.pickerIntent == intent && rt.pickerSource == source
201198 if updated {
202- rt.picker = model
199+ rt.picker.ReplaceFrom(model)
200+ if intent == pickerNavigate && opts.nearestRow >= 0 {
201+ rt.picker.SelectNearestRow(opts.nearestRow)
202+ }
203203 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
204204 }
205205 rt.pickerMu.Unlock()
@@ -173,6 +173,8 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
173 }173 }
174 intent, source := rt.pickerIntent, rt.pickerSource174 intent, source := rt.pickerIntent, rt.pickerSource
175 observedModel, observedGeneration := rt.picker, rt.pickerGeneration175 observedModel, observedGeneration := rt.picker, rt.pickerGeneration
176+ rt.pickerRefreshSequence++
177+ refreshSequence := rt.pickerRefreshSequence
176 current := picker.SourceFilter{}178 current := picker.SourceFilter{}
177 if intent != pickerNavigate || opts.preserveSelection {179 if intent != pickerNavigate || opts.preserveSelection {
178 cursor, _ := rt.picker.Cursor()180 cursor, _ := rt.picker.Cursor()
@@ -187,19 +189,17 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
187 }189 }
188 if intent != pickerNavigate {190 if intent != pickerNavigate {
189 if _, ok := model.Selected(); !ok {191 if _, ok := model.Selected(); !ok {
190- d.closePickerIfCurrent(ac, observedModel, observedGeneration)192+ d.closePickerIfCurrentRefresh(ac, observedModel, observedGeneration, refreshSequence)
191 return193 return
192 }194 }
193 }195 }
194- // Navigate-only: move intents keep the selection-or-close logic above
195- // authoritative, so a stale row index can never override it.
196- if intent == pickerNavigate && opts.nearestRow >= 0 {
197- model.SelectNearestRow(opts.nearestRow)
198- }
199 rt.pickerMu.Lock()196 rt.pickerMu.Lock()
200- updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerIntent == intent && rt.pickerSource == source197+ updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerRefreshSequence == refreshSequence && rt.pickerIntent == intent && rt.pickerSource == source
201 if updated {198 if updated {
202- rt.picker = model199+ rt.picker.ReplaceFrom(model)
200+ if intent == pickerNavigate && opts.nearestRow >= 0 {
201+ rt.picker.SelectNearestRow(opts.nearestRow)
202+ }
203 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))203 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
204 }204 }
205 rt.pickerMu.Unlock()205 rt.pickerMu.Unlock()
M internal/usecase/daemon/move_picker_test.go +54 -0
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "sync/atomic"
45 "testing"
56 "time"
67
@@ -297,6 +298,59 @@ func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
297298 ac.overlays.pickerMu.Unlock()
298299 }
299300
301+func TestMovePickerOlderEmptyRefreshCannotCloseNewerValidRefresh(t *testing.T) {
302+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
303+ defer releaseAll(releases)
304+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
305+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
306+ TabID: "source-tab", PaneID: "source-pane",
307+ }))
308+ d.mu.Lock()
309+ delete(d.sessions, destination.id)
310+ d.mu.Unlock()
311+
312+ rebuildReached := make(chan struct{})
313+ allowOld := make(chan struct{})
314+ var builds atomic.Int32
315+ ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
316+ if builds.Add(1) == 1 {
317+ close(rebuildReached)
318+ select {
319+ case <-allowOld:
320+ case <-time.After(time.Second):
321+ t.Error("timed out waiting to release the older move-picker refresh")
322+ }
323+ }
324+ }
325+ oldDone := make(chan struct{})
326+ go func() {
327+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
328+ close(oldDone)
329+ }()
330+ select {
331+ case <-rebuildReached:
332+ case <-time.After(time.Second):
333+ t.Fatal("timed out waiting for the first move-picker refresh rebuild")
334+ }
335+
336+ d.mu.Lock()
337+ d.sessions[destination.id] = destination
338+ d.mu.Unlock()
339+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
340+ close(allowOld)
341+ select {
342+ case <-oldDone:
343+ case <-time.After(time.Second):
344+ t.Fatal("timed out waiting for the older move-picker refresh")
345+ }
346+
347+ require.True(t, ac.overlays.pickerActive(), "the older empty refresh must not close the newer in-place publication")
348+ ac.overlays.pickerMu.Lock()
349+ _, ok := ac.overlays.picker.Selected()
350+ ac.overlays.pickerMu.Unlock()
351+ require.True(t, ok)
352+}
353+
300354 func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) {
301355 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
302356 defer releaseAll(releases)
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "sync/atomic"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -297,6 +298,59 @@ func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {
297 ac.overlays.pickerMu.Unlock()298 ac.overlays.pickerMu.Unlock()
298 }299 }
299 300
301+func TestMovePickerOlderEmptyRefreshCannotCloseNewerValidRefresh(t *testing.T) {
302+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
303+ defer releaseAll(releases)
304+ require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{
305+ Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
306+ TabID: "source-tab", PaneID: "source-pane",
307+ }))
308+ d.mu.Lock()
309+ delete(d.sessions, destination.id)
310+ d.mu.Unlock()
311+
312+ rebuildReached := make(chan struct{})
313+ allowOld := make(chan struct{})
314+ var builds atomic.Int32
315+ ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
316+ if builds.Add(1) == 1 {
317+ close(rebuildReached)
318+ select {
319+ case <-allowOld:
320+ case <-time.After(time.Second):
321+ t.Error("timed out waiting to release the older move-picker refresh")
322+ }
323+ }
324+ }
325+ oldDone := make(chan struct{})
326+ go func() {
327+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
328+ close(oldDone)
329+ }()
330+ select {
331+ case <-rebuildReached:
332+ case <-time.After(time.Second):
333+ t.Fatal("timed out waiting for the first move-picker refresh rebuild")
334+ }
335+
336+ d.mu.Lock()
337+ d.sessions[destination.id] = destination
338+ d.mu.Unlock()
339+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
340+ close(allowOld)
341+ select {
342+ case <-oldDone:
343+ case <-time.After(time.Second):
344+ t.Fatal("timed out waiting for the older move-picker refresh")
345+ }
346+
347+ require.True(t, ac.overlays.pickerActive(), "the older empty refresh must not close the newer in-place publication")
348+ ac.overlays.pickerMu.Lock()
349+ _, ok := ac.overlays.picker.Selected()
350+ ac.overlays.pickerMu.Unlock()
351+ require.True(t, ok)
352+}
353+
300 func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) {354 func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) {
301 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)355 d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
302 defer releaseAll(releases)356 defer releaseAll(releases)
M internal/usecase/daemon/overlay_runtime.go +4 -1
@@ -23,7 +23,10 @@ type overlayRuntime struct {
2323 // pickerGeneration identifies one open lifecycle. It advances only when a
2424 // picker is published, so delayed close and registration work can prove it
2525 // still owns the exact lifecycle it captured.
26- pickerGeneration uint64
26+ pickerGeneration uint64
27+ // pickerRefreshSequence advances for every rebuild attempt in one open
28+ // lifecycle. A zero close token deliberately disables this extra fence.
29+ pickerRefreshSequence uint64
2730 pickerTitle string
2831 pickerIntent pickerIntent
2932 pickerSource moveSourceLocator
@@ -23,7 +23,10 @@ type overlayRuntime struct {
23 // pickerGeneration identifies one open lifecycle. It advances only when a23 // pickerGeneration identifies one open lifecycle. It advances only when a
24 // picker is published, so delayed close and registration work can prove it24 // picker is published, so delayed close and registration work can prove it
25 // still owns the exact lifecycle it captured.25 // still owns the exact lifecycle it captured.
26- pickerGeneration uint6426+ pickerGeneration uint64
27+ // pickerRefreshSequence advances for every rebuild attempt in one open
28+ // lifecycle. A zero close token deliberately disables this extra fence.
29+ pickerRefreshSequence uint64
27 pickerTitle string30 pickerTitle string
28 pickerIntent pickerIntent31 pickerIntent pickerIntent
29 pickerSource moveSourceLocator32 pickerSource moveSourceLocator
M internal/usecase/daemon/picker.go +134 -29
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/protocol"
1313 "github.com/bnema/vev/internal/protocol/catalogue"
14+ "github.com/bnema/vev/internal/usecase/keys"
1415 "github.com/bnema/vev/internal/usecase/layout"
1516 "github.com/bnema/vev/internal/usecase/picker"
1617 "github.com/bnema/vev/internal/usecase/ui"
@@ -177,32 +178,56 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
177178 }
178179 }
179180 views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)
180- for _, item := range live {
181- views = append(views, item.view.pickerView())
181+ grouped := pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped
182+ for i, item := range live {
183+ view := item.view.pickerView()
184+ if grouped && i == 0 {
185+ view.Section = "LOCAL"
186+ }
187+ views = append(views, view)
182188 }
189+ publishedRemote := false
183190 for _, host := range catalog {
191+ publishedForHost := 0
184192 for _, session := range host.entry.Sessions {
185193 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
186194 if key.Validate() != nil {
187195 continue
188196 }
189- views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
197+ view := remotePickerView(key, session, host.status, host.entry.FetchedAt)
198+ if grouped {
199+ view.HideRemoteOrigin = true
200+ if publishedForHost == 0 {
201+ view.Section = "REMOTE " + host.entry.Host
202+ }
203+ }
204+ views = append(views, view)
205+ publishedForHost++
206+ publishedRemote = true
190207 }
191208 if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
192- views = append(views, remotePickerHostView(host.entry.Host, host.status))
209+ view := remotePickerHostView(host.entry.Host, host.status)
210+ if grouped {
211+ view.Section = "REMOTE " + host.entry.Host
212+ }
213+ views = append(views, view)
214+ publishedRemote = true
193215 }
194216 }
195- for _, s := range stopped {
217+ for i, s := range stopped {
196218 createdAt := s.createdAt
197- views = append(views, picker.SessionView{
219+ view := picker.SessionView{
198220 ID: domain.SessionID("stopped:" + s.name),
199221 Incarnation: s.incarnation,
200222 Name: s.name,
201223 TargetName: s.name,
202- Tabs: []picker.TabEntry{{}},
203224 Stopped: true,
204225 ExpectedCreatedAt: &createdAt,
205- })
226+ }
227+ if grouped && i == 0 && (len(live) == 0 || publishedRemote) {
228+ view.Section = "LOCAL"
229+ }
230+ views = append(views, view)
206231 }
207232 return views, current
208233 }
@@ -263,6 +288,29 @@ func remotePickerReasonText(reason string) string {
263288 }
264289 }
265290
291+func pickerSearchSlashIndex(data, pending []byte) int {
292+ if len(pending) != 0 {
293+ return -1
294+ }
295+ for offset := 0; offset < len(data); {
296+ if data[offset] == '/' {
297+ return offset
298+ }
299+ if data[offset] != keys.ESC {
300+ offset++
301+ continue
302+ }
303+ if consumed, _ := routeListEscape(data[offset:]); consumed > 0 {
304+ offset += consumed
305+ continue
306+ }
307+ // An incomplete or unknown escape owns the rest of this input chunk;
308+ // slash bytes inside it cannot transition the picker into search mode.
309+ return -1
310+ }
311+ return -1
312+}
313+
266314 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
267315 rt := ac.overlays
268316 var previewGeneration uint64
@@ -305,22 +353,40 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
305353 rt.pickerMu.Unlock()
306354 return
307355 }
308- result := handleListInputLocked(d.clock, data, d.pickerListInputState(ac), func(b byte) listInputResult {
356+ normalAction := func(b byte) listInputResult {
309357 switch b {
310- case 'x':
311- return listInputResult{action: b, stop: true}
312- case 's':
358+ case 'x', 's':
313359 return listInputResult{action: b, stop: true}
314360 case '\r', '\n':
315361 return listInputResult{action: b, exit: true}
316362 default:
317363 return listInputResult{}
318364 }
319- })
365+ }
366+ var result listInputResult
367+ if rt.picker.SearchActive() {
368+ result = d.handlePickerSearchInputLocked(ac, data)
369+ } else if slash := pickerSearchSlashIndex(data, rt.pickerPending); slash >= 0 {
370+ result = handleListInputLocked(d.clock, data[:slash], d.pickerListInputState(ac), normalAction)
371+ if result.action == 0 && !result.exit && len(rt.pickerPending) == 0 {
372+ rt.picker.EnterSearch()
373+ result.changed = true
374+ if slash+1 < len(data) {
375+ searchResult := d.handlePickerSearchInputLocked(ac, data[slash+1:])
376+ searchResult.changed = searchResult.changed || result.changed
377+ result = searchResult
378+ }
379+ }
380+ } else {
381+ result = handleListInputLocked(d.clock, data, d.pickerListInputState(ac), normalAction)
382+ }
320383 var target picker.Target
321384 var cursor picker.Target
322385 var ok bool
323386 var cursorOK bool
387+ var rejectedBySearch bool
388+ observedPicker := rt.picker
389+ observedGeneration := rt.pickerGeneration
324390 var intent pickerIntent
325391 var source moveSourceLocator
326392 // prevIdx is the row the victim occupied; -1 means "no post-delete hint".
@@ -328,17 +394,25 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
328394 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
329395 target, ok = rt.picker.Selected()
330396 cursor, cursorOK = rt.picker.Cursor()
397+ rejectedBySearch = rt.picker.SelectionRejectedBySearch()
331398 prevIdx = rt.picker.SelectedIndex()
332399 }
333400 intent, source = rt.pickerIntent, rt.pickerSource
334401 rt.pickerMu.Unlock()
335402
403+ if !d.pickerInstanceCurrent(ac, observedPicker, observedGeneration) {
404+ return
405+ }
336406 if result.action == 's' {
337407 d.togglePickerSort()
338408 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
339409 d.invalidateRender(sess, ac, true, "picker.go")
340410 return
341411 }
412+ if (result.action == '\r' || result.action == '\n') && !ok && rejectedBySearch {
413+ d.invalidateRender(sess, ac, true, "picker search no match")
414+ return
415+ }
342416 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
343417 d.notifyRemotePickerUnavailable(sess, cursor)
344418 d.invalidateRender(sess, ac, true, "picker.go")
@@ -392,7 +466,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
392466 }
393467 backNavigationSent = true
394468 }
395- d.closePicker(ac)
469+ d.closePickerIfCurrent(ac, observedPicker, observedGeneration)
396470 }
397471 if committing {
398472 if intent == pickerMovePane || intent == pickerMoveTab {
@@ -404,7 +478,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
404478 if len(effects) != 0 && effects[0] != nil {
405479 effects[0].End()
406480 }
407- d.closePicker(ac)
481+ if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) {
482+ return
483+ }
408484 err := d.commitMovePickerSelection(intent, source, target)
409485 if err != nil {
410486 d.reportAttachmentError(sess, movePickerUserError(err))
@@ -416,9 +492,11 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
416492 }
417493 var err error
418494 if len(effects) != 0 && effects[0] != nil {
419- err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select")
495+ err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true, pickerModel: observedPicker, pickerGeneration: observedGeneration}, "picker-select")
420496 } else {
421- d.closePicker(ac)
497+ if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) {
498+ return
499+ }
422500 err = d.switchToTarget(sess, ac, target)
423501 }
424502 if errors.Is(err, errAttachmentTransition) {
@@ -595,8 +673,9 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ
595673 }
596674
597675 func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {
598- layout := picker.ChooseLayout(size)
599- width, height := layout.Preview.Width, layout.Preview.Height
676+ presentation := pickerModal.Resolve(size)
677+ geometry := picker.ChooseGeometry(rectSize(presentation.Inner))
678+ width, height := geometry.Preview.Width, geometry.Preview.Height
600679 if width <= 0 || height <= 0 {
601680 return 0, 0
602681 }
@@ -712,15 +791,31 @@ func (d *Daemon) closePicker(ac *attachedClient) {
712791 d.closePickerIfCurrent(ac, nil, 0)
713792 }
714793
794+func (d *Daemon) pickerInstanceCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
795+ if ac == nil || ac.overlays == nil {
796+ return false
797+ }
798+ ac.overlays.pickerMu.Lock()
799+ defer ac.overlays.pickerMu.Unlock()
800+ return ac.overlays.picker == model && ac.overlays.pickerGeneration == generation
801+}
802+
715803 // closePickerIfCurrent closes only the observed picker lifecycle. A nonzero
716804 // generation guards terminal parked cleanup even when a refresh replaced the
717805 // model within that generation.
718806 func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
807+ return d.closePickerIfCurrentRefresh(ac, model, generation, 0)
808+}
809+
810+func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.Model, generation, refreshSequence uint64) bool {
719811 if ac == nil || ac.overlays == nil {
720812 return false
721813 }
722814 ac.overlays.pickerMu.Lock()
723- if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation {
815+ refreshChanged := refreshSequence != 0 && ac.overlays.pickerRefreshSequence != refreshSequence
816+ modelChanged := model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation)
817+ generationChanged := model == nil && generation != 0 && ac.overlays.pickerGeneration != generation
818+ if refreshChanged || modelChanged || generationChanged {
724819 ac.overlays.pickerMu.Unlock()
725820 return false
726821 }
@@ -777,9 +872,19 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
777872 // one active-session handoff. Its zero value keeps daemon-owned transitions on
778873 // their established path.
779874 type sessionHandoffGuard struct {
780- expectedSource *tab
781- closePicker bool
782- allowSamePeer bool
875+ expectedSource *tab
876+ closePicker bool
877+ allowSamePeer bool
878+ pickerModel *picker.Model
879+ pickerGeneration uint64
880+}
881+
882+func (d *Daemon) closePickerForGuard(ac *attachedClient, guard sessionHandoffGuard) bool {
883+ if guard.pickerModel != nil || guard.pickerGeneration != 0 {
884+ return d.closePickerIfCurrent(ac, guard.pickerModel, guard.pickerGeneration)
885+ }
886+ d.closePicker(ac)
887+ return true
783888 }
784889
785890 // switchActiveTargetForAttachment hands a frame-bound navigation request to the
@@ -849,7 +954,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect
849954 if guard.closePicker {
850955 fresh, admitted := effect.ac.beginAttachmentEffect(transition.published)
851956 if admitted {
852- d.closePicker(effect.ac)
957+ d.closePickerForGuard(effect.ac, guard)
853958 fresh.End()
854959 }
855960 }
@@ -958,7 +1063,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
9581063 // Keep the source link open until the client receives the ordered cleanup
9591064 // and handoff frames, then let the client's close drive ordinary parking.
9601065 if guard.closePicker {
961- d.closePicker(effect.ac)
1066+ d.closePickerForGuard(effect.ac, guard)
9621067 }
9631068 effect.bindActionEnd(d, "detach")
9641069 effect.End()
@@ -999,7 +1104,7 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t
9991104 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
10001105 }
10011106 if guard.closePicker {
1002- d.closePicker(effect.ac)
1107+ d.closePickerForGuard(effect.ac, guard)
10031108 }
10041109 d.clientGoneForAttachment(effect, false)
10051110 return nil
@@ -1109,11 +1214,11 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
11091214 if sourceEffect != nil {
11101215 fresh, admitted := ac.beginAttachmentEffect(transition.published)
11111216 if admitted {
1112- d.closePicker(ac)
1217+ d.closePickerForGuard(ac, guard)
11131218 fresh.End()
11141219 }
11151220 } else {
1116- d.closePicker(ac)
1221+ d.closePickerForGuard(ac, guard)
11171222 }
11181223 }
11191224 d.deferAttachmentTransitionCleanups(transition)
@@ -1397,7 +1502,7 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, effect *att
13971502
13981503 func (d *Daemon) killPickerTarget(target picker.Target) error {
13991504 if target.Stopped {
1400- if err := d.retryStoppedPurge(target.Name); err != nil {
1505+ if err := d.retryStoppedPurgeExact(target.Name, target.Incarnation, target.ExpectedCreatedAt); err != nil {
14011506 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
14021507 return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
14031508 }
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/protocol/catalogue"13 "github.com/bnema/vev/internal/protocol/catalogue"
14+ "github.com/bnema/vev/internal/usecase/keys"
14 "github.com/bnema/vev/internal/usecase/layout"15 "github.com/bnema/vev/internal/usecase/layout"
15 "github.com/bnema/vev/internal/usecase/picker"16 "github.com/bnema/vev/internal/usecase/picker"
16 "github.com/bnema/vev/internal/usecase/ui"17 "github.com/bnema/vev/internal/usecase/ui"
@@ -177,32 +178,56 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
177 }178 }
178 }179 }
179 views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)180 views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)
180- for _, item := range live {181+ grouped := pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped
181- views = append(views, item.view.pickerView())182+ for i, item := range live {
183+ view := item.view.pickerView()
184+ if grouped && i == 0 {
185+ view.Section = "LOCAL"
186+ }
187+ views = append(views, view)
182 }188 }
189+ publishedRemote := false
183 for _, host := range catalog {190 for _, host := range catalog {
191+ publishedForHost := 0
184 for _, session := range host.entry.Sessions {192 for _, session := range host.entry.Sessions {
185 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}193 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
186 if key.Validate() != nil {194 if key.Validate() != nil {
187 continue195 continue
188 }196 }
189- views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))197+ view := remotePickerView(key, session, host.status, host.entry.FetchedAt)
198+ if grouped {
199+ view.HideRemoteOrigin = true
200+ if publishedForHost == 0 {
201+ view.Section = "REMOTE " + host.entry.Host
202+ }
203+ }
204+ views = append(views, view)
205+ publishedForHost++
206+ publishedRemote = true
190 }207 }
191 if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {208 if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
192- views = append(views, remotePickerHostView(host.entry.Host, host.status))209+ view := remotePickerHostView(host.entry.Host, host.status)
210+ if grouped {
211+ view.Section = "REMOTE " + host.entry.Host
212+ }
213+ views = append(views, view)
214+ publishedRemote = true
193 }215 }
194 }216 }
195- for _, s := range stopped {217+ for i, s := range stopped {
196 createdAt := s.createdAt218 createdAt := s.createdAt
197- views = append(views, picker.SessionView{219+ view := picker.SessionView{
198 ID: domain.SessionID("stopped:" + s.name),220 ID: domain.SessionID("stopped:" + s.name),
199 Incarnation: s.incarnation,221 Incarnation: s.incarnation,
200 Name: s.name,222 Name: s.name,
201 TargetName: s.name,223 TargetName: s.name,
202- Tabs: []picker.TabEntry{{}},
203 Stopped: true,224 Stopped: true,
204 ExpectedCreatedAt: &createdAt,225 ExpectedCreatedAt: &createdAt,
205- })226+ }
227+ if grouped && i == 0 && (len(live) == 0 || publishedRemote) {
228+ view.Section = "LOCAL"
229+ }
230+ views = append(views, view)
206 }231 }
207 return views, current232 return views, current
208 }233 }
@@ -263,6 +288,29 @@ func remotePickerReasonText(reason string) string {
263 }288 }
264 }289 }
265 290
291+func pickerSearchSlashIndex(data, pending []byte) int {
292+ if len(pending) != 0 {
293+ return -1
294+ }
295+ for offset := 0; offset < len(data); {
296+ if data[offset] == '/' {
297+ return offset
298+ }
299+ if data[offset] != keys.ESC {
300+ offset++
301+ continue
302+ }
303+ if consumed, _ := routeListEscape(data[offset:]); consumed > 0 {
304+ offset += consumed
305+ continue
306+ }
307+ // An incomplete or unknown escape owns the rest of this input chunk;
308+ // slash bytes inside it cannot transition the picker into search mode.
309+ return -1
310+ }
311+ return -1
312+}
313+
266 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {314 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
267 rt := ac.overlays315 rt := ac.overlays
268 var previewGeneration uint64316 var previewGeneration uint64
@@ -305,22 +353,40 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
305 rt.pickerMu.Unlock()353 rt.pickerMu.Unlock()
306 return354 return
307 }355 }
308- result := handleListInputLocked(d.clock, data, d.pickerListInputState(ac), func(b byte) listInputResult {356+ normalAction := func(b byte) listInputResult {
309 switch b {357 switch b {
310- case 'x':358+ case 'x', 's':
311- return listInputResult{action: b, stop: true}
312- case 's':
313 return listInputResult{action: b, stop: true}359 return listInputResult{action: b, stop: true}
314 case '\r', '\n':360 case '\r', '\n':
315 return listInputResult{action: b, exit: true}361 return listInputResult{action: b, exit: true}
316 default:362 default:
317 return listInputResult{}363 return listInputResult{}
318 }364 }
319- })365+ }
366+ var result listInputResult
367+ if rt.picker.SearchActive() {
368+ result = d.handlePickerSearchInputLocked(ac, data)
369+ } else if slash := pickerSearchSlashIndex(data, rt.pickerPending); slash >= 0 {
370+ result = handleListInputLocked(d.clock, data[:slash], d.pickerListInputState(ac), normalAction)
371+ if result.action == 0 && !result.exit && len(rt.pickerPending) == 0 {
372+ rt.picker.EnterSearch()
373+ result.changed = true
374+ if slash+1 < len(data) {
375+ searchResult := d.handlePickerSearchInputLocked(ac, data[slash+1:])
376+ searchResult.changed = searchResult.changed || result.changed
377+ result = searchResult
378+ }
379+ }
380+ } else {
381+ result = handleListInputLocked(d.clock, data, d.pickerListInputState(ac), normalAction)
382+ }
320 var target picker.Target383 var target picker.Target
321 var cursor picker.Target384 var cursor picker.Target
322 var ok bool385 var ok bool
323 var cursorOK bool386 var cursorOK bool
387+ var rejectedBySearch bool
388+ observedPicker := rt.picker
389+ observedGeneration := rt.pickerGeneration
324 var intent pickerIntent390 var intent pickerIntent
325 var source moveSourceLocator391 var source moveSourceLocator
326 // prevIdx is the row the victim occupied; -1 means "no post-delete hint".392 // prevIdx is the row the victim occupied; -1 means "no post-delete hint".
@@ -328,17 +394,25 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
328 if result.action == 'x' || result.action == '\r' || result.action == '\n' {394 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
329 target, ok = rt.picker.Selected()395 target, ok = rt.picker.Selected()
330 cursor, cursorOK = rt.picker.Cursor()396 cursor, cursorOK = rt.picker.Cursor()
397+ rejectedBySearch = rt.picker.SelectionRejectedBySearch()
331 prevIdx = rt.picker.SelectedIndex()398 prevIdx = rt.picker.SelectedIndex()
332 }399 }
333 intent, source = rt.pickerIntent, rt.pickerSource400 intent, source = rt.pickerIntent, rt.pickerSource
334 rt.pickerMu.Unlock()401 rt.pickerMu.Unlock()
335 402
403+ if !d.pickerInstanceCurrent(ac, observedPicker, observedGeneration) {
404+ return
405+ }
336 if result.action == 's' {406 if result.action == 's' {
337 d.togglePickerSort()407 d.togglePickerSort()
338 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})408 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
339 d.invalidateRender(sess, ac, true, "picker.go")409 d.invalidateRender(sess, ac, true, "picker.go")
340 return410 return
341 }411 }
412+ if (result.action == '\r' || result.action == '\n') && !ok && rejectedBySearch {
413+ d.invalidateRender(sess, ac, true, "picker search no match")
414+ return
415+ }
342 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {416 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
343 d.notifyRemotePickerUnavailable(sess, cursor)417 d.notifyRemotePickerUnavailable(sess, cursor)
344 d.invalidateRender(sess, ac, true, "picker.go")418 d.invalidateRender(sess, ac, true, "picker.go")
@@ -392,7 +466,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
392 }466 }
393 backNavigationSent = true467 backNavigationSent = true
394 }468 }
395- d.closePicker(ac)469+ d.closePickerIfCurrent(ac, observedPicker, observedGeneration)
396 }470 }
397 if committing {471 if committing {
398 if intent == pickerMovePane || intent == pickerMoveTab {472 if intent == pickerMovePane || intent == pickerMoveTab {
@@ -404,7 +478,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
404 if len(effects) != 0 && effects[0] != nil {478 if len(effects) != 0 && effects[0] != nil {
405 effects[0].End()479 effects[0].End()
406 }480 }
407- d.closePicker(ac)481+ if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) {
482+ return
483+ }
408 err := d.commitMovePickerSelection(intent, source, target)484 err := d.commitMovePickerSelection(intent, source, target)
409 if err != nil {485 if err != nil {
410 d.reportAttachmentError(sess, movePickerUserError(err))486 d.reportAttachmentError(sess, movePickerUserError(err))
@@ -416,9 +492,11 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
416 }492 }
417 var err error493 var err error
418 if len(effects) != 0 && effects[0] != nil {494 if len(effects) != 0 && effects[0] != nil {
419- err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select")495+ err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true, pickerModel: observedPicker, pickerGeneration: observedGeneration}, "picker-select")
420 } else {496 } else {
421- d.closePicker(ac)497+ if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) {
498+ return
499+ }
422 err = d.switchToTarget(sess, ac, target)500 err = d.switchToTarget(sess, ac, target)
423 }501 }
424 if errors.Is(err, errAttachmentTransition) {502 if errors.Is(err, errAttachmentTransition) {
@@ -595,8 +673,9 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ
595 }673 }
596 674
597 func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {675 func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {
598- layout := picker.ChooseLayout(size)676+ presentation := pickerModal.Resolve(size)
599- width, height := layout.Preview.Width, layout.Preview.Height677+ geometry := picker.ChooseGeometry(rectSize(presentation.Inner))
678+ width, height := geometry.Preview.Width, geometry.Preview.Height
600 if width <= 0 || height <= 0 {679 if width <= 0 || height <= 0 {
601 return 0, 0680 return 0, 0
602 }681 }
@@ -712,15 +791,31 @@ func (d *Daemon) closePicker(ac *attachedClient) {
712 d.closePickerIfCurrent(ac, nil, 0)791 d.closePickerIfCurrent(ac, nil, 0)
713 }792 }
714 793
794+func (d *Daemon) pickerInstanceCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
795+ if ac == nil || ac.overlays == nil {
796+ return false
797+ }
798+ ac.overlays.pickerMu.Lock()
799+ defer ac.overlays.pickerMu.Unlock()
800+ return ac.overlays.picker == model && ac.overlays.pickerGeneration == generation
801+}
802+
715 // closePickerIfCurrent closes only the observed picker lifecycle. A nonzero803 // closePickerIfCurrent closes only the observed picker lifecycle. A nonzero
716 // generation guards terminal parked cleanup even when a refresh replaced the804 // generation guards terminal parked cleanup even when a refresh replaced the
717 // model within that generation.805 // model within that generation.
718 func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {806 func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
807+ return d.closePickerIfCurrentRefresh(ac, model, generation, 0)
808+}
809+
810+func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.Model, generation, refreshSequence uint64) bool {
719 if ac == nil || ac.overlays == nil {811 if ac == nil || ac.overlays == nil {
720 return false812 return false
721 }813 }
722 ac.overlays.pickerMu.Lock()814 ac.overlays.pickerMu.Lock()
723- if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation {815+ refreshChanged := refreshSequence != 0 && ac.overlays.pickerRefreshSequence != refreshSequence
816+ modelChanged := model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation)
817+ generationChanged := model == nil && generation != 0 && ac.overlays.pickerGeneration != generation
818+ if refreshChanged || modelChanged || generationChanged {
724 ac.overlays.pickerMu.Unlock()819 ac.overlays.pickerMu.Unlock()
725 return false820 return false
726 }821 }
@@ -777,9 +872,19 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
777 // one active-session handoff. Its zero value keeps daemon-owned transitions on872 // one active-session handoff. Its zero value keeps daemon-owned transitions on
778 // their established path.873 // their established path.
779 type sessionHandoffGuard struct {874 type sessionHandoffGuard struct {
780- expectedSource *tab875+ expectedSource *tab
781- closePicker bool876+ closePicker bool
782- allowSamePeer bool877+ allowSamePeer bool
878+ pickerModel *picker.Model
879+ pickerGeneration uint64
880+}
881+
882+func (d *Daemon) closePickerForGuard(ac *attachedClient, guard sessionHandoffGuard) bool {
883+ if guard.pickerModel != nil || guard.pickerGeneration != 0 {
884+ return d.closePickerIfCurrent(ac, guard.pickerModel, guard.pickerGeneration)
885+ }
886+ d.closePicker(ac)
887+ return true
783 }888 }
784 889
785 // switchActiveTargetForAttachment hands a frame-bound navigation request to the890 // switchActiveTargetForAttachment hands a frame-bound navigation request to the
@@ -849,7 +954,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect
849 if guard.closePicker {954 if guard.closePicker {
850 fresh, admitted := effect.ac.beginAttachmentEffect(transition.published)955 fresh, admitted := effect.ac.beginAttachmentEffect(transition.published)
851 if admitted {956 if admitted {
852- d.closePicker(effect.ac)957+ d.closePickerForGuard(effect.ac, guard)
853 fresh.End()958 fresh.End()
854 }959 }
855 }960 }
@@ -958,7 +1063,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
958 // Keep the source link open until the client receives the ordered cleanup1063 // Keep the source link open until the client receives the ordered cleanup
959 // and handoff frames, then let the client's close drive ordinary parking.1064 // and handoff frames, then let the client's close drive ordinary parking.
960 if guard.closePicker {1065 if guard.closePicker {
961- d.closePicker(effect.ac)1066+ d.closePickerForGuard(effect.ac, guard)
962 }1067 }
963 effect.bindActionEnd(d, "detach")1068 effect.bindActionEnd(d, "detach")
964 effect.End()1069 effect.End()
@@ -999,7 +1104,7 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t
999 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)1104 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
1000 }1105 }
1001 if guard.closePicker {1106 if guard.closePicker {
1002- d.closePicker(effect.ac)1107+ d.closePickerForGuard(effect.ac, guard)
1003 }1108 }
1004 d.clientGoneForAttachment(effect, false)1109 d.clientGoneForAttachment(effect, false)
1005 return nil1110 return nil
@@ -1109,11 +1214,11 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
1109 if sourceEffect != nil {1214 if sourceEffect != nil {
1110 fresh, admitted := ac.beginAttachmentEffect(transition.published)1215 fresh, admitted := ac.beginAttachmentEffect(transition.published)
1111 if admitted {1216 if admitted {
1112- d.closePicker(ac)1217+ d.closePickerForGuard(ac, guard)
1113 fresh.End()1218 fresh.End()
1114 }1219 }
1115 } else {1220 } else {
1116- d.closePicker(ac)1221+ d.closePickerForGuard(ac, guard)
1117 }1222 }
1118 }1223 }
1119 d.deferAttachmentTransitionCleanups(transition)1224 d.deferAttachmentTransitionCleanups(transition)
@@ -1397,7 +1502,7 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, effect *att
1397 1502
1398 func (d *Daemon) killPickerTarget(target picker.Target) error {1503 func (d *Daemon) killPickerTarget(target picker.Target) error {
1399 if target.Stopped {1504 if target.Stopped {
1400- if err := d.retryStoppedPurge(target.Name); err != nil {1505+ if err := d.retryStoppedPurgeExact(target.Name, target.Incarnation, target.ExpectedCreatedAt); err != nil {
1401 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)1506 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
1402 return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)1507 return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
1403 }1508 }
A internal/usecase/daemon/picker_search_input.go +130 -0
@@ -0,0 +1,130 @@
1+package daemon
2+
3+import (
4+ "unicode"
5+ "unicode/utf8"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/keys"
9+)
10+
11+// handlePickerSearchInputLocked decodes picker search input while pickerMu is
12+// held. Printable bytes belong to the query; navigation is intentionally
13+// limited to arrows and Ctrl-N/Ctrl-P so names containing j/k/q/x/s remain
14+// searchable. Incomplete UTF-8 and escape sequences are retained across input
15+// chunks in the attachment-owned picker pending buffer.
16+func (d *Daemon) handlePickerSearchInputLocked(ac *attachedClient, data []byte) listInputResult {
17+ rt := ac.overlays
18+ if len(rt.pickerPending) > 0 {
19+ rt.pickerESC.stop()
20+ combined := make([]byte, 0, len(rt.pickerPending)+len(data))
21+ combined = append(combined, rt.pickerPending...)
22+ combined = append(combined, data...)
23+ data = combined
24+ rt.pickerPending = nil
25+ }
26+
27+ var result listInputResult
28+ for offset := 0; offset < len(data); {
29+ switch data[offset] {
30+ case keys.ESC:
31+ tail := data[offset:]
32+ if len(tail) > 1 && tail[1] == keys.ESC {
33+ d.applyPickerSearchEscapeLocked(ac)
34+ result.changed = true
35+ if !rt.picker.SearchActive() {
36+ result.exit = true
37+ return result
38+ }
39+ offset++
40+ continue
41+ }
42+ if consumed, move := routeListEscape(tail); consumed > 0 {
43+ switch move {
44+ case 'A':
45+ rt.picker.Up()
46+ result.changed = true
47+ case 'B':
48+ rt.picker.Down()
49+ result.changed = true
50+ }
51+ offset += consumed
52+ continue
53+ }
54+ if len(tail) == 1 {
55+ d.retainPickerSearchESCLocked(ac)
56+ return result
57+ }
58+ if isListEscapePrefix(tail) {
59+ rt.pickerPending = append(rt.pickerPending[:0], tail...)
60+ return result
61+ }
62+ offset++
63+ case 0x03: // Ctrl-C
64+ result.exit = true
65+ return result
66+ case 0x0e: // Ctrl-N
67+ rt.picker.Down()
68+ result.changed = true
69+ offset++
70+ case 0x10: // Ctrl-P
71+ rt.picker.Up()
72+ result.changed = true
73+ offset++
74+ case '\r', '\n':
75+ result.action = data[offset]
76+ result.exit = true
77+ return result
78+ case 0x08, 0x7f:
79+ before := rt.picker.Query()
80+ rt.picker.BackspaceSearch()
81+ result.changed = result.changed || before != rt.picker.Query()
82+ offset++
83+ default:
84+ if !utf8.FullRune(data[offset:]) {
85+ rt.pickerPending = append(rt.pickerPending[:0], data[offset:]...)
86+ return result
87+ }
88+ r, width := utf8.DecodeRune(data[offset:])
89+ if r == utf8.RuneError && width == 1 {
90+ offset++
91+ continue
92+ }
93+ if unicode.IsPrint(r) {
94+ rt.picker.InsertSearch(r)
95+ result.changed = true
96+ }
97+ offset += width
98+ }
99+ }
100+ return result
101+}
102+
103+func (d *Daemon) applyPickerSearchEscapeLocked(ac *attachedClient) {
104+ if ac.overlays.picker.Query() != "" {
105+ ac.overlays.picker.ClearSearch()
106+ } else {
107+ ac.overlays.picker.ExitSearch()
108+ }
109+}
110+
111+func (d *Daemon) retainPickerSearchESCLocked(ac *attachedClient) {
112+ rt := ac.overlays
113+ rt.pickerPending = append(rt.pickerPending[:0], keys.ESC)
114+ rt.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
115+ rt.pickerMu.Lock()
116+ if rt.pickerESC.timer != timer || len(rt.pickerPending) != 1 || rt.pickerPending[0] != keys.ESC || rt.picker == nil || !rt.picker.SearchActive() {
117+ rt.pickerMu.Unlock()
118+ return
119+ }
120+ rt.pickerPending = nil
121+ rt.pickerESC.timer = nil
122+ rt.pickerESC.done = nil
123+ d.applyPickerSearchEscapeLocked(ac)
124+ rt.pickerMu.Unlock()
125+ if sess := ac.currentAttachmentSession(); sess != nil {
126+ d.registerPreviewForSelection(ac)
127+ d.invalidateRender(sess, ac, true, "picker search escape")
128+ }
129+ })
130+}
@@ -0,0 +1,130 @@
1+package daemon
2+
3+import (
4+ "unicode"
5+ "unicode/utf8"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/keys"
9+)
10+
11+// handlePickerSearchInputLocked decodes picker search input while pickerMu is
12+// held. Printable bytes belong to the query; navigation is intentionally
13+// limited to arrows and Ctrl-N/Ctrl-P so names containing j/k/q/x/s remain
14+// searchable. Incomplete UTF-8 and escape sequences are retained across input
15+// chunks in the attachment-owned picker pending buffer.
16+func (d *Daemon) handlePickerSearchInputLocked(ac *attachedClient, data []byte) listInputResult {
17+ rt := ac.overlays
18+ if len(rt.pickerPending) > 0 {
19+ rt.pickerESC.stop()
20+ combined := make([]byte, 0, len(rt.pickerPending)+len(data))
21+ combined = append(combined, rt.pickerPending...)
22+ combined = append(combined, data...)
23+ data = combined
24+ rt.pickerPending = nil
25+ }
26+
27+ var result listInputResult
28+ for offset := 0; offset < len(data); {
29+ switch data[offset] {
30+ case keys.ESC:
31+ tail := data[offset:]
32+ if len(tail) > 1 && tail[1] == keys.ESC {
33+ d.applyPickerSearchEscapeLocked(ac)
34+ result.changed = true
35+ if !rt.picker.SearchActive() {
36+ result.exit = true
37+ return result
38+ }
39+ offset++
40+ continue
41+ }
42+ if consumed, move := routeListEscape(tail); consumed > 0 {
43+ switch move {
44+ case 'A':
45+ rt.picker.Up()
46+ result.changed = true
47+ case 'B':
48+ rt.picker.Down()
49+ result.changed = true
50+ }
51+ offset += consumed
52+ continue
53+ }
54+ if len(tail) == 1 {
55+ d.retainPickerSearchESCLocked(ac)
56+ return result
57+ }
58+ if isListEscapePrefix(tail) {
59+ rt.pickerPending = append(rt.pickerPending[:0], tail...)
60+ return result
61+ }
62+ offset++
63+ case 0x03: // Ctrl-C
64+ result.exit = true
65+ return result
66+ case 0x0e: // Ctrl-N
67+ rt.picker.Down()
68+ result.changed = true
69+ offset++
70+ case 0x10: // Ctrl-P
71+ rt.picker.Up()
72+ result.changed = true
73+ offset++
74+ case '\r', '\n':
75+ result.action = data[offset]
76+ result.exit = true
77+ return result
78+ case 0x08, 0x7f:
79+ before := rt.picker.Query()
80+ rt.picker.BackspaceSearch()
81+ result.changed = result.changed || before != rt.picker.Query()
82+ offset++
83+ default:
84+ if !utf8.FullRune(data[offset:]) {
85+ rt.pickerPending = append(rt.pickerPending[:0], data[offset:]...)
86+ return result
87+ }
88+ r, width := utf8.DecodeRune(data[offset:])
89+ if r == utf8.RuneError && width == 1 {
90+ offset++
91+ continue
92+ }
93+ if unicode.IsPrint(r) {
94+ rt.picker.InsertSearch(r)
95+ result.changed = true
96+ }
97+ offset += width
98+ }
99+ }
100+ return result
101+}
102+
103+func (d *Daemon) applyPickerSearchEscapeLocked(ac *attachedClient) {
104+ if ac.overlays.picker.Query() != "" {
105+ ac.overlays.picker.ClearSearch()
106+ } else {
107+ ac.overlays.picker.ExitSearch()
108+ }
109+}
110+
111+func (d *Daemon) retainPickerSearchESCLocked(ac *attachedClient) {
112+ rt := ac.overlays
113+ rt.pickerPending = append(rt.pickerPending[:0], keys.ESC)
114+ rt.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
115+ rt.pickerMu.Lock()
116+ if rt.pickerESC.timer != timer || len(rt.pickerPending) != 1 || rt.pickerPending[0] != keys.ESC || rt.picker == nil || !rt.picker.SearchActive() {
117+ rt.pickerMu.Unlock()
118+ return
119+ }
120+ rt.pickerPending = nil
121+ rt.pickerESC.timer = nil
122+ rt.pickerESC.done = nil
123+ d.applyPickerSearchEscapeLocked(ac)
124+ rt.pickerMu.Unlock()
125+ if sess := ac.currentAttachmentSession(); sess != nil {
126+ d.registerPreviewForSelection(ac)
127+ d.invalidateRender(sess, ac, true, "picker search escape")
128+ }
129+ })
130+}
M internal/usecase/daemon/picker_test.go +177 -2
@@ -190,6 +190,10 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
190190 // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the
191191 // ephemeral session has the highest mruAt.
192192 require.Equal(t, []string{"notes", "work", "1", "halted"}, names)
193+ require.Equal(t, "LOCAL", views[0].Section)
194+ require.Empty(t, views[1].Section)
195+ require.Empty(t, views[2].Section)
196+ require.Empty(t, views[3].Section, "a contiguous local-only inventory keeps one section header")
193197 }
194198
195199 func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
@@ -888,6 +892,129 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
888892 }
889893 }
890894
895+func TestPickerSearchAcceptsPrintableActionsAndSplitUTF8(t *testing.T) {
896+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
897+ defer releaseAll(releases)
898+
899+ d.enterPicker(sess, ac)
900+ awaitFrame(t, sends, wire.MsgOutput)
901+ d.handlePickerInput(ac, []byte("j/jkqxs/"))
902+ d.handlePickerInput(ac, []byte{0xc3})
903+ d.handlePickerInput(ac, []byte{0xa9})
904+
905+ ac.overlays.pickerMu.Lock()
906+ query := ac.overlays.picker.Query()
907+ active := ac.overlays.picker.SearchActive()
908+ ac.overlays.pickerMu.Unlock()
909+ require.True(t, active)
910+ require.Equal(t, "jkqxs/é", query)
911+ require.True(t, ac.overlays.pickerActive(), "a slash after navigation must enter search and keep later action bytes printable")
912+}
913+
914+func TestPickerSlashInsideEscapeSequenceDoesNotEnterSearch(t *testing.T) {
915+ for _, chunks := range [][][]byte{
916+ {{0x1b, '[', '/', 'A'}},
917+ {{0x1b, '['}, {'/', 'A'}},
918+ } {
919+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
920+ d.enterPicker(sess, ac)
921+ awaitFrame(t, sends, wire.MsgOutput)
922+ for _, chunk := range chunks {
923+ d.handlePickerInput(ac, chunk)
924+ }
925+
926+ ac.overlays.pickerMu.Lock()
927+ searchActive := ac.overlays.picker != nil && ac.overlays.picker.SearchActive()
928+ ac.overlays.pickerMu.Unlock()
929+ require.False(t, searchActive)
930+ releaseAll(releases)
931+ }
932+}
933+
934+func TestPickerSearchZeroMatchEnterKeepsPickerOpen(t *testing.T) {
935+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
936+ defer releaseAll(releases)
937+
938+ d.enterPicker(sess, ac)
939+ awaitFrame(t, sends, wire.MsgOutput)
940+ d.handlePickerInput(ac, []byte("/absent\r"))
941+
942+ require.True(t, ac.overlays.pickerActive())
943+ ac.overlays.pickerMu.Lock()
944+ defer ac.overlays.pickerMu.Unlock()
945+ require.True(t, ac.overlays.picker.SearchActive())
946+ require.Equal(t, "absent", ac.overlays.picker.Query())
947+ _, ok := ac.overlays.picker.Selected()
948+ require.False(t, ok)
949+}
950+
951+func TestPickerSearchSplitArrowNavigatesMatches(t *testing.T) {
952+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
953+ defer releaseAll(releases)
954+
955+ d.enterPicker(sess, ac)
956+ awaitFrame(t, sends, wire.MsgOutput)
957+ d.handlePickerInput(ac, []byte("/"))
958+ d.handlePickerInput(ac, []byte("\x1b["))
959+ d.handlePickerInput(ac, []byte("B"))
960+
961+ ac.overlays.pickerMu.Lock()
962+ selected, ok := ac.overlays.picker.Selected()
963+ ac.overlays.pickerMu.Unlock()
964+ require.True(t, ok)
965+ require.Equal(t, 1, selected.TabIndex)
966+}
967+
968+func TestPickerSearchRepeatedEscapeChunkClearsThenExits(t *testing.T) {
969+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
970+ defer releaseAll(releases)
971+
972+ d.enterPicker(sess, ac)
973+ awaitFrame(t, sends, wire.MsgOutput)
974+ d.handlePickerInput(ac, []byte("/query"))
975+ d.handlePickerInput(ac, []byte("\x1b\x1b"))
976+
977+ require.True(t, ac.overlays.pickerActive())
978+ ac.overlays.pickerMu.Lock()
979+ defer ac.overlays.pickerMu.Unlock()
980+ require.True(t, ac.overlays.picker.SearchActive(), "first Escape clears while the second is retained as the next staged Escape")
981+ require.Empty(t, ac.overlays.picker.Query())
982+ require.Equal(t, []byte{0x1b}, ac.overlays.pickerPending)
983+}
984+
985+func TestPickerSearchLoneEscapeClearsThenExitsThenCloses(t *testing.T) {
986+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
987+ defer releaseAll(releases)
988+ clk := &signalClock{timers: make(chan *signalTimer, 3)}
989+ d.clock = clk
990+
991+ d.enterPicker(sess, ac)
992+ awaitFrame(t, sends, wire.MsgOutput)
993+ d.handlePickerInput(ac, []byte("/query"))
994+
995+ fireEscape := func() {
996+ d.handlePickerInput(ac, []byte("\x1b"))
997+ timer := <-clk.timers
998+ timer.ch <- time.Now()
999+ }
1000+ fireEscape()
1001+ require.Eventually(t, func() bool {
1002+ ac.overlays.pickerMu.Lock()
1003+ defer ac.overlays.pickerMu.Unlock()
1004+ return ac.overlays.picker != nil && ac.overlays.picker.SearchActive() && ac.overlays.picker.Query() == ""
1005+ }, time.Second, 5*time.Millisecond)
1006+
1007+ fireEscape()
1008+ require.Eventually(t, func() bool {
1009+ ac.overlays.pickerMu.Lock()
1010+ defer ac.overlays.pickerMu.Unlock()
1011+ return ac.overlays.picker != nil && !ac.overlays.picker.SearchActive()
1012+ }, time.Second, 5*time.Millisecond)
1013+
1014+ fireEscape()
1015+ require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
1016+}
1017+
8911018 func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) {
8921019 d, sess, ac, _, releases := newManualTabSession(t, 1)
8931020 defer releases[0]()
@@ -1212,7 +1339,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
12121339 }
12131340
12141341 inner := state.overlays.picker.inner
1215- layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
1342+ layout := picker.ChooseGeometry(domain.Size{Cols: inner.Width, Rows: inner.Height})
12161343 require.Equal(t, picker.LayoutHorizontal, layout.Mode)
12171344 require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text keeps a muted foreground on the terminal background")
12181345 require.True(t, inner.At(layout.List.Width-1, 3).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background")
@@ -1227,6 +1354,29 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
12271354 }
12281355 }
12291356
1357+func TestCaptureOverlayLayersRendersNarrowSearchTitleAndStatus(t *testing.T) {
1358+ model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{TabID: "tab", Name: "shell"}}}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
1359+ model.EnterSearch()
1360+ for _, r := range "a-very-long-absent-query" {
1361+ model.InsertSearch(r)
1362+ }
1363+ state := capturedRenderState{
1364+ theme: themeui.BuiltinDark,
1365+ styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
1366+ layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 10}},
1367+ }
1368+ snap := &overlayRenderSnapshot{pickerActive: true, pickerModel: model}
1369+
1370+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
1371+
1372+ require.True(t, strings.HasPrefix(state.overlays.picker.title, " / "))
1373+ require.True(t, strings.HasSuffix(state.overlays.picker.title, "_ "))
1374+ inner := state.overlays.picker.inner
1375+ status := rowText(inner.Row(inner.Height - 1))
1376+ require.Contains(t, status, "0 matches")
1377+ require.NotContains(t, status, "x delete", "search action bytes belong to the query")
1378+}
1379+
12301380 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
12311381 model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
12321382 state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
@@ -1251,7 +1401,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin
12511401 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
12521402
12531403 inner := state.overlays.picker.inner
1254- layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})
1404+ layout := picker.ChooseGeometry(domain.Size{Cols: inner.Width, Rows: inner.Height})
12551405 require.Equal(t, tt.wantMode, layout.Mode)
12561406 got := strings.Join(frameRows(inner), "\n")
12571407 if tt.wantMarker {
@@ -1645,6 +1795,31 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
16451795 require.False(t, stillActive, "the purge keeps its existing removal semantics despite cleanup failure")
16461796 }
16471797
1798+func TestPickerKillStoppedSessionRejectsSameNameReplacement(t *testing.T) {
1799+ p, release := newBlockingPTY(t)
1800+ defer release()
1801+ d, _, _, _ := newManualSessionWithPTYs(t, p)
1802+
1803+ oldCreatedAt := int64(7)
1804+ replacement := inactiveSession{
1805+ name: "stopped", createdAt: 8, incarnation: domain.IncarnationID{2},
1806+ state: protocol.SessionDown,
1807+ }
1808+ d.inactive[replacement.name] = replacement
1809+
1810+ err := d.killPickerTarget(picker.Target{
1811+ Name: "stopped", Stopped: true, Incarnation: domain.IncarnationID{1}, ExpectedCreatedAt: &oldCreatedAt,
1812+ })
1813+ require.NoError(t, err)
1814+
1815+ d.mu.Lock()
1816+ got, ok := d.inactive[replacement.name]
1817+ d.mu.Unlock()
1818+ require.True(t, ok, "a stale picker row must not purge a same-name replacement")
1819+ require.Equal(t, replacement.incarnation, got.incarnation)
1820+ require.Equal(t, replacement.createdAt, got.createdAt)
1821+}
1822+
16481823 // TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
16491824 // drives 'x' on a stopped picker entry whose persisted-record delete fails
16501825 // (mock store error). The failure must reach the user as a
@@ -190,6 +190,10 @@ func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
190 // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the190 // Named (MRU-desc) → ephemeral (MRU-desc) → stopped, even though the
191 // ephemeral session has the highest mruAt.191 // ephemeral session has the highest mruAt.
192 require.Equal(t, []string{"notes", "work", "1", "halted"}, names)192 require.Equal(t, []string{"notes", "work", "1", "halted"}, names)
193+ require.Equal(t, "LOCAL", views[0].Section)
194+ require.Empty(t, views[1].Section)
195+ require.Empty(t, views[2].Section)
196+ require.Empty(t, views[3].Section, "a contiguous local-only inventory keeps one section header")
193 }197 }
194 198
195 func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {199 func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
@@ -888,6 +892,129 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
888 }892 }
889 }893 }
890 894
895+func TestPickerSearchAcceptsPrintableActionsAndSplitUTF8(t *testing.T) {
896+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
897+ defer releaseAll(releases)
898+
899+ d.enterPicker(sess, ac)
900+ awaitFrame(t, sends, wire.MsgOutput)
901+ d.handlePickerInput(ac, []byte("j/jkqxs/"))
902+ d.handlePickerInput(ac, []byte{0xc3})
903+ d.handlePickerInput(ac, []byte{0xa9})
904+
905+ ac.overlays.pickerMu.Lock()
906+ query := ac.overlays.picker.Query()
907+ active := ac.overlays.picker.SearchActive()
908+ ac.overlays.pickerMu.Unlock()
909+ require.True(t, active)
910+ require.Equal(t, "jkqxs/é", query)
911+ require.True(t, ac.overlays.pickerActive(), "a slash after navigation must enter search and keep later action bytes printable")
912+}
913+
914+func TestPickerSlashInsideEscapeSequenceDoesNotEnterSearch(t *testing.T) {
915+ for _, chunks := range [][][]byte{
916+ {{0x1b, '[', '/', 'A'}},
917+ {{0x1b, '['}, {'/', 'A'}},
918+ } {
919+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
920+ d.enterPicker(sess, ac)
921+ awaitFrame(t, sends, wire.MsgOutput)
922+ for _, chunk := range chunks {
923+ d.handlePickerInput(ac, chunk)
924+ }
925+
926+ ac.overlays.pickerMu.Lock()
927+ searchActive := ac.overlays.picker != nil && ac.overlays.picker.SearchActive()
928+ ac.overlays.pickerMu.Unlock()
929+ require.False(t, searchActive)
930+ releaseAll(releases)
931+ }
932+}
933+
934+func TestPickerSearchZeroMatchEnterKeepsPickerOpen(t *testing.T) {
935+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
936+ defer releaseAll(releases)
937+
938+ d.enterPicker(sess, ac)
939+ awaitFrame(t, sends, wire.MsgOutput)
940+ d.handlePickerInput(ac, []byte("/absent\r"))
941+
942+ require.True(t, ac.overlays.pickerActive())
943+ ac.overlays.pickerMu.Lock()
944+ defer ac.overlays.pickerMu.Unlock()
945+ require.True(t, ac.overlays.picker.SearchActive())
946+ require.Equal(t, "absent", ac.overlays.picker.Query())
947+ _, ok := ac.overlays.picker.Selected()
948+ require.False(t, ok)
949+}
950+
951+func TestPickerSearchSplitArrowNavigatesMatches(t *testing.T) {
952+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
953+ defer releaseAll(releases)
954+
955+ d.enterPicker(sess, ac)
956+ awaitFrame(t, sends, wire.MsgOutput)
957+ d.handlePickerInput(ac, []byte("/"))
958+ d.handlePickerInput(ac, []byte("\x1b["))
959+ d.handlePickerInput(ac, []byte("B"))
960+
961+ ac.overlays.pickerMu.Lock()
962+ selected, ok := ac.overlays.picker.Selected()
963+ ac.overlays.pickerMu.Unlock()
964+ require.True(t, ok)
965+ require.Equal(t, 1, selected.TabIndex)
966+}
967+
968+func TestPickerSearchRepeatedEscapeChunkClearsThenExits(t *testing.T) {
969+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
970+ defer releaseAll(releases)
971+
972+ d.enterPicker(sess, ac)
973+ awaitFrame(t, sends, wire.MsgOutput)
974+ d.handlePickerInput(ac, []byte("/query"))
975+ d.handlePickerInput(ac, []byte("\x1b\x1b"))
976+
977+ require.True(t, ac.overlays.pickerActive())
978+ ac.overlays.pickerMu.Lock()
979+ defer ac.overlays.pickerMu.Unlock()
980+ require.True(t, ac.overlays.picker.SearchActive(), "first Escape clears while the second is retained as the next staged Escape")
981+ require.Empty(t, ac.overlays.picker.Query())
982+ require.Equal(t, []byte{0x1b}, ac.overlays.pickerPending)
983+}
984+
985+func TestPickerSearchLoneEscapeClearsThenExitsThenCloses(t *testing.T) {
986+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
987+ defer releaseAll(releases)
988+ clk := &signalClock{timers: make(chan *signalTimer, 3)}
989+ d.clock = clk
990+
991+ d.enterPicker(sess, ac)
992+ awaitFrame(t, sends, wire.MsgOutput)
993+ d.handlePickerInput(ac, []byte("/query"))
994+
995+ fireEscape := func() {
996+ d.handlePickerInput(ac, []byte("\x1b"))
997+ timer := <-clk.timers
998+ timer.ch <- time.Now()
999+ }
1000+ fireEscape()
1001+ require.Eventually(t, func() bool {
1002+ ac.overlays.pickerMu.Lock()
1003+ defer ac.overlays.pickerMu.Unlock()
1004+ return ac.overlays.picker != nil && ac.overlays.picker.SearchActive() && ac.overlays.picker.Query() == ""
1005+ }, time.Second, 5*time.Millisecond)
1006+
1007+ fireEscape()
1008+ require.Eventually(t, func() bool {
1009+ ac.overlays.pickerMu.Lock()
1010+ defer ac.overlays.pickerMu.Unlock()
1011+ return ac.overlays.picker != nil && !ac.overlays.picker.SearchActive()
1012+ }, time.Second, 5*time.Millisecond)
1013+
1014+ fireEscape()
1015+ require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
1016+}
1017+
891 func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) {1018 func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) {
892 d, sess, ac, _, releases := newManualTabSession(t, 1)1019 d, sess, ac, _, releases := newManualTabSession(t, 1)
893 defer releases[0]()1020 defer releases[0]()
@@ -1212,7 +1339,7 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
1212 }1339 }
1213 1340
1214 inner := state.overlays.picker.inner1341 inner := state.overlays.picker.inner
1215- layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})1342+ layout := picker.ChooseGeometry(domain.Size{Cols: inner.Width, Rows: inner.Height})
1216 require.Equal(t, picker.LayoutHorizontal, layout.Mode)1343 require.Equal(t, picker.LayoutHorizontal, layout.Mode)
1217 require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text keeps a muted foreground on the terminal background")1344 require.True(t, inner.At(5, 3).Style.Equal(state.styles.PickerDescription), "description text keeps a muted foreground on the terminal background")
1218 require.True(t, inner.At(layout.List.Width-1, 3).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background")1345 require.True(t, inner.At(layout.List.Width-1, 3).Style.Equal(state.styles.PickerBase), "inactive row filler keeps the terminal background")
@@ -1227,6 +1354,29 @@ func TestCaptureOverlayLayersPreservesPickerSemanticSurfacesAcrossFallbacks(t *t
1227 }1354 }
1228 }1355 }
1229 1356
1357+func TestCaptureOverlayLayersRendersNarrowSearchTitleAndStatus(t *testing.T) {
1358+ model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{TabID: "tab", Name: "shell"}}}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
1359+ model.EnterSearch()
1360+ for _, r := range "a-very-long-absent-query" {
1361+ model.InsertSearch(r)
1362+ }
1363+ state := capturedRenderState{
1364+ theme: themeui.BuiltinDark,
1365+ styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles,
1366+ layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 10}},
1367+ }
1368+ snap := &overlayRenderSnapshot{pickerActive: true, pickerModel: model}
1369+
1370+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
1371+
1372+ require.True(t, strings.HasPrefix(state.overlays.picker.title, " / "))
1373+ require.True(t, strings.HasSuffix(state.overlays.picker.title, "_ "))
1374+ inner := state.overlays.picker.inner
1375+ status := rowText(inner.Row(inner.Height - 1))
1376+ require.Contains(t, status, "0 matches")
1377+ require.NotContains(t, status, "x delete", "search action bytes belong to the query")
1378+}
1379+
1230 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {1380 func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testing.T) {
1231 model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})1381 model := picker.New([]picker.SessionView{{ID: "s", Name: "session", Tabs: []picker.TabEntry{{Name: "tab"}}, Active: 0}}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
1232 state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}1382 state := capturedRenderState{theme: themeui.BuiltinDark, styles: themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles}
@@ -1251,7 +1401,7 @@ func TestCaptureOverlayLayersResizeRecomposesPickerWithoutStalePreview(t *testin
1251 captureOverlayLayers(&state, snap, domain.PaletteConfig{})1401 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
1252 1402
1253 inner := state.overlays.picker.inner1403 inner := state.overlays.picker.inner
1254- layout := picker.ChooseLayout(domain.Size{Cols: inner.Width, Rows: inner.Height})1404+ layout := picker.ChooseGeometry(domain.Size{Cols: inner.Width, Rows: inner.Height})
1255 require.Equal(t, tt.wantMode, layout.Mode)1405 require.Equal(t, tt.wantMode, layout.Mode)
1256 got := strings.Join(frameRows(inner), "\n")1406 got := strings.Join(frameRows(inner), "\n")
1257 if tt.wantMarker {1407 if tt.wantMarker {
@@ -1645,6 +1795,31 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
1645 require.False(t, stillActive, "the purge keeps its existing removal semantics despite cleanup failure")1795 require.False(t, stillActive, "the purge keeps its existing removal semantics despite cleanup failure")
1646 }1796 }
1647 1797
1798+func TestPickerKillStoppedSessionRejectsSameNameReplacement(t *testing.T) {
1799+ p, release := newBlockingPTY(t)
1800+ defer release()
1801+ d, _, _, _ := newManualSessionWithPTYs(t, p)
1802+
1803+ oldCreatedAt := int64(7)
1804+ replacement := inactiveSession{
1805+ name: "stopped", createdAt: 8, incarnation: domain.IncarnationID{2},
1806+ state: protocol.SessionDown,
1807+ }
1808+ d.inactive[replacement.name] = replacement
1809+
1810+ err := d.killPickerTarget(picker.Target{
1811+ Name: "stopped", Stopped: true, Incarnation: domain.IncarnationID{1}, ExpectedCreatedAt: &oldCreatedAt,
1812+ })
1813+ require.NoError(t, err)
1814+
1815+ d.mu.Lock()
1816+ got, ok := d.inactive[replacement.name]
1817+ d.mu.Unlock()
1818+ require.True(t, ok, "a stale picker row must not purge a same-name replacement")
1819+ require.Equal(t, replacement.incarnation, got.incarnation)
1820+ require.Equal(t, replacement.createdAt, got.createdAt)
1821+}
1822+
1648 // TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry1823 // TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1649 // drives 'x' on a stopped picker entry whose persisted-record delete fails1824 // drives 'x' on a stopped picker entry whose persisted-record delete fails
1650 // (mock store error). The failure must reach the user as a1825 // (mock store error). The failure must reach the user as a
M internal/usecase/daemon/remote_picker.go +0 -6
@@ -206,12 +206,6 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal
206206 // must remain possible, while sendRemoteAttachTargetForAttachment will
207207 // fail closed on the invalid target.
208208 remoteTarget := &target
209- if stopped && len(viewTabs) == 0 {
210- // A healthy durable record may intentionally have no retained tabs,
211- // for example after a protocol reset. Mirror local stopped rows with
212- // one default picker target; the owning daemon creates that sole tab.
213- viewTabs = append(viewTabs, picker.TabEntry{})
214- }
215209 reason := remoteReasonForStatus(status)
216210 targetValid := remoteTarget.Validate() == nil
217211 activation := picker.RemoteUnavailable
@@ -206,12 +206,6 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal
206 // must remain possible, while sendRemoteAttachTargetForAttachment will206 // must remain possible, while sendRemoteAttachTargetForAttachment will
207 // fail closed on the invalid target.207 // fail closed on the invalid target.
208 remoteTarget := &target208 remoteTarget := &target
209- if stopped && len(viewTabs) == 0 {
210- // A healthy durable record may intentionally have no retained tabs,
211- // for example after a protocol reset. Mirror local stopped rows with
212- // one default picker target; the owning daemon creates that sole tab.
213- viewTabs = append(viewTabs, picker.TabEntry{})
214- }
215 reason := remoteReasonForStatus(status)209 reason := remoteReasonForStatus(status)
216 targetValid := remoteTarget.Validate() == nil210 targetValid := remoteTarget.Validate() == nil
217 activation := picker.RemoteUnavailable211 activation := picker.RemoteUnavailable
M internal/usecase/daemon/remote_picker_test.go +31 -1
@@ -979,7 +979,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
979979 updated := owner.overlays.picker
980980 selected, ok := updated.Selected()
981981 owner.overlays.pickerMu.Unlock()
982- require.NotSame(t, before[owner], updated, "every open picker must receive the refreshed model")
982+ require.Same(t, before[owner], updated, "refresh updates rows in place so attachment-local editor state remains owned")
983983 require.True(t, ok)
984984 require.Equal(t, domain.SessionID("second"), selected.Session)
985985 }
@@ -1253,6 +1253,36 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
12531253 ac.overlays.pickerMu.Unlock()
12541254 }
12551255
1256+func TestRemotePickerRefreshPreservesQueryEditedDuringBuild(t *testing.T) {
1257+ d := newRemotePickerDaemon(nil)
1258+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1259+ model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1260+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
1261+ d.handlePickerInput(ac, []byte("/before"))
1262+
1263+ rebuildReached := make(chan struct{})
1264+ allowPublication := make(chan struct{})
1265+ ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
1266+ close(rebuildReached)
1267+ <-allowPublication
1268+ }
1269+ refreshed := make(chan struct{})
1270+ go func() {
1271+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
1272+ close(refreshed)
1273+ }()
1274+ receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
1275+
1276+ d.handlePickerInput(ac, []byte("-after"))
1277+ close(allowPublication)
1278+ receiveRemotePickerClose(t, refreshed, "picker refresh")
1279+
1280+ ac.overlays.pickerMu.Lock()
1281+ defer ac.overlays.pickerMu.Unlock()
1282+ require.Same(t, model, ac.overlays.picker, "refresh applies rows in place so attachment-local editor state remains owned")
1283+ require.Equal(t, "before-after", ac.overlays.picker.Query())
1284+}
1285+
12561286 func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
12571287 d := newRemotePickerDaemon(nil)
12581288 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
@@ -979,7 +979,7 @@ func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
979 updated := owner.overlays.picker979 updated := owner.overlays.picker
980 selected, ok := updated.Selected()980 selected, ok := updated.Selected()
981 owner.overlays.pickerMu.Unlock()981 owner.overlays.pickerMu.Unlock()
982- require.NotSame(t, before[owner], updated, "every open picker must receive the refreshed model")982+ require.Same(t, before[owner], updated, "refresh updates rows in place so attachment-local editor state remains owned")
983 require.True(t, ok)983 require.True(t, ok)
984 require.Equal(t, domain.SessionID("second"), selected.Session)984 require.Equal(t, domain.SessionID("second"), selected.Session)
985 }985 }
@@ -1253,6 +1253,36 @@ func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
1253 ac.overlays.pickerMu.Unlock()1253 ac.overlays.pickerMu.Unlock()
1254 }1254 }
1255 1255
1256+func TestRemotePickerRefreshPreservesQueryEditedDuringBuild(t *testing.T) {
1257+ d := newRemotePickerDaemon(nil)
1258+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1259+ model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1260+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
1261+ d.handlePickerInput(ac, []byte("/before"))
1262+
1263+ rebuildReached := make(chan struct{})
1264+ allowPublication := make(chan struct{})
1265+ ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
1266+ close(rebuildReached)
1267+ <-allowPublication
1268+ }
1269+ refreshed := make(chan struct{})
1270+ go func() {
1271+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
1272+ close(refreshed)
1273+ }()
1274+ receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
1275+
1276+ d.handlePickerInput(ac, []byte("-after"))
1277+ close(allowPublication)
1278+ receiveRemotePickerClose(t, refreshed, "picker refresh")
1279+
1280+ ac.overlays.pickerMu.Lock()
1281+ defer ac.overlays.pickerMu.Unlock()
1282+ require.Same(t, model, ac.overlays.picker, "refresh applies rows in place so attachment-local editor state remains owned")
1283+ require.Equal(t, "before-after", ac.overlays.picker.Query())
1284+}
1285+
1256 func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {1286 func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
1257 d := newRemotePickerDaemon(nil)1287 d := newRemotePickerDaemon(nil)
1258 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")1288 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
M internal/usecase/daemon/render_pipeline.go +4 -2
@@ -242,14 +242,16 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
242242 if snap.pickerActive && snap.pickerModel != nil {
243243 presentation := pickerModal.Resolve(size)
244244 title := snap.pickerTitle
245- if title == "" {
245+ if searchTitle := snap.pickerModel.SearchTitle(presentation.Bounds.Width - 2); searchTitle != "" {
246+ title = searchTitle
247+ } else if title == "" {
246248 title = pickerModal.Title
247249 }
248250 o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true}
249251 stoppedStyle := styles.PickerName
250252 stoppedStyle.Attrs |= renderer.AttrDim
251253 stoppedStyle.Italic = true
252- 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}
254+ 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, Status: styles.PickerDescription, SearchMatch: styles.SearchSelection, SelectionMatch: styles.HintKey}
253255 o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
254256 }
255257 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
@@ -242,14 +242,16 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
242 if snap.pickerActive && snap.pickerModel != nil {242 if snap.pickerActive && snap.pickerModel != nil {
243 presentation := pickerModal.Resolve(size)243 presentation := pickerModal.Resolve(size)
244 title := snap.pickerTitle244 title := snap.pickerTitle
245- if title == "" {245+ if searchTitle := snap.pickerModel.SearchTitle(presentation.Bounds.Width - 2); searchTitle != "" {
246+ title = searchTitle
247+ } else if title == "" {
246 title = pickerModal.Title248 title = pickerModal.Title
247 }249 }
248 o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true}250 o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true}
249 stoppedStyle := styles.PickerName251 stoppedStyle := styles.PickerName
250 stoppedStyle.Attrs |= renderer.AttrDim252 stoppedStyle.Attrs |= renderer.AttrDim
251 stoppedStyle.Italic = true253 stoppedStyle.Italic = true
252- 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}254+ 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, Status: styles.PickerDescription, SearchMatch: styles.SearchSelection, SelectionMatch: styles.HintKey}
253 o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)255 o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
254 }256 }
255 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {257 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
M internal/usecase/daemon/session.go +14 -0
@@ -1262,13 +1262,27 @@ func (d *Daemon) retryStoppedPurge(name string) error {
12621262 return d.retryStoppedPurgeContext(d.serveCtx, name)
12631263 }
12641264
1265+func (d *Daemon) retryStoppedPurgeExact(name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error {
1266+ return d.retryStoppedPurgeContextExact(d.serveCtx, name, incarnation, expectedCreatedAt)
1267+}
1268+
12651269 func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {
1270+ return d.retryStoppedPurgeContextExact(ctx, name, domain.IncarnationID{}, nil)
1271+}
1272+
1273+func (d *Daemon) retryStoppedPurgeContextExact(ctx context.Context, name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error {
12661274 d.mu.Lock()
12671275 stopped, ok := d.inactive[name]
12681276 if !ok {
12691277 d.mu.Unlock()
12701278 return nil
12711279 }
1280+ incarnationChanged := incarnation != (domain.IncarnationID{}) && stopped.incarnation != incarnation
1281+ createdAtChanged := expectedCreatedAt != nil && stopped.createdAt != *expectedCreatedAt
1282+ if incarnationChanged || createdAtChanged {
1283+ d.mu.Unlock()
1284+ return nil
1285+ }
12721286 stopped.purging = true
12731287 d.inactive[name] = stopped
12741288
@@ -1262,13 +1262,27 @@ func (d *Daemon) retryStoppedPurge(name string) error {
1262 return d.retryStoppedPurgeContext(d.serveCtx, name)1262 return d.retryStoppedPurgeContext(d.serveCtx, name)
1263 }1263 }
1264 1264
1265+func (d *Daemon) retryStoppedPurgeExact(name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error {
1266+ return d.retryStoppedPurgeContextExact(d.serveCtx, name, incarnation, expectedCreatedAt)
1267+}
1268+
1265 func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {1269 func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {
1270+ return d.retryStoppedPurgeContextExact(ctx, name, domain.IncarnationID{}, nil)
1271+}
1272+
1273+func (d *Daemon) retryStoppedPurgeContextExact(ctx context.Context, name string, incarnation domain.IncarnationID, expectedCreatedAt *int64) error {
1266 d.mu.Lock()1274 d.mu.Lock()
1267 stopped, ok := d.inactive[name]1275 stopped, ok := d.inactive[name]
1268 if !ok {1276 if !ok {
1269 d.mu.Unlock()1277 d.mu.Unlock()
1270 return nil1278 return nil
1271 }1279 }
1280+ incarnationChanged := incarnation != (domain.IncarnationID{}) && stopped.incarnation != incarnation
1281+ createdAtChanged := expectedCreatedAt != nil && stopped.createdAt != *expectedCreatedAt
1282+ if incarnationChanged || createdAtChanged {
1283+ d.mu.Unlock()
1284+ return nil
1285+ }
1272 stopped.purging = true1286 stopped.purging = true
1273 d.inactive[name] = stopped1287 d.inactive[name] = stopped
1274 1288
M internal/usecase/picker/model.go +308 -58
@@ -1,7 +1,9 @@
11 package picker
22
33 import (
4+ "fmt"
45 "math"
6+ "strings"
57
68 renderer "github.com/bnema/vev-vt"
79 "github.com/bnema/vev/internal/domain"
@@ -12,7 +14,8 @@ const (
1214 MinPreviewWidth = 24
1315 MinHorizontalPreviewWidth = 48
1416 MinListWidth = 16
15- MaxListWidth = 32
17+ MinHorizontalListWidth = 20
18+ MaxListWidth = 44
1619 MinPaneHeight = 4
1720 MinStackHeight = 12
1821 )
@@ -40,6 +43,7 @@ const (
4043
4144 type SessionView struct {
4245 ID domain.SessionID
46+ Section string
4347 Incarnation domain.IncarnationID
4448 Name string
4549 TargetName string
@@ -48,6 +52,7 @@ type SessionView struct {
4852 Stopped bool
4953 ExpectedCreatedAt *int64
5054 RemoteKey *domain.RemoteSessionKey
55+ HideRemoteOrigin bool
5156 // RemoteTarget is the structured route/lifecycle identity for picker rows.
5257 // It is never reconstructed from Name or a rendered label.
5358 RemoteTarget *domain.RemoteSessionTarget
@@ -108,10 +113,13 @@ type RenderStyles struct {
108113 Detail renderer.Style // non-selected detail segment
109114 // Background fills otherwise unused interior cells. Base owns ordinary
110115 // rows, preserving a distinct inactive surface from modal chrome.
111- Background renderer.Style
112- Base renderer.Style // non-selected row fill + suffixes
113- Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix)
114- Separator renderer.Style // preview separator
116+ Background renderer.Style
117+ Base renderer.Style // non-selected row fill + suffixes
118+ Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix)
119+ Separator renderer.Style // preview separator
120+ Status renderer.Style // one-row picker-local contextual help
121+ SearchMatch renderer.Style // matched runes on ordinary rows
122+ SelectionMatch renderer.Style // matched runes on the selected row
115123 }
116124
117125 func defaultRenderStyles() RenderStyles {
@@ -123,7 +131,11 @@ func defaultRenderStyles() RenderStyles {
123131 stopped := renderer.DefaultStyle()
124132 stopped.Attrs = renderer.AttrDim
125133 stopped.Italic = true
126- return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped}
134+ searchMatch := base
135+ searchMatch.Bold = true
136+ selectionMatch := selection
137+ selectionMatch.Bold = true
138+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped, Status: separator, SearchMatch: searchMatch, SelectionMatch: selectionMatch}
127139 }
128140
129141 type Target struct {
@@ -161,10 +173,25 @@ type Layout struct {
161173 Preview domain.Rect
162174 }
163175
176+// Geometry is the single picker layout contract consumed by rendering and
177+// remote preview sizing. Status always reserves the final available inner row.
178+type Geometry struct {
179+ Content domain.Rect
180+ Status domain.Rect
181+ Mode LayoutMode
182+ List domain.Rect
183+ Separator domain.Rect
184+ Preview domain.Rect
185+}
186+
164187 type Model struct {
165- mode SelectionMode
166- rows []row
167- selected int
188+ mode SelectionMode
189+ rows []row
190+ selected int
191+ searchActive bool
192+ query ui.TextInput
193+ searchMatches map[int]searchMatch
194+ matchRows []int
168195 }
169196
170197 type rowKind uint8
@@ -172,8 +199,40 @@ type rowKind uint8
172199 const (
173200 rowSession rowKind = iota
174201 rowTab
202+ rowSection
175203 )
176204
205+type rowStatus uint8
206+
207+const (
208+ rowStatusNone rowStatus = iota
209+ rowStatusUp
210+ rowStatusStopped
211+ rowStatusDown
212+ rowStatusStale
213+ rowStatusVersion
214+ rowStatusError
215+)
216+
217+func (s rowStatus) badge() string {
218+ switch s {
219+ case rowStatusUp:
220+ return "[up]"
221+ case rowStatusStopped:
222+ return "[stopped]"
223+ case rowStatusDown:
224+ return "[down]"
225+ case rowStatusStale:
226+ return "[stale]"
227+ case rowStatusVersion:
228+ return "[version]"
229+ case rowStatusError:
230+ return "[error]"
231+ default:
232+ return ""
233+ }
234+}
235+
177236 func (k rowKind) rendersAsHeader() bool {
178237 return k == rowSession
179238 }
@@ -199,6 +258,7 @@ type row struct {
199258 // targetName is the named-session lookup name threaded into Target;
200259 // distinct from dispName, which is what gets drawn.
201260 targetName string
261+ sessionDisplay string
202262 tabID domain.TabStableID
203263 tabIndex int
204264 stopped bool
@@ -214,6 +274,12 @@ type row struct {
214274 selectable bool
215275 focusable bool
216276 dim bool
277+ status rowStatus
278+ statusDetail string
279+ foldedName string
280+ foldedDetail string
281+ foldedSession string
282+ foldedHost string
217283 }
218284
219285 func New(sessions []SessionView, config SelectionConfig) *Model {
@@ -221,6 +287,12 @@ func New(sessions []SessionView, config SelectionConfig) *Model {
221287 activeSelection := -1
222288 for _, session := range sessions {
223289 sessionRows := rowsForSession(session, config)
290+ if len(sessionRows) == 0 {
291+ continue
292+ }
293+ if session.Section != "" {
294+ m.rows = append(m.rows, row{kind: rowSection, dispName: session.Section, dim: true})
295+ }
224296 for _, pickerRow := range sessionRows {
225297 idx := len(m.rows)
226298 m.rows = append(m.rows, pickerRow)
@@ -269,8 +341,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
269341 }
270342 expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt)
271343 common := row{
272- session: session.ID, incarnation: session.Incarnation, targetName: targetName,
273- stopped: stopped, expectedCreatedAt: expectedCreatedAt,
344+ session: session.ID, incarnation: session.Incarnation, targetName: targetName, sessionDisplay: session.Name,
345+ stopped: stopped, status: statusForSession(session, stopped), statusDetail: session.RemoteDetail, expectedCreatedAt: expectedCreatedAt,
274346 hasExpectedCreatedAt: hasExpectedCreatedAt,
275347 }
276348 if session.RemoteKey != nil {
@@ -285,21 +357,19 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
285357 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
286358 if common.hasRemoteKey {
287359 header.dispName = common.remoteKey.Name
288- display := common.remoteKey.Display()
289- header.detail = display[len(common.remoteKey.Name):]
360+ if !session.HideRemoteOrigin {
361+ display := common.remoteKey.Display()
362+ header.detail = display[len(common.remoteKey.Name):]
363+ }
290364 }
291365 header.selectable = header.kind.selectable(config.Mode)
292366 header.focusable = header.selectable
367+ if config.Mode == SelectNavigationTab && stopped && len(session.Tabs) == 0 {
368+ header.selectable, header.focusable = true, true
369+ }
293370 if common.remote {
294- if session.RemoteDetail != "" {
295- if header.detail != "" {
296- header.detail += " (" + session.RemoteDetail + ")"
297- } else {
298- header.detail = session.RemoteDetail
299- }
300- }
301371 if common.hasRemoteTarget {
302- header.selectable = false
372+ header.selectable = config.Mode == SelectNavigationTab && stopped && len(session.Tabs) == 0 && remoteActivatable(session) && common.remoteTarget.Validate() == nil
303373 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0
304374 } else {
305375 header.selectable = false
@@ -311,6 +381,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
311381 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
312382 header.selectable, header.focusable, header.dim = false, false, true
313383 }
384+ header.prepareSearch()
314385 rows := []row{header}
315386 for i, tab := range session.Tabs {
316387 if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
@@ -355,6 +426,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
355426 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
356427 tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
357428 }
429+ tabRow.prepareSearch()
358430 rows = append(rows, tabRow)
359431 }
360432 if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {
@@ -363,6 +435,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
363435 return rows
364436 }
365437
438+func (r *row) prepareSearch() {
439+ r.foldedName = strings.ToLower(r.dispName)
440+ r.foldedDetail = strings.ToLower(r.detail)
441+ r.foldedSession = strings.ToLower(r.sessionDisplay)
442+ r.foldedHost = strings.ToLower(r.remoteHost)
443+}
444+
366445 func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) {
367446 if tabCount > math.MaxUint16 || index < 0 || index >= tabCount {
368447 return domain.TabSelector{}, false
@@ -370,6 +449,32 @@ func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (doma
370449 return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true
371450 }
372451
452+func statusForSession(session SessionView, stopped bool) rowStatus {
453+ remote := session.RemoteKey != nil || session.RemoteTarget != nil || session.RemoteHost != ""
454+ if remote {
455+ switch session.RemoteReason {
456+ case "host_unreachable":
457+ return rowStatusDown
458+ case "version_mismatch":
459+ return rowStatusVersion
460+ case "refreshing", "catalog_stale":
461+ return rowStatusStale
462+ case "malformed", "session_broken", "identity_changed":
463+ return rowStatusError
464+ }
465+ }
466+ if stopped {
467+ return rowStatusStopped
468+ }
469+ if !remote {
470+ return rowStatusNone
471+ }
472+ if session.RemoteActivation == RemoteAttach {
473+ return rowStatusUp
474+ }
475+ return rowStatusError
476+}
477+
373478 func remoteActivatable(session SessionView) bool {
374479 return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart
375480 }
@@ -397,21 +502,19 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b
397502 return false
398503 }
399504 if pickerRow.hasRemoteKey && pickerRow.kind == rowSession {
400- return current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey
505+ return pickerRow.focusable && (current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey)
401506 }
402507 if mode == SelectMoveTabSession {
403508 return pickerRow.kind == rowSession
404509 }
510+ // A stopped session with no retained tab metadata uses its selectable
511+ // header as the default restart target and matches on exact session identity.
512+ if current.TabID == "" && pickerRow.stopped {
513+ return pickerRow.kind == rowSession && pickerRow.selectable
514+ }
405515 if pickerRow.kind != rowTab {
406516 return false
407517 }
408- // A stopped session contributes one synthetic tab row carrying no stable tab
409- // identity, so it matches on session identity alone. Its session ID is
410- // namespaced ("stopped:<name>"), and move modes drop stopped sessions
411- // entirely, so no live row can be reached this way.
412- if current.TabID == "" {
413- return pickerRow.stopped
414- }
415518 return pickerRow.tabID == current.TabID
416519 }
417520
@@ -422,14 +525,30 @@ func int64Value(value *int64) (int64, bool) {
422525 return *value, true
423526 }
424527
528+// ChooseGeometry reserves the final inner row for picker-local status before
529+// solving the list and preview layout.
530+func ChooseGeometry(inner domain.Size) Geometry {
531+ if inner.Cols <= 0 || inner.Rows <= 0 {
532+ return Geometry{}
533+ }
534+ contentRows := max(inner.Rows-1, 0)
535+ layout := ChooseLayout(domain.Size{Cols: inner.Cols, Rows: contentRows})
536+ return Geometry{
537+ Content: domain.Rect{Width: inner.Cols, Height: contentRows},
538+ Status: domain.Rect{Y: contentRows, Width: inner.Cols, Height: 1},
539+ Mode: layout.Mode, List: layout.List, Separator: layout.Separator, Preview: layout.Preview,
540+ }
541+}
542+
425543 func ChooseLayout(inner domain.Size) Layout {
426544 if inner.Cols <= 0 || inner.Rows <= 0 {
427545 return Layout{}
428546 }
429547 if inner.Rows >= MinPaneHeight {
430- listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth)
548+ listWidth := clamp(inner.Cols*40/100, MinListWidth, MaxListWidth)
549+ listWidth = min(listWidth, inner.Cols-MinHorizontalPreviewWidth-1)
431550 previewWidth := inner.Cols - listWidth - 1
432- if previewWidth >= MinHorizontalPreviewWidth {
551+ if listWidth >= MinHorizontalListWidth && previewWidth >= MinHorizontalPreviewWidth {
433552 return Layout{
434553 Mode: LayoutHorizontal,
435554 List: domain.Rect{Width: listWidth, Height: inner.Rows},
@@ -452,10 +571,18 @@ func ChooseLayout(inner domain.Size) Layout {
452571 }
453572
454573 func (m *Model) Up() {
574+ if m != nil && m.searchActive {
575+ m.moveSearch(-1)
576+ return
577+ }
455578 m.move(-1)
456579 }
457580
458581 func (m *Model) Down() {
582+ if m != nil && m.searchActive {
583+ m.moveSearch(1)
584+ return
585+ }
459586 m.move(1)
460587 }
461588
@@ -474,7 +601,7 @@ func (m *Model) Selected() (Target, bool) {
474601 return Target{}, false
475602 }
476603 r := m.rows[m.selected]
477- if !r.selectable {
604+ if !r.selectable || m.searchActive && m.query.Value() != "" && !m.rowMatches(m.selected) {
478605 return Target{}, false
479606 }
480607 return r.target(), true
@@ -519,25 +646,31 @@ func (m *Model) Clone() *Model {
519646 }
520647 clone := *m
521648 clone.rows = append([]row(nil), m.rows...)
649+ clone.query.SetValue(m.query.Value())
650+ // Search results are copy-on-write: every query mutation publishes a new
651+ // map and row slice, so render snapshots can safely share the old values.
652+ clone.matchRows = m.matchRows
653+ clone.searchMatches = m.searchMatches
522654 return &clone
523655 }
524656
525657 func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame {
526658 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
527- layout := ChooseLayout(inner)
659+ geometry := ChooseGeometry(inner)
528660 styleSet := defaultRenderStyles()
529661 if len(styles) > 0 {
530662 styleSet = styles[0]
531663 }
532664 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background})
533- m.renderList(frame, layout.List, styleSet)
534- switch layout.Mode {
665+ m.renderList(frame, geometry.List, styleSet)
666+ switch geometry.Mode {
535667 case LayoutHorizontal:
536- ui.DrawSeparator(frame, layout.Separator, ui.SeparatorVertical, styleSet.Separator)
668+ ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorVertical, styleSet.Separator)
537669 case LayoutStacked:
538- ui.DrawSeparator(frame, layout.Separator, ui.SeparatorHorizontal, styleSet.Separator)
670+ ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorHorizontal, styleSet.Separator)
539671 }
540- ui.BlitFrame(frame, layout.Preview, preview, ui.VerticalAnchorBottom)
672+ ui.BlitFrame(frame, geometry.Preview, preview, ui.VerticalAnchorBottom)
673+ m.renderStatus(frame, geometry.Status, styleSet.Status)
541674 return frame
542675 }
543676
@@ -613,13 +746,16 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
613746 }
614747 r := m.rows[idx]
615748 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
749+ if r.kind == rowSection {
750+ nameStyle = styles.Detail
751+ }
616752 if idx == m.selected {
617753 base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
618754 }
619755 if r.stopped && idx != m.selected {
620756 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
621757 }
622- if r.dim {
758+ if r.dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) {
623759 base.Attrs |= renderer.AttrDim
624760 nameStyle.Attrs |= renderer.AttrDim
625761 detailStyle.Attrs |= renderer.AttrDim
@@ -627,29 +763,43 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
627763 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
628764
629765 name := r.dispName
630- if !r.kind.rendersAsHeader() {
766+ if r.kind == rowTab {
631767 name = " " + name
632768 }
633- suffix := ""
634- if r.kind.rendersAsHeader() && r.stopped {
635- suffix = "(down)"
769+ badge := ""
770+ if r.kind.rendersAsHeader() {
771+ badge = r.status.badge()
772+ }
773+ contentClipX := clipX
774+ nameWidth := rect.Width
775+ badgeWidth := textCellWidth(badge)
776+ if badge != "" {
777+ nameWidth = max(rect.Width-badgeWidth-1, 0)
778+ contentClipX = max(rect.X, clipX-badgeWidth-1)
779+ }
780+ originalName := name
781+ name = ui.TruncateText(name, nameWidth)
782+ nameMatchStyle := styles.SearchMatch
783+ if idx == m.selected {
784+ nameMatchStyle = styles.SelectionMatch
785+ }
786+ namePositions := m.matchPositions(idx, matchName)
787+ if r.kind == rowTab {
788+ namePositions = shiftPositions(namePositions, 2)
636789 }
637- name = ui.TruncateText(name, max(rect.Width-len(suffix), 0))
638- x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
790+ namePositions = visibleMatchPositions(namePositions, name, name != originalName)
791+ x := drawMatchedText(frame, rect.X, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
639792
793+ if r.kind == rowSection {
794+ continue
795+ }
640796 if r.kind.rendersAsHeader() {
641- if r.stopped {
642- detail := r.detail
643- if detail == "" {
644- detail = " "
645- } else {
646- detail += " "
647- }
648- detail = ui.TruncateText(detail, max(clipX-x-len(suffix), 0))
649- x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, base)
650- ui.DrawText(frame, x, rect.Y+y, clipX, suffix, base)
651- } else {
652- ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)
797+ detail := ui.TruncateText(r.detail, contentClipX-x)
798+ detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.detail)
799+ drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
800+ if badge != "" {
801+ badgeX := max(rect.X, clipX-badgeWidth)
802+ ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, base)
653803 }
654804 continue
655805 }
@@ -659,8 +809,108 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
659809 }
660810
661811 detail := ui.TruncateText(r.detail, clipX-x)
662- ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)
812+ detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.detail)
813+ drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions)
814+ }
815+}
816+
817+func (m *Model) matchPositions(idx int, field matchField) []int {
818+ if m == nil || !m.searchActive || m.query.Value() == "" {
819+ return nil
820+ }
821+ return m.searchMatches[idx].positions(field)
822+}
823+
824+func visibleMatchPositions(positions []int, rendered string, truncated bool) []int {
825+ if !truncated || len(positions) == 0 {
826+ return positions
827+ }
828+ limit := max(len([]rune(rendered))-1, 0) // the final rune is the ellipsis
829+ visible := make([]int, 0, len(positions))
830+ for _, position := range positions {
831+ if position < limit {
832+ visible = append(visible, position)
833+ }
834+ }
835+ return visible
836+}
837+
838+func shiftPositions(positions []int, delta int) []int {
839+ if len(positions) == 0 || delta == 0 {
840+ return positions
841+ }
842+ shifted := make([]int, len(positions))
843+ for i, position := range positions {
844+ shifted[i] = position + delta
845+ }
846+ return shifted
847+}
848+
849+func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int {
850+ if len(positions) == 0 {
851+ return ui.DrawText(frame, x, y, clipX, text, base)
852+ }
853+ positionIndex := 0
854+ runeIndex := 0
855+ for _, r := range text {
856+ style := base
857+ if positionIndex < len(positions) && positions[positionIndex] == runeIndex {
858+ style = match
859+ positionIndex++
860+ }
861+ x = ui.DrawText(frame, x, y, clipX, string(r), style)
862+ if x >= clipX {
863+ break
864+ }
865+ runeIndex++
866+ }
867+ return x
868+}
869+
870+func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style renderer.Style) {
871+ if rect.Width <= 0 || rect.Height <= 0 || rect.Y < 0 || rect.Y >= frame.Height {
872+ return
873+ }
874+ ui.FillRect(frame, rect, renderer.Cell{Rune: ' ', Style: style})
875+ action := "open"
876+ deletable := false
877+ if m != nil && m.selected >= 0 && m.selected < len(m.rows) {
878+ selected := m.rows[m.selected]
879+ if !selected.selectable {
880+ action = "unavailable"
881+ } else if selected.stopped {
882+ action = "restart"
883+ }
884+ deletable = selected.selectable && !selected.remote
885+ }
886+ var groups []string
887+ if m != nil && m.searchActive {
888+ groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), "Enter " + action, "arrows next"}
889+ escape := "Esc exit"
890+ if m.query.Value() != "" {
891+ escape = "Esc clear"
892+ }
893+ groups = append(groups, escape)
894+ } else {
895+ if m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].dim && m.rows[m.selected].statusDetail != "" {
896+ groups = append(groups, m.rows[m.selected].statusDetail)
897+ }
898+ if rect.Width < 60 {
899+ groups = append(groups, "Enter "+action, "/", "Esc", "j/k")
900+ if deletable {
901+ groups = append(groups, "x")
902+ }
903+ groups = append(groups, "s")
904+ } else {
905+ groups = append(groups, "j/k move", "Enter "+action)
906+ if deletable {
907+ groups = append(groups, "x delete")
908+ }
909+ groups = append(groups, "/ search", "s sort", "Esc close")
910+ }
663911 }
912+ text := strings.Join(groups, " ")
913+ ui.DrawText(frame, rect.X, rect.Y, rect.X+rect.Width, ui.TruncateText(text, rect.Width), style)
664914 }
665915
666916 func (m *Model) scrollOffset(visible int) int {
@@ -1,7 +1,9 @@
1 package picker1 package picker
2 2
3 import (3 import (
4+ "fmt"
4 "math"5 "math"
6+ "strings"
5 7
6 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
@@ -12,7 +14,8 @@ const (
12 MinPreviewWidth = 2414 MinPreviewWidth = 24
13 MinHorizontalPreviewWidth = 4815 MinHorizontalPreviewWidth = 48
14 MinListWidth = 1616 MinListWidth = 16
15- MaxListWidth = 3217+ MinHorizontalListWidth = 20
18+ MaxListWidth = 44
16 MinPaneHeight = 419 MinPaneHeight = 4
17 MinStackHeight = 1220 MinStackHeight = 12
18 )21 )
@@ -40,6 +43,7 @@ const (
40 43
41 type SessionView struct {44 type SessionView struct {
42 ID domain.SessionID45 ID domain.SessionID
46+ Section string
43 Incarnation domain.IncarnationID47 Incarnation domain.IncarnationID
44 Name string48 Name string
45 TargetName string49 TargetName string
@@ -48,6 +52,7 @@ type SessionView struct {
48 Stopped bool52 Stopped bool
49 ExpectedCreatedAt *int6453 ExpectedCreatedAt *int64
50 RemoteKey *domain.RemoteSessionKey54 RemoteKey *domain.RemoteSessionKey
55+ HideRemoteOrigin bool
51 // RemoteTarget is the structured route/lifecycle identity for picker rows.56 // RemoteTarget is the structured route/lifecycle identity for picker rows.
52 // It is never reconstructed from Name or a rendered label.57 // It is never reconstructed from Name or a rendered label.
53 RemoteTarget *domain.RemoteSessionTarget58 RemoteTarget *domain.RemoteSessionTarget
@@ -108,10 +113,13 @@ type RenderStyles struct {
108 Detail renderer.Style // non-selected detail segment113 Detail renderer.Style // non-selected detail segment
109 // Background fills otherwise unused interior cells. Base owns ordinary114 // Background fills otherwise unused interior cells. Base owns ordinary
110 // rows, preserving a distinct inactive surface from modal chrome.115 // rows, preserving a distinct inactive surface from modal chrome.
111- Background renderer.Style116+ Background renderer.Style
112- Base renderer.Style // non-selected row fill + suffixes117+ Base renderer.Style // non-selected row fill + suffixes
113- Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix)118+ Stopped renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix)
114- Separator renderer.Style // preview separator119+ Separator renderer.Style // preview separator
120+ Status renderer.Style // one-row picker-local contextual help
121+ SearchMatch renderer.Style // matched runes on ordinary rows
122+ SelectionMatch renderer.Style // matched runes on the selected row
115 }123 }
116 124
117 func defaultRenderStyles() RenderStyles {125 func defaultRenderStyles() RenderStyles {
@@ -123,7 +131,11 @@ func defaultRenderStyles() RenderStyles {
123 stopped := renderer.DefaultStyle()131 stopped := renderer.DefaultStyle()
124 stopped.Attrs = renderer.AttrDim132 stopped.Attrs = renderer.AttrDim
125 stopped.Italic = true133 stopped.Italic = true
126- return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped}134+ searchMatch := base
135+ searchMatch.Bold = true
136+ selectionMatch := selection
137+ selectionMatch.Bold = true
138+ return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped, Status: separator, SearchMatch: searchMatch, SelectionMatch: selectionMatch}
127 }139 }
128 140
129 type Target struct {141 type Target struct {
@@ -161,10 +173,25 @@ type Layout struct {
161 Preview domain.Rect173 Preview domain.Rect
162 }174 }
163 175
176+// Geometry is the single picker layout contract consumed by rendering and
177+// remote preview sizing. Status always reserves the final available inner row.
178+type Geometry struct {
179+ Content domain.Rect
180+ Status domain.Rect
181+ Mode LayoutMode
182+ List domain.Rect
183+ Separator domain.Rect
184+ Preview domain.Rect
185+}
186+
164 type Model struct {187 type Model struct {
165- mode SelectionMode188+ mode SelectionMode
166- rows []row189+ rows []row
167- selected int190+ selected int
191+ searchActive bool
192+ query ui.TextInput
193+ searchMatches map[int]searchMatch
194+ matchRows []int
168 }195 }
169 196
170 type rowKind uint8197 type rowKind uint8
@@ -172,8 +199,40 @@ type rowKind uint8
172 const (199 const (
173 rowSession rowKind = iota200 rowSession rowKind = iota
174 rowTab201 rowTab
202+ rowSection
175 )203 )
176 204
205+type rowStatus uint8
206+
207+const (
208+ rowStatusNone rowStatus = iota
209+ rowStatusUp
210+ rowStatusStopped
211+ rowStatusDown
212+ rowStatusStale
213+ rowStatusVersion
214+ rowStatusError
215+)
216+
217+func (s rowStatus) badge() string {
218+ switch s {
219+ case rowStatusUp:
220+ return "[up]"
221+ case rowStatusStopped:
222+ return "[stopped]"
223+ case rowStatusDown:
224+ return "[down]"
225+ case rowStatusStale:
226+ return "[stale]"
227+ case rowStatusVersion:
228+ return "[version]"
229+ case rowStatusError:
230+ return "[error]"
231+ default:
232+ return ""
233+ }
234+}
235+
177 func (k rowKind) rendersAsHeader() bool {236 func (k rowKind) rendersAsHeader() bool {
178 return k == rowSession237 return k == rowSession
179 }238 }
@@ -199,6 +258,7 @@ type row struct {
199 // targetName is the named-session lookup name threaded into Target;258 // targetName is the named-session lookup name threaded into Target;
200 // distinct from dispName, which is what gets drawn.259 // distinct from dispName, which is what gets drawn.
201 targetName string260 targetName string
261+ sessionDisplay string
202 tabID domain.TabStableID262 tabID domain.TabStableID
203 tabIndex int263 tabIndex int
204 stopped bool264 stopped bool
@@ -214,6 +274,12 @@ type row struct {
214 selectable bool274 selectable bool
215 focusable bool275 focusable bool
216 dim bool276 dim bool
277+ status rowStatus
278+ statusDetail string
279+ foldedName string
280+ foldedDetail string
281+ foldedSession string
282+ foldedHost string
217 }283 }
218 284
219 func New(sessions []SessionView, config SelectionConfig) *Model {285 func New(sessions []SessionView, config SelectionConfig) *Model {
@@ -221,6 +287,12 @@ func New(sessions []SessionView, config SelectionConfig) *Model {
221 activeSelection := -1287 activeSelection := -1
222 for _, session := range sessions {288 for _, session := range sessions {
223 sessionRows := rowsForSession(session, config)289 sessionRows := rowsForSession(session, config)
290+ if len(sessionRows) == 0 {
291+ continue
292+ }
293+ if session.Section != "" {
294+ m.rows = append(m.rows, row{kind: rowSection, dispName: session.Section, dim: true})
295+ }
224 for _, pickerRow := range sessionRows {296 for _, pickerRow := range sessionRows {
225 idx := len(m.rows)297 idx := len(m.rows)
226 m.rows = append(m.rows, pickerRow)298 m.rows = append(m.rows, pickerRow)
@@ -269,8 +341,8 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
269 }341 }
270 expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt)342 expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt)
271 common := row{343 common := row{
272- session: session.ID, incarnation: session.Incarnation, targetName: targetName,344+ session: session.ID, incarnation: session.Incarnation, targetName: targetName, sessionDisplay: session.Name,
273- stopped: stopped, expectedCreatedAt: expectedCreatedAt,345+ stopped: stopped, status: statusForSession(session, stopped), statusDetail: session.RemoteDetail, expectedCreatedAt: expectedCreatedAt,
274 hasExpectedCreatedAt: hasExpectedCreatedAt,346 hasExpectedCreatedAt: hasExpectedCreatedAt,
275 }347 }
276 if session.RemoteKey != nil {348 if session.RemoteKey != nil {
@@ -285,21 +357,19 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
285 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1357 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
286 if common.hasRemoteKey {358 if common.hasRemoteKey {
287 header.dispName = common.remoteKey.Name359 header.dispName = common.remoteKey.Name
288- display := common.remoteKey.Display()360+ if !session.HideRemoteOrigin {
289- header.detail = display[len(common.remoteKey.Name):]361+ display := common.remoteKey.Display()
362+ header.detail = display[len(common.remoteKey.Name):]
363+ }
290 }364 }
291 header.selectable = header.kind.selectable(config.Mode)365 header.selectable = header.kind.selectable(config.Mode)
292 header.focusable = header.selectable366 header.focusable = header.selectable
367+ if config.Mode == SelectNavigationTab && stopped && len(session.Tabs) == 0 {
368+ header.selectable, header.focusable = true, true
369+ }
293 if common.remote {370 if common.remote {
294- if session.RemoteDetail != "" {
295- if header.detail != "" {
296- header.detail += " (" + session.RemoteDetail + ")"
297- } else {
298- header.detail = session.RemoteDetail
299- }
300- }
301 if common.hasRemoteTarget {371 if common.hasRemoteTarget {
302- header.selectable = false372+ header.selectable = config.Mode == SelectNavigationTab && stopped && len(session.Tabs) == 0 && remoteActivatable(session) && common.remoteTarget.Validate() == nil
303 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0373 header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0
304 } else {374 } else {
305 header.selectable = false375 header.selectable = false
@@ -311,6 +381,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
311 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {381 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
312 header.selectable, header.focusable, header.dim = false, false, true382 header.selectable, header.focusable, header.dim = false, false, true
313 }383 }
384+ header.prepareSearch()
314 rows := []row{header}385 rows := []row{header}
315 for i, tab := range session.Tabs {386 for i, tab := range session.Tabs {
316 if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {387 if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
@@ -355,6 +426,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
355 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {426 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
356 tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true427 tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
357 }428 }
429+ tabRow.prepareSearch()
358 rows = append(rows, tabRow)430 rows = append(rows, tabRow)
359 }431 }
360 if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {432 if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {
@@ -363,6 +435,13 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
363 return rows435 return rows
364 }436 }
365 437
438+func (r *row) prepareSearch() {
439+ r.foldedName = strings.ToLower(r.dispName)
440+ r.foldedDetail = strings.ToLower(r.detail)
441+ r.foldedSession = strings.ToLower(r.sessionDisplay)
442+ r.foldedHost = strings.ToLower(r.remoteHost)
443+}
444+
366 func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) {445 func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) {
367 if tabCount > math.MaxUint16 || index < 0 || index >= tabCount {446 if tabCount > math.MaxUint16 || index < 0 || index >= tabCount {
368 return domain.TabSelector{}, false447 return domain.TabSelector{}, false
@@ -370,6 +449,32 @@ func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (doma
370 return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true449 return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true
371 }450 }
372 451
452+func statusForSession(session SessionView, stopped bool) rowStatus {
453+ remote := session.RemoteKey != nil || session.RemoteTarget != nil || session.RemoteHost != ""
454+ if remote {
455+ switch session.RemoteReason {
456+ case "host_unreachable":
457+ return rowStatusDown
458+ case "version_mismatch":
459+ return rowStatusVersion
460+ case "refreshing", "catalog_stale":
461+ return rowStatusStale
462+ case "malformed", "session_broken", "identity_changed":
463+ return rowStatusError
464+ }
465+ }
466+ if stopped {
467+ return rowStatusStopped
468+ }
469+ if !remote {
470+ return rowStatusNone
471+ }
472+ if session.RemoteActivation == RemoteAttach {
473+ return rowStatusUp
474+ }
475+ return rowStatusError
476+}
477+
373 func remoteActivatable(session SessionView) bool {478 func remoteActivatable(session SessionView) bool {
374 return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart479 return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart
375 }480 }
@@ -397,21 +502,19 @@ func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) b
397 return false502 return false
398 }503 }
399 if pickerRow.hasRemoteKey && pickerRow.kind == rowSession {504 if pickerRow.hasRemoteKey && pickerRow.kind == rowSession {
400- return current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey505+ return pickerRow.focusable && (current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey)
401 }506 }
402 if mode == SelectMoveTabSession {507 if mode == SelectMoveTabSession {
403 return pickerRow.kind == rowSession508 return pickerRow.kind == rowSession
404 }509 }
510+ // A stopped session with no retained tab metadata uses its selectable
511+ // header as the default restart target and matches on exact session identity.
512+ if current.TabID == "" && pickerRow.stopped {
513+ return pickerRow.kind == rowSession && pickerRow.selectable
514+ }
405 if pickerRow.kind != rowTab {515 if pickerRow.kind != rowTab {
406 return false516 return false
407 }517 }
408- // A stopped session contributes one synthetic tab row carrying no stable tab
409- // identity, so it matches on session identity alone. Its session ID is
410- // namespaced ("stopped:<name>"), and move modes drop stopped sessions
411- // entirely, so no live row can be reached this way.
412- if current.TabID == "" {
413- return pickerRow.stopped
414- }
415 return pickerRow.tabID == current.TabID518 return pickerRow.tabID == current.TabID
416 }519 }
417 520
@@ -422,14 +525,30 @@ func int64Value(value *int64) (int64, bool) {
422 return *value, true525 return *value, true
423 }526 }
424 527
528+// ChooseGeometry reserves the final inner row for picker-local status before
529+// solving the list and preview layout.
530+func ChooseGeometry(inner domain.Size) Geometry {
531+ if inner.Cols <= 0 || inner.Rows <= 0 {
532+ return Geometry{}
533+ }
534+ contentRows := max(inner.Rows-1, 0)
535+ layout := ChooseLayout(domain.Size{Cols: inner.Cols, Rows: contentRows})
536+ return Geometry{
537+ Content: domain.Rect{Width: inner.Cols, Height: contentRows},
538+ Status: domain.Rect{Y: contentRows, Width: inner.Cols, Height: 1},
539+ Mode: layout.Mode, List: layout.List, Separator: layout.Separator, Preview: layout.Preview,
540+ }
541+}
542+
425 func ChooseLayout(inner domain.Size) Layout {543 func ChooseLayout(inner domain.Size) Layout {
426 if inner.Cols <= 0 || inner.Rows <= 0 {544 if inner.Cols <= 0 || inner.Rows <= 0 {
427 return Layout{}545 return Layout{}
428 }546 }
429 if inner.Rows >= MinPaneHeight {547 if inner.Rows >= MinPaneHeight {
430- listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth)548+ listWidth := clamp(inner.Cols*40/100, MinListWidth, MaxListWidth)
549+ listWidth = min(listWidth, inner.Cols-MinHorizontalPreviewWidth-1)
431 previewWidth := inner.Cols - listWidth - 1550 previewWidth := inner.Cols - listWidth - 1
432- if previewWidth >= MinHorizontalPreviewWidth {551+ if listWidth >= MinHorizontalListWidth && previewWidth >= MinHorizontalPreviewWidth {
433 return Layout{552 return Layout{
434 Mode: LayoutHorizontal,553 Mode: LayoutHorizontal,
435 List: domain.Rect{Width: listWidth, Height: inner.Rows},554 List: domain.Rect{Width: listWidth, Height: inner.Rows},
@@ -452,10 +571,18 @@ func ChooseLayout(inner domain.Size) Layout {
452 }571 }
453 572
454 func (m *Model) Up() {573 func (m *Model) Up() {
574+ if m != nil && m.searchActive {
575+ m.moveSearch(-1)
576+ return
577+ }
455 m.move(-1)578 m.move(-1)
456 }579 }
457 580
458 func (m *Model) Down() {581 func (m *Model) Down() {
582+ if m != nil && m.searchActive {
583+ m.moveSearch(1)
584+ return
585+ }
459 m.move(1)586 m.move(1)
460 }587 }
461 588
@@ -474,7 +601,7 @@ func (m *Model) Selected() (Target, bool) {
474 return Target{}, false601 return Target{}, false
475 }602 }
476 r := m.rows[m.selected]603 r := m.rows[m.selected]
477- if !r.selectable {604+ if !r.selectable || m.searchActive && m.query.Value() != "" && !m.rowMatches(m.selected) {
478 return Target{}, false605 return Target{}, false
479 }606 }
480 return r.target(), true607 return r.target(), true
@@ -519,25 +646,31 @@ func (m *Model) Clone() *Model {
519 }646 }
520 clone := *m647 clone := *m
521 clone.rows = append([]row(nil), m.rows...)648 clone.rows = append([]row(nil), m.rows...)
649+ clone.query.SetValue(m.query.Value())
650+ // Search results are copy-on-write: every query mutation publishes a new
651+ // map and row slice, so render snapshots can safely share the old values.
652+ clone.matchRows = m.matchRows
653+ clone.searchMatches = m.searchMatches
522 return &clone654 return &clone
523 }655 }
524 656
525 func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame {657 func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame {
526 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))658 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
527- layout := ChooseLayout(inner)659+ geometry := ChooseGeometry(inner)
528 styleSet := defaultRenderStyles()660 styleSet := defaultRenderStyles()
529 if len(styles) > 0 {661 if len(styles) > 0 {
530 styleSet = styles[0]662 styleSet = styles[0]
531 }663 }
532 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background})664 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background})
533- m.renderList(frame, layout.List, styleSet)665+ m.renderList(frame, geometry.List, styleSet)
534- switch layout.Mode {666+ switch geometry.Mode {
535 case LayoutHorizontal:667 case LayoutHorizontal:
536- ui.DrawSeparator(frame, layout.Separator, ui.SeparatorVertical, styleSet.Separator)668+ ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorVertical, styleSet.Separator)
537 case LayoutStacked:669 case LayoutStacked:
538- ui.DrawSeparator(frame, layout.Separator, ui.SeparatorHorizontal, styleSet.Separator)670+ ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorHorizontal, styleSet.Separator)
539 }671 }
540- ui.BlitFrame(frame, layout.Preview, preview, ui.VerticalAnchorBottom)672+ ui.BlitFrame(frame, geometry.Preview, preview, ui.VerticalAnchorBottom)
673+ m.renderStatus(frame, geometry.Status, styleSet.Status)
541 return frame674 return frame
542 }675 }
543 676
@@ -613,13 +746,16 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
613 }746 }
614 r := m.rows[idx]747 r := m.rows[idx]
615 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail748 base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
749+ if r.kind == rowSection {
750+ nameStyle = styles.Detail
751+ }
616 if idx == m.selected {752 if idx == m.selected {
617 base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted753 base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
618 }754 }
619 if r.stopped && idx != m.selected {755 if r.stopped && idx != m.selected {
620 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped756 base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
621 }757 }
622- if r.dim {758+ if r.dim || m.searchActive && m.query.Value() != "" && !m.rowMatches(idx) {
623 base.Attrs |= renderer.AttrDim759 base.Attrs |= renderer.AttrDim
624 nameStyle.Attrs |= renderer.AttrDim760 nameStyle.Attrs |= renderer.AttrDim
625 detailStyle.Attrs |= renderer.AttrDim761 detailStyle.Attrs |= renderer.AttrDim
@@ -627,29 +763,43 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
627 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})763 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
628 764
629 name := r.dispName765 name := r.dispName
630- if !r.kind.rendersAsHeader() {766+ if r.kind == rowTab {
631 name = " " + name767 name = " " + name
632 }768 }
633- suffix := ""769+ badge := ""
634- if r.kind.rendersAsHeader() && r.stopped {770+ if r.kind.rendersAsHeader() {
635- suffix = "(down)"771+ badge = r.status.badge()
772+ }
773+ contentClipX := clipX
774+ nameWidth := rect.Width
775+ badgeWidth := textCellWidth(badge)
776+ if badge != "" {
777+ nameWidth = max(rect.Width-badgeWidth-1, 0)
778+ contentClipX = max(rect.X, clipX-badgeWidth-1)
779+ }
780+ originalName := name
781+ name = ui.TruncateText(name, nameWidth)
782+ nameMatchStyle := styles.SearchMatch
783+ if idx == m.selected {
784+ nameMatchStyle = styles.SelectionMatch
785+ }
786+ namePositions := m.matchPositions(idx, matchName)
787+ if r.kind == rowTab {
788+ namePositions = shiftPositions(namePositions, 2)
636 }789 }
637- name = ui.TruncateText(name, max(rect.Width-len(suffix), 0))790+ namePositions = visibleMatchPositions(namePositions, name, name != originalName)
638- x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)791+ x := drawMatchedText(frame, rect.X, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
639 792
793+ if r.kind == rowSection {
794+ continue
795+ }
640 if r.kind.rendersAsHeader() {796 if r.kind.rendersAsHeader() {
641- if r.stopped {797+ detail := ui.TruncateText(r.detail, contentClipX-x)
642- detail := r.detail798+ detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.detail)
643- if detail == "" {799+ drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
644- detail = " "800+ if badge != "" {
645- } else {801+ badgeX := max(rect.X, clipX-badgeWidth)
646- detail += " "802+ ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, base)
647- }
648- detail = ui.TruncateText(detail, max(clipX-x-len(suffix), 0))
649- x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, base)
650- ui.DrawText(frame, x, rect.Y+y, clipX, suffix, base)
651- } else {
652- ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)
653 }803 }
654 continue804 continue
655 }805 }
@@ -659,8 +809,108 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
659 }809 }
660 810
661 detail := ui.TruncateText(r.detail, clipX-x)811 detail := ui.TruncateText(r.detail, clipX-x)
662- ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)812+ detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.detail)
813+ drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions)
814+ }
815+}
816+
817+func (m *Model) matchPositions(idx int, field matchField) []int {
818+ if m == nil || !m.searchActive || m.query.Value() == "" {
819+ return nil
820+ }
821+ return m.searchMatches[idx].positions(field)
822+}
823+
824+func visibleMatchPositions(positions []int, rendered string, truncated bool) []int {
825+ if !truncated || len(positions) == 0 {
826+ return positions
827+ }
828+ limit := max(len([]rune(rendered))-1, 0) // the final rune is the ellipsis
829+ visible := make([]int, 0, len(positions))
830+ for _, position := range positions {
831+ if position < limit {
832+ visible = append(visible, position)
833+ }
834+ }
835+ return visible
836+}
837+
838+func shiftPositions(positions []int, delta int) []int {
839+ if len(positions) == 0 || delta == 0 {
840+ return positions
841+ }
842+ shifted := make([]int, len(positions))
843+ for i, position := range positions {
844+ shifted[i] = position + delta
845+ }
846+ return shifted
847+}
848+
849+func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int {
850+ if len(positions) == 0 {
851+ return ui.DrawText(frame, x, y, clipX, text, base)
852+ }
853+ positionIndex := 0
854+ runeIndex := 0
855+ for _, r := range text {
856+ style := base
857+ if positionIndex < len(positions) && positions[positionIndex] == runeIndex {
858+ style = match
859+ positionIndex++
860+ }
861+ x = ui.DrawText(frame, x, y, clipX, string(r), style)
862+ if x >= clipX {
863+ break
864+ }
865+ runeIndex++
866+ }
867+ return x
868+}
869+
870+func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style renderer.Style) {
871+ if rect.Width <= 0 || rect.Height <= 0 || rect.Y < 0 || rect.Y >= frame.Height {
872+ return
873+ }
874+ ui.FillRect(frame, rect, renderer.Cell{Rune: ' ', Style: style})
875+ action := "open"
876+ deletable := false
877+ if m != nil && m.selected >= 0 && m.selected < len(m.rows) {
878+ selected := m.rows[m.selected]
879+ if !selected.selectable {
880+ action = "unavailable"
881+ } else if selected.stopped {
882+ action = "restart"
883+ }
884+ deletable = selected.selectable && !selected.remote
885+ }
886+ var groups []string
887+ if m != nil && m.searchActive {
888+ groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), "Enter " + action, "arrows next"}
889+ escape := "Esc exit"
890+ if m.query.Value() != "" {
891+ escape = "Esc clear"
892+ }
893+ groups = append(groups, escape)
894+ } else {
895+ if m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].dim && m.rows[m.selected].statusDetail != "" {
896+ groups = append(groups, m.rows[m.selected].statusDetail)
897+ }
898+ if rect.Width < 60 {
899+ groups = append(groups, "Enter "+action, "/", "Esc", "j/k")
900+ if deletable {
901+ groups = append(groups, "x")
902+ }
903+ groups = append(groups, "s")
904+ } else {
905+ groups = append(groups, "j/k move", "Enter "+action)
906+ if deletable {
907+ groups = append(groups, "x delete")
908+ }
909+ groups = append(groups, "/ search", "s sort", "Esc close")
910+ }
663 }911 }
912+ text := strings.Join(groups, " ")
913+ ui.DrawText(frame, rect.X, rect.Y, rect.X+rect.Width, ui.TruncateText(text, rect.Width), style)
664 }914 }
665 915
666 func (m *Model) scrollOffset(visible int) int {916 func (m *Model) scrollOffset(visible int) int {
M internal/usecase/picker/model_test.go +238 -28
@@ -1,6 +1,7 @@
11 package picker
22
33 import (
4+ "fmt"
45 "math"
56 "reflect"
67 "strings"
@@ -335,8 +336,8 @@ func TestRenderDrawsCustomOrientedSeparators(t *testing.T) {
335336 rect domain.Rect
336337 rune rune
337338 }{
338- {"horizontal layout", domain.Size{Cols: 69, Rows: 4}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'},
339- {"stacked layout", domain.Size{Cols: 24, Rows: 12}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'},
339+ {"horizontal layout", domain.Size{Cols: 69, Rows: 5}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'},
340+ {"stacked layout", domain.Size{Cols: 24, Rows: 13}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'},
340341 }
341342 for _, tt := range tests {
342343 t.Run(tt.name, func(t *testing.T) {
@@ -356,26 +357,28 @@ func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) {
356357 m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
357358 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}})
358359
359- require.Equal(t, ' ', frame.At(22, 10).Rune)
360360 require.NotEqual(t, 'x', frame.At(22, 10).Rune)
361+ require.Equal(t, 'E', frame.At(0, 10).Rune, "the final inner row is reserved for status")
361362 }
362363
363364 func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {
364365 m := New(nil, SelectionConfig{Mode: SelectNavigationTab})
365366 preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")}
366367
367- frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
368+ frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview)
368369 require.Equal(t, 'c', frame.At(0, 5).Rune)
369370 require.Equal(t, 'i', frame.At(0, 11).Rune)
371+ require.Equal(t, 'E', frame.At(0, 12).Rune)
370372 }
371373
372374 func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {
373375 m := New(nil, SelectionConfig{Mode: SelectNavigationTab})
374376 preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")}
375377
376- frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
378+ frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview)
377379 require.Equal(t, ' ', frame.At(0, 5).Rune)
378380 require.Equal(t, 'z', frame.At(0, 11).Rune)
381+ require.Equal(t, 'E', frame.At(0, 12).Rune)
379382 }
380383
381384 func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) {
@@ -492,35 +495,64 @@ func TestSelectedIndexReportsRawSelectedRow(t *testing.T) {
492495 require.Equal(t, 1, m.SelectedIndex())
493496 }
494497
495-func TestStoppedSessionRendersDownSuffix(t *testing.T) {
498+func TestStoppedSessionUsesOneSelectableHeaderWithRightAlignedStatus(t *testing.T) {
496499 for _, tt := range []struct {
497500 name string
498501 sessionID domain.SessionID
499502 sessionName string
500503 size domain.Size
504+ wantRow string
501505 }{
502- {name: "normal", sessionID: "stopped:work", sessionName: "work", size: domain.Size{Cols: 24, Rows: 4}},
503- {name: "constrained", sessionID: "stopped:long", sessionName: "long-session", size: domain.Size{Cols: 8, Rows: 2}},
506+ {name: "normal", sessionID: "stopped:work", sessionName: "work", size: domain.Size{Cols: 24, Rows: 4}, wantRow: "work [stopped]"},
507+ {name: "constrained", sessionID: "stopped:long", sessionName: "long-session", size: domain.Size{Cols: 12, Rows: 2}, wantRow: "l… [stopped]"},
504508 } {
505509 t.Run(tt.name, func(t *testing.T) {
506- m := New([]SessionView{{ID: tt.sessionID, Name: tt.sessionName, Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
510+ m := New([]SessionView{{ID: tt.sessionID, Name: tt.sessionName, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
507511 got, ok := m.Selected()
508512 require.True(t, ok)
509- require.Equal(t, Target{Session: tt.sessionID, Name: tt.sessionName, TabIndex: 0, Stopped: true}, got)
513+ require.Equal(t, Target{Session: tt.sessionID, Name: tt.sessionName, TabIndex: -1, Stopped: true}, got)
514+ require.Len(t, m.rows, 1)
510515
511516 frame := m.Render(tt.size, Preview{})
512517 var label strings.Builder
513518 for _, cell := range frame.Row(0) {
514519 label.WriteRune(cell.Rune)
515520 }
516- require.Contains(t, label.String(), "(down)")
521+ require.Equal(t, tt.wantRow, strings.TrimRight(label.String(), " "))
517522 })
518523 }
519524 }
520525
526+func TestRemoteStatusBadgesAlignRightAndStatusOmitsDelete(t *testing.T) {
527+ lifecycle := domain.SessionLifecycleID{1}
528+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
529+ target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab"}
530+ m := New([]SessionView{{
531+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &target, RemoteHost: "arch",
532+ RemoteActivation: RemoteAttach, Tabs: []TabEntry{{TabID: "tab", Name: "shell"}},
533+ }}, SelectionConfig{Mode: SelectNavigationTab})
534+
535+ frame := m.Render(domain.Size{Cols: 24, Rows: 4}, Preview{})
536+ require.Equal(t, "work@arch [up]", strings.TrimRight(rowText(frame.Row(0)), " "))
537+ status := rowText(frame.Row(3))
538+ require.Contains(t, status, "Enter open")
539+ require.NotContains(t, status, "x delete")
540+}
541+
542+func rowText(row []renderer.Cell) string {
543+ var text strings.Builder
544+ for _, cell := range row {
545+ if cell.Continuation {
546+ continue
547+ }
548+ text.WriteRune(cell.Rune)
549+ }
550+ return text.String()
551+}
552+
521553 func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
522554 live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}}
523- halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true, Tabs: []TabEntry{{}}}
555+ halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true}
524556 m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab})
525557
526558 stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim}
@@ -528,15 +560,14 @@ func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
528560 require.Equal(t, stoppedStyle, styles.Stopped)
529561
530562 frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
531- // Rows: 0 "work" header, 1 " tab" (selected), 2 "old (down)" header, 3 its tab row.
563+ // Rows: 0 "work" header, 1 " tab" (selected), 2 "old [stopped]" header.
532564 require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic")
533- require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic")
534565 require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style")
535566
536567 selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}})
537568 selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
538- require.NotEqual(t, stoppedStyle, selectedFrame.Row(3)[0].Style, "selected stopped row keeps selection style, not Stopped")
539- require.True(t, selectedFrame.Row(3)[0].Style.Inverse, "selected stopped row still shows selection")
569+ require.NotEqual(t, stoppedStyle, selectedFrame.Row(2)[0].Style, "selected stopped row keeps selection style, not Stopped")
570+ require.True(t, selectedFrame.Row(2)[0].Style.Inverse, "selected stopped row still shows selection")
540571 }
541572
542573 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
@@ -551,12 +582,13 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
551582 }},
552583 }
553584
554- frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)
585+ frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview)
555586 require.True(t, frame.At(0, 1).Style.Inverse)
556587 require.Equal(t, 'a', frame.At(0, 11).Rune)
557588 require.Equal(t, 'w', frame.At(22, 11).Rune)
558589 require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped")
559590 require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored")
591+ require.Equal(t, 'E', frame.At(0, 12).Rune)
560592 }
561593
562594 func TestRenderListScrollsSelectionIntoView(t *testing.T) {
@@ -566,9 +598,10 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
566598 }
567599
568600 frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{})
569- require.Equal(t, ' ', frame.At(0, 3).Rune)
570- require.Equal(t, 'f', frame.At(2, 3).Rune)
571- require.True(t, frame.At(0, 3).Style.Inverse)
601+ require.Equal(t, ' ', frame.At(0, 2).Rune)
602+ require.Equal(t, 'f', frame.At(2, 2).Rune)
603+ require.True(t, frame.At(0, 2).Style.Inverse)
604+ require.Equal(t, 'E', frame.At(0, 3).Rune)
572605 }
573606
574607 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
@@ -576,7 +609,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
576609
577610 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
578611
579- layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
612+ layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5})
580613 require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
581614 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge")
582615 require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
@@ -640,7 +673,7 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
640673 }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
641674
642675 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
643- layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
676+ layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5})
644677 require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
645678
646679 want := " short-name (a ver…"
@@ -657,13 +690,189 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
657690 }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
658691
659692 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
660- layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})
693+ layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5})
661694 require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
662695
663696 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")
664697 require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
665698 }
666699
700+func TestSearchSelectsBestMatchAndPreservesExactTarget(t *testing.T) {
701+ m := New([]SessionView{
702+ {ID: "backend", Name: "backend", Tabs: []TabEntry{{TabID: "api-tab", Name: "api-server"}, {TabID: "logs-tab", Name: "logs"}}},
703+ {ID: "frontend", Name: "frontend", Tabs: []TabEntry{{TabID: "web-tab", Name: "web"}}},
704+ }, SelectionConfig{Mode: SelectNavigationTab})
705+
706+ m.EnterSearch()
707+ for _, r := range "api" {
708+ m.InsertSearch(r)
709+ }
710+
711+ require.True(t, m.SearchActive())
712+ require.Equal(t, "api", m.Query())
713+ require.Equal(t, 1, m.MatchCount())
714+ selected, ok := m.Selected()
715+ require.True(t, ok)
716+ require.Equal(t, Target{Session: "backend", TabID: "api-tab", TabIndex: 0}, selected)
717+ require.Equal(t, " Search sessions & tabs: api_ ", m.SearchTitle())
718+}
719+
720+func TestSearchUsesContextWithoutHighlightingUnrelatedText(t *testing.T) {
721+ m := New([]SessionView{
722+ {ID: "backend", Name: "backend", Tabs: []TabEntry{{TabID: "logs-tab", Name: "logs"}}},
723+ {ID: "frontend", Name: "frontend", Tabs: []TabEntry{{TabID: "web-tab", Name: "web"}}},
724+ }, SelectionConfig{Mode: SelectNavigationTab})
725+ m.EnterSearch()
726+ for _, r := range "back" {
727+ m.InsertSearch(r)
728+ }
729+
730+ selected, ok := m.Selected()
731+ require.True(t, ok)
732+ require.Equal(t, domain.TabStableID("logs-tab"), selected.TabID, "a parent-session match keeps its tab target navigable")
733+
734+ matchStyle := renderer.Style{Bold: true, Foreground: 2}
735+ frame := m.Render(domain.Size{Cols: 40, Rows: 6}, Preview{}, RenderStyles{
736+ Background: renderer.DefaultStyle(), Base: renderer.DefaultStyle(), Name: renderer.DefaultStyle(), Detail: renderer.DefaultStyle(),
737+ Selection: renderer.Style{Inverse: true}, SelectionName: renderer.Style{Inverse: true}, SelectionMuted: renderer.Style{Inverse: true},
738+ SearchMatch: matchStyle, SelectionMatch: matchStyle,
739+ })
740+ for x := 2; x < 6; x++ {
741+ require.False(t, frame.At(x, 1).Style.Equal(matchStyle), "context matches must not highlight unrelated rendered tab text")
742+ }
743+ require.True(t, frame.At(0, 3).Style.Attrs&renderer.AttrDim != 0, "non-matching rows are dimmed")
744+}
745+
746+func TestSearchZeroMatchesSuppressesActivationAndRetainsCursorAnchor(t *testing.T) {
747+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}}, SelectionConfig{Mode: SelectNavigationTab})
748+ before, ok := m.Cursor()
749+ require.True(t, ok)
750+
751+ m.EnterSearch()
752+ for _, r := range "absent" {
753+ m.InsertSearch(r)
754+ }
755+
756+ require.Zero(t, m.MatchCount())
757+ _, ok = m.Selected()
758+ require.False(t, ok)
759+ after, ok := m.Cursor()
760+ require.True(t, ok)
761+ require.Equal(t, before, after)
762+}
763+
764+func TestSearchTitleKeepsQueryTailAndCaretAtNarrowWidth(t *testing.T) {
765+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}}, SelectionConfig{Mode: SelectNavigationTab})
766+ m.EnterSearch()
767+ for _, r := range "abcdefghijklmnopqrstuv" {
768+ m.InsertSearch(r)
769+ }
770+
771+ title := m.SearchTitle(20)
772+ require.LessOrEqual(t, textCellWidth(title), 20)
773+ require.True(t, strings.HasPrefix(title, " / "))
774+ require.True(t, strings.HasSuffix(title, "v_ "), "the visible title keeps the query tail and caret")
775+
776+ for width := 0; width < 5; width++ {
777+ t.Run(fmt.Sprintf("degenerate width %d", width), func(t *testing.T) {
778+ got := m.SearchTitle(width)
779+ require.LessOrEqual(t, textCellWidth(got), width)
780+ require.NotContains(t, got, "v", "degenerate titles must not force a query cell")
781+ })
782+ }
783+}
784+
785+func TestSearchDoesNotHighlightEllipsisForTruncatedWideMatch(t *testing.T) {
786+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "abcdefghijklmnopq界"}}}}, SelectionConfig{Mode: SelectNavigationTab})
787+ m.EnterSearch()
788+ m.InsertSearch('界')
789+ matchStyle := renderer.Style{Foreground: 2, Bold: true}
790+ frame := m.Render(domain.Size{Cols: 20, Rows: 3}, Preview{}, RenderStyles{
791+ Background: renderer.DefaultStyle(), Base: renderer.DefaultStyle(), Name: renderer.DefaultStyle(), Detail: renderer.DefaultStyle(),
792+ Selection: renderer.Style{Inverse: true}, SelectionName: renderer.Style{Inverse: true}, SelectionMuted: renderer.Style{Inverse: true},
793+ SearchMatch: matchStyle, SelectionMatch: matchStyle,
794+ })
795+
796+ require.Equal(t, '…', frame.At(19, 1).Rune)
797+ require.False(t, frame.At(19, 1).Style.Equal(matchStyle), "a dropped wide-rune match must not style the truncation ellipsis")
798+}
799+
800+func TestReplaceFromMovesStoppedHeaderCursorToNewFocusableTab(t *testing.T) {
801+ lifecycle := domain.SessionLifecycleID{1}
802+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
803+ emptyTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", Stopped: true}
804+ m := New([]SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &emptyTarget, Stopped: true, RemoteActivation: RemoteRestart}}, SelectionConfig{Mode: SelectNavigationTab})
805+ require.Equal(t, 0, m.SelectedIndex())
806+
807+ tabTarget := emptyTarget
808+ tabTarget.StoppedTab = domain.NewStableTabSelector("tab")
809+ retained := []SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &tabTarget, Stopped: true, RemoteActivation: RemoteRestart, Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}}
810+ next := New(retained, SelectionConfig{Mode: SelectNavigationTab})
811+ direct := New(retained, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID(), RemoteKey: &key}})
812+ directSelected, ok := direct.Selected()
813+ require.True(t, ok)
814+ require.Equal(t, domain.TabStableID("tab"), directSelected.TabID, "retained tabs must not pin the current cursor to the contextual header")
815+
816+ m.ReplaceFrom(next)
817+
818+ selected, ok := m.Selected()
819+ require.True(t, ok)
820+ require.Equal(t, domain.TabStableID("tab"), selected.TabID)
821+ require.Equal(t, 1, m.SelectedIndex(), "the contextual stopped header must not retain focus after real tabs appear")
822+}
823+
824+func TestReplaceFromPreservesStableTabAcrossReordering(t *testing.T) {
825+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "first", Name: "first"}, {TabID: "selected", Name: "selected"}}}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s", TabID: "selected"}})
826+ next := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "selected", Name: "selected"}, {TabID: "first", Name: "first"}}}}, SelectionConfig{Mode: SelectNavigationTab})
827+
828+ m.ReplaceFrom(next)
829+
830+ selected, ok := m.Selected()
831+ require.True(t, ok)
832+ require.Equal(t, domain.TabStableID("selected"), selected.TabID)
833+ require.Equal(t, 0, selected.TabIndex)
834+}
835+
836+func TestFilteredSessionDoesNotLeaveOrphanSection(t *testing.T) {
837+ m := New([]SessionView{{ID: "stopped", Section: "LOCAL", Name: "stopped", Stopped: true}}, SelectionConfig{Mode: SelectMovePaneTab})
838+ require.Empty(t, m.rows)
839+}
840+
841+func TestUnavailableStoppedRemoteUsesAvailabilityBadgeAndHint(t *testing.T) {
842+ lifecycle := domain.SessionLifecycleID{1}
843+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
844+ target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", Stopped: true}
845+ m := New([]SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &target, Stopped: true, RemoteReason: "catalog_stale", RemoteDetail: "catalog stale", RemoteActivation: RemoteUnavailable}}, SelectionConfig{Mode: SelectNavigationTab})
846+
847+ frame := m.Render(domain.Size{Cols: 40, Rows: 3}, Preview{})
848+ require.Contains(t, rowText(frame.Row(0)), "[stale]")
849+ require.Contains(t, rowText(frame.Row(2)), "Enter unavailable")
850+ require.NotContains(t, rowText(frame.Row(2)), "Enter restart")
851+}
852+
853+func TestSearchNavigationUsesCanonicalVisualOrderAfterBestRank(t *testing.T) {
854+ m := New([]SessionView{
855+ {ID: "first", Name: "first", Tabs: []TabEntry{{TabID: "zeta", Name: "zeta"}}},
856+ {ID: "second", Name: "second", Tabs: []TabEntry{{TabID: "a", Name: "a"}}},
857+ {ID: "third", Name: "third", Tabs: []TabEntry{{TabID: "alpha", Name: "alpha"}}},
858+ }, SelectionConfig{Mode: SelectNavigationTab})
859+ m.EnterSearch()
860+ m.InsertSearch('a')
861+
862+ selected, ok := m.Selected()
863+ require.True(t, ok)
864+ require.Equal(t, domain.TabStableID("a"), selected.TabID, "exact match wins initial ranking")
865+ m.Up()
866+ selected, ok = m.Selected()
867+ require.True(t, ok)
868+ require.Equal(t, domain.TabStableID("zeta"), selected.TabID, "navigation follows visual order rather than rank order")
869+ m.Down()
870+ m.Down()
871+ selected, ok = m.Selected()
872+ require.True(t, ok)
873+ require.Equal(t, domain.TabStableID("alpha"), selected.TabID)
874+}
875+
667876 func cell(r rune) renderer.Cell {
668877 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
669878 }
@@ -676,10 +885,11 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
676885 frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{
677886 Background: background, Base: base, Name: base, Detail: base,
678887 Selection: selection, SelectionName: selection, SelectionMuted: selection,
679- Separator: base,
888+ Separator: base, Status: base,
680889 })
681890
682- require.True(t, frame.At(19, 4).Style.Equal(background), "unused interior keeps modal base")
891+ require.True(t, frame.At(19, 3).Style.Equal(background), "unused content keeps modal base")
892+ require.True(t, frame.At(19, 4).Style.Equal(base), "status owns the final inner row")
683893 require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface")
684894 require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")
685895 }
@@ -733,10 +943,10 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
733943 Background: styles.PickerBase, Base: styles.PickerBase, Name: styles.PickerName,
734944 Detail: styles.PickerDescription, Selection: styles.PickerSelection,
735945 SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection,
736- Separator: styles.PickerSeparator,
946+ Separator: styles.PickerSeparator, Status: styles.PickerDescription,
737947 })
738- require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text keeps a contrast-derived foreground without a background tint")
739- require.True(t, frame.At(31, 3).Style.Equal(styles.PickerBase), "unused row cells retain the terminal background")
948+ require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "status text keeps a contrast-derived foreground without a background tint")
949+ require.True(t, frame.At(31, 3).Style.Equal(styles.PickerDescription), "status filler owns the final row")
740950 })
741951 }
742952 }
@@ -1,6 +1,7 @@
1 package picker1 package picker
2 2
3 import (3 import (
4+ "fmt"
4 "math"5 "math"
5 "reflect"6 "reflect"
6 "strings"7 "strings"
@@ -335,8 +336,8 @@ func TestRenderDrawsCustomOrientedSeparators(t *testing.T) {
335 rect domain.Rect336 rect domain.Rect
336 rune rune337 rune rune
337 }{338 }{
338- {"horizontal layout", domain.Size{Cols: 69, Rows: 4}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'},339+ {"horizontal layout", domain.Size{Cols: 69, Rows: 5}, domain.Rect{X: 20, Width: 1, Height: 4}, '│'},
339- {"stacked layout", domain.Size{Cols: 24, Rows: 12}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'},340+ {"stacked layout", domain.Size{Cols: 24, Rows: 13}, domain.Rect{Y: 4, Width: 24, Height: 1}, '─'},
340 }341 }
341 for _, tt := range tests {342 for _, tt := range tests {
342 t.Run(tt.name, func(t *testing.T) {343 t.Run(tt.name, func(t *testing.T) {
@@ -356,26 +357,28 @@ func TestRenderListOnlyOmitsSeparatorAndPreview(t *testing.T) {
356 m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})357 m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{Name: "tab"}}, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
357 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}})358 frame := m.Render(domain.Size{Cols: 23, Rows: 11}, Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}, RenderStyles{Separator: renderer.Style{Foreground: 8}})
358 359
359- require.Equal(t, ' ', frame.At(22, 10).Rune)
360 require.NotEqual(t, 'x', frame.At(22, 10).Rune)360 require.NotEqual(t, 'x', frame.At(22, 10).Rune)
361+ require.Equal(t, 'E', frame.At(0, 10).Rune, "the final inner row is reserved for status")
361 }362 }
362 363
363 func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {364 func TestRenderPreviewAnchorsOversizedSourceToFinalRows(t *testing.T) {
364 m := New(nil, SelectionConfig{Mode: SelectNavigationTab})365 m := New(nil, SelectionConfig{Mode: SelectNavigationTab})
365 preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")}366 preview := Preview{Width: 24, Height: 9, Rows: previewRows(24, "abcdefghi")}
366 367
367- frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)368+ frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview)
368 require.Equal(t, 'c', frame.At(0, 5).Rune)369 require.Equal(t, 'c', frame.At(0, 5).Rune)
369 require.Equal(t, 'i', frame.At(0, 11).Rune)370 require.Equal(t, 'i', frame.At(0, 11).Rune)
371+ require.Equal(t, 'E', frame.At(0, 12).Rune)
370 }372 }
371 373
372 func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {374 func TestRenderPreviewBottomPlacesShortSource(t *testing.T) {
373 m := New(nil, SelectionConfig{Mode: SelectNavigationTab})375 m := New(nil, SelectionConfig{Mode: SelectNavigationTab})
374 preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")}376 preview := Preview{Width: 24, Height: 1, Rows: previewRows(24, "z")}
375 377
376- frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)378+ frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview)
377 require.Equal(t, ' ', frame.At(0, 5).Rune)379 require.Equal(t, ' ', frame.At(0, 5).Rune)
378 require.Equal(t, 'z', frame.At(0, 11).Rune)380 require.Equal(t, 'z', frame.At(0, 11).Rune)
381+ require.Equal(t, 'E', frame.At(0, 12).Rune)
379 }382 }
380 383
381 func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) {384 func TestRenderAttentionMarkerSmokeWithResponsiveLayout(t *testing.T) {
@@ -492,35 +495,64 @@ func TestSelectedIndexReportsRawSelectedRow(t *testing.T) {
492 require.Equal(t, 1, m.SelectedIndex())495 require.Equal(t, 1, m.SelectedIndex())
493 }496 }
494 497
495-func TestStoppedSessionRendersDownSuffix(t *testing.T) {498+func TestStoppedSessionUsesOneSelectableHeaderWithRightAlignedStatus(t *testing.T) {
496 for _, tt := range []struct {499 for _, tt := range []struct {
497 name string500 name string
498 sessionID domain.SessionID501 sessionID domain.SessionID
499 sessionName string502 sessionName string
500 size domain.Size503 size domain.Size
504+ wantRow string
501 }{505 }{
502- {name: "normal", sessionID: "stopped:work", sessionName: "work", size: domain.Size{Cols: 24, Rows: 4}},506+ {name: "normal", sessionID: "stopped:work", sessionName: "work", size: domain.Size{Cols: 24, Rows: 4}, wantRow: "work [stopped]"},
503- {name: "constrained", sessionID: "stopped:long", sessionName: "long-session", size: domain.Size{Cols: 8, Rows: 2}},507+ {name: "constrained", sessionID: "stopped:long", sessionName: "long-session", size: domain.Size{Cols: 12, Rows: 2}, wantRow: "l… [stopped]"},
504 } {508 } {
505 t.Run(tt.name, func(t *testing.T) {509 t.Run(tt.name, func(t *testing.T) {
506- m := New([]SessionView{{ID: tt.sessionID, Name: tt.sessionName, Tabs: []TabEntry{{Name: ""}}, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})510+ m := New([]SessionView{{ID: tt.sessionID, Name: tt.sessionName, Stopped: true}}, SelectionConfig{Mode: SelectNavigationTab})
507 got, ok := m.Selected()511 got, ok := m.Selected()
508 require.True(t, ok)512 require.True(t, ok)
509- require.Equal(t, Target{Session: tt.sessionID, Name: tt.sessionName, TabIndex: 0, Stopped: true}, got)513+ require.Equal(t, Target{Session: tt.sessionID, Name: tt.sessionName, TabIndex: -1, Stopped: true}, got)
514+ require.Len(t, m.rows, 1)
510 515
511 frame := m.Render(tt.size, Preview{})516 frame := m.Render(tt.size, Preview{})
512 var label strings.Builder517 var label strings.Builder
513 for _, cell := range frame.Row(0) {518 for _, cell := range frame.Row(0) {
514 label.WriteRune(cell.Rune)519 label.WriteRune(cell.Rune)
515 }520 }
516- require.Contains(t, label.String(), "(down)")521+ require.Equal(t, tt.wantRow, strings.TrimRight(label.String(), " "))
517 })522 })
518 }523 }
519 }524 }
520 525
526+func TestRemoteStatusBadgesAlignRightAndStatusOmitsDelete(t *testing.T) {
527+ lifecycle := domain.SessionLifecycleID{1}
528+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
529+ target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab"}
530+ m := New([]SessionView{{
531+ ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &target, RemoteHost: "arch",
532+ RemoteActivation: RemoteAttach, Tabs: []TabEntry{{TabID: "tab", Name: "shell"}},
533+ }}, SelectionConfig{Mode: SelectNavigationTab})
534+
535+ frame := m.Render(domain.Size{Cols: 24, Rows: 4}, Preview{})
536+ require.Equal(t, "work@arch [up]", strings.TrimRight(rowText(frame.Row(0)), " "))
537+ status := rowText(frame.Row(3))
538+ require.Contains(t, status, "Enter open")
539+ require.NotContains(t, status, "x delete")
540+}
541+
542+func rowText(row []renderer.Cell) string {
543+ var text strings.Builder
544+ for _, cell := range row {
545+ if cell.Continuation {
546+ continue
547+ }
548+ text.WriteRune(cell.Rune)
549+ }
550+ return text.String()
551+}
552+
521 func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {553 func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
522 live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}}554 live := SessionView{ID: "live", Name: "work", Tabs: []TabEntry{{TabID: "t1", Name: "tab"}}}
523- halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true, Tabs: []TabEntry{{}}}555+ halted := SessionView{ID: "stopped:old", Name: "old", TargetName: "old", Stopped: true}
524 m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab})556 m := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab})
525 557
526 stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim}558 stoppedStyle := renderer.Style{Foreground: -1, Background: -1, Italic: true, Attrs: renderer.AttrDim}
@@ -528,15 +560,14 @@ func TestRenderStopsStoppedRowsDimItalic(t *testing.T) {
528 require.Equal(t, stoppedStyle, styles.Stopped)560 require.Equal(t, stoppedStyle, styles.Stopped)
529 561
530 frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})562 frame := m.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
531- // Rows: 0 "work" header, 1 " tab" (selected), 2 "old (down)" header, 3 its tab row.563+ // Rows: 0 "work" header, 1 " tab" (selected), 2 "old [stopped]" header.
532 require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic")564 require.Equal(t, stoppedStyle, frame.Row(2)[0].Style, "stopped header must be dim italic")
533- require.Equal(t, stoppedStyle, frame.Row(3)[0].Style, "stopped tab row must be dim italic")
534 require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style")565 require.NotEqual(t, stoppedStyle, frame.Row(0)[0].Style, "live header keeps base style")
535 566
536 selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}})567 selected := New([]SessionView{live, halted}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: halted.ID}})
537 selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})568 selectedFrame := selected.Render(domain.Size{Cols: 15, Rows: 6}, Preview{})
538- require.NotEqual(t, stoppedStyle, selectedFrame.Row(3)[0].Style, "selected stopped row keeps selection style, not Stopped")569+ require.NotEqual(t, stoppedStyle, selectedFrame.Row(2)[0].Style, "selected stopped row keeps selection style, not Stopped")
539- require.True(t, selectedFrame.Row(3)[0].Style.Inverse, "selected stopped row still shows selection")570+ require.True(t, selectedFrame.Row(2)[0].Style.Inverse, "selected stopped row still shows selection")
540 }571 }
541 572
542 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {573 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
@@ -551,12 +582,13 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
551 }},582 }},
552 }583 }
553 584
554- frame := m.Render(domain.Size{Cols: 24, Rows: 12}, preview)585+ frame := m.Render(domain.Size{Cols: 24, Rows: 13}, preview)
555 require.True(t, frame.At(0, 1).Style.Inverse)586 require.True(t, frame.At(0, 1).Style.Inverse)
556 require.Equal(t, 'a', frame.At(0, 11).Rune)587 require.Equal(t, 'a', frame.At(0, 11).Rune)
557 require.Equal(t, 'w', frame.At(22, 11).Rune)588 require.Equal(t, 'w', frame.At(22, 11).Rune)
558 require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped")589 require.Equal(t, ' ', frame.At(23, 11).Rune, "wide rune crossing preview pane is dropped")
559 require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored")590 require.Equal(t, ' ', frame.At(0, 5).Rune, "short preview is bottom anchored")
591+ require.Equal(t, 'E', frame.At(0, 12).Rune)
560 }592 }
561 593
562 func TestRenderListScrollsSelectionIntoView(t *testing.T) {594 func TestRenderListScrollsSelectionIntoView(t *testing.T) {
@@ -566,9 +598,10 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) {
566 }598 }
567 599
568 frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{})600 frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{})
569- require.Equal(t, ' ', frame.At(0, 3).Rune)601+ require.Equal(t, ' ', frame.At(0, 2).Rune)
570- require.Equal(t, 'f', frame.At(2, 3).Rune)602+ require.Equal(t, 'f', frame.At(2, 2).Rune)
571- require.True(t, frame.At(0, 3).Style.Inverse)603+ require.True(t, frame.At(0, 2).Style.Inverse)
604+ require.Equal(t, 'E', frame.At(0, 3).Rune)
572 }605 }
573 606
574 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {607 func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
@@ -576,7 +609,7 @@ func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) {
576 609
577 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})610 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
578 611
579- layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})612+ layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5})
580 require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")613 require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
581 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge")614 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge")
582 require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")615 require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
@@ -640,7 +673,7 @@ func TestRenderListTruncatesDetailBeforeName(t *testing.T) {
640 }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})673 }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
641 674
642 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})675 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
643- layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})676+ layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5})
644 require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")677 require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
645 678
646 want := " short-name (a ver…"679 want := " short-name (a ver…"
@@ -657,13 +690,189 @@ func TestRenderListTruncatesNameWhenAloneExceedsWidth(t *testing.T) {
657 }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})690 }, Active: 0}}, SelectionConfig{Mode: SelectNavigationTab})
658 691
659 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})692 frame := m.Render(domain.Size{Cols: 69, Rows: 5}, Preview{})
660- layout := ChooseLayout(domain.Size{Cols: 69, Rows: 5})693+ layout := ChooseGeometry(domain.Size{Cols: 69, Rows: 5})
661 require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")694 require.Equal(t, 20, layout.List.Width, "test assumes a narrow list column")
662 695
663 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")696 require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "the name segment itself is ellipsized once it alone exceeds the width")
664 require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")697 require.Equal(t, '│', frame.At(layout.List.Width, 1).Rune, "the separator occupies the cell after the list")
665 }698 }
666 699
700+func TestSearchSelectsBestMatchAndPreservesExactTarget(t *testing.T) {
701+ m := New([]SessionView{
702+ {ID: "backend", Name: "backend", Tabs: []TabEntry{{TabID: "api-tab", Name: "api-server"}, {TabID: "logs-tab", Name: "logs"}}},
703+ {ID: "frontend", Name: "frontend", Tabs: []TabEntry{{TabID: "web-tab", Name: "web"}}},
704+ }, SelectionConfig{Mode: SelectNavigationTab})
705+
706+ m.EnterSearch()
707+ for _, r := range "api" {
708+ m.InsertSearch(r)
709+ }
710+
711+ require.True(t, m.SearchActive())
712+ require.Equal(t, "api", m.Query())
713+ require.Equal(t, 1, m.MatchCount())
714+ selected, ok := m.Selected()
715+ require.True(t, ok)
716+ require.Equal(t, Target{Session: "backend", TabID: "api-tab", TabIndex: 0}, selected)
717+ require.Equal(t, " Search sessions & tabs: api_ ", m.SearchTitle())
718+}
719+
720+func TestSearchUsesContextWithoutHighlightingUnrelatedText(t *testing.T) {
721+ m := New([]SessionView{
722+ {ID: "backend", Name: "backend", Tabs: []TabEntry{{TabID: "logs-tab", Name: "logs"}}},
723+ {ID: "frontend", Name: "frontend", Tabs: []TabEntry{{TabID: "web-tab", Name: "web"}}},
724+ }, SelectionConfig{Mode: SelectNavigationTab})
725+ m.EnterSearch()
726+ for _, r := range "back" {
727+ m.InsertSearch(r)
728+ }
729+
730+ selected, ok := m.Selected()
731+ require.True(t, ok)
732+ require.Equal(t, domain.TabStableID("logs-tab"), selected.TabID, "a parent-session match keeps its tab target navigable")
733+
734+ matchStyle := renderer.Style{Bold: true, Foreground: 2}
735+ frame := m.Render(domain.Size{Cols: 40, Rows: 6}, Preview{}, RenderStyles{
736+ Background: renderer.DefaultStyle(), Base: renderer.DefaultStyle(), Name: renderer.DefaultStyle(), Detail: renderer.DefaultStyle(),
737+ Selection: renderer.Style{Inverse: true}, SelectionName: renderer.Style{Inverse: true}, SelectionMuted: renderer.Style{Inverse: true},
738+ SearchMatch: matchStyle, SelectionMatch: matchStyle,
739+ })
740+ for x := 2; x < 6; x++ {
741+ require.False(t, frame.At(x, 1).Style.Equal(matchStyle), "context matches must not highlight unrelated rendered tab text")
742+ }
743+ require.True(t, frame.At(0, 3).Style.Attrs&renderer.AttrDim != 0, "non-matching rows are dimmed")
744+}
745+
746+func TestSearchZeroMatchesSuppressesActivationAndRetainsCursorAnchor(t *testing.T) {
747+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}}, SelectionConfig{Mode: SelectNavigationTab})
748+ before, ok := m.Cursor()
749+ require.True(t, ok)
750+
751+ m.EnterSearch()
752+ for _, r := range "absent" {
753+ m.InsertSearch(r)
754+ }
755+
756+ require.Zero(t, m.MatchCount())
757+ _, ok = m.Selected()
758+ require.False(t, ok)
759+ after, ok := m.Cursor()
760+ require.True(t, ok)
761+ require.Equal(t, before, after)
762+}
763+
764+func TestSearchTitleKeepsQueryTailAndCaretAtNarrowWidth(t *testing.T) {
765+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}}, SelectionConfig{Mode: SelectNavigationTab})
766+ m.EnterSearch()
767+ for _, r := range "abcdefghijklmnopqrstuv" {
768+ m.InsertSearch(r)
769+ }
770+
771+ title := m.SearchTitle(20)
772+ require.LessOrEqual(t, textCellWidth(title), 20)
773+ require.True(t, strings.HasPrefix(title, " / "))
774+ require.True(t, strings.HasSuffix(title, "v_ "), "the visible title keeps the query tail and caret")
775+
776+ for width := 0; width < 5; width++ {
777+ t.Run(fmt.Sprintf("degenerate width %d", width), func(t *testing.T) {
778+ got := m.SearchTitle(width)
779+ require.LessOrEqual(t, textCellWidth(got), width)
780+ require.NotContains(t, got, "v", "degenerate titles must not force a query cell")
781+ })
782+ }
783+}
784+
785+func TestSearchDoesNotHighlightEllipsisForTruncatedWideMatch(t *testing.T) {
786+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "tab", Name: "abcdefghijklmnopq界"}}}}, SelectionConfig{Mode: SelectNavigationTab})
787+ m.EnterSearch()
788+ m.InsertSearch('界')
789+ matchStyle := renderer.Style{Foreground: 2, Bold: true}
790+ frame := m.Render(domain.Size{Cols: 20, Rows: 3}, Preview{}, RenderStyles{
791+ Background: renderer.DefaultStyle(), Base: renderer.DefaultStyle(), Name: renderer.DefaultStyle(), Detail: renderer.DefaultStyle(),
792+ Selection: renderer.Style{Inverse: true}, SelectionName: renderer.Style{Inverse: true}, SelectionMuted: renderer.Style{Inverse: true},
793+ SearchMatch: matchStyle, SelectionMatch: matchStyle,
794+ })
795+
796+ require.Equal(t, '…', frame.At(19, 1).Rune)
797+ require.False(t, frame.At(19, 1).Style.Equal(matchStyle), "a dropped wide-rune match must not style the truncation ellipsis")
798+}
799+
800+func TestReplaceFromMovesStoppedHeaderCursorToNewFocusableTab(t *testing.T) {
801+ lifecycle := domain.SessionLifecycleID{1}
802+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
803+ emptyTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", Stopped: true}
804+ m := New([]SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &emptyTarget, Stopped: true, RemoteActivation: RemoteRestart}}, SelectionConfig{Mode: SelectNavigationTab})
805+ require.Equal(t, 0, m.SelectedIndex())
806+
807+ tabTarget := emptyTarget
808+ tabTarget.StoppedTab = domain.NewStableTabSelector("tab")
809+ retained := []SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &tabTarget, Stopped: true, RemoteActivation: RemoteRestart, Tabs: []TabEntry{{TabID: "tab", Name: "shell"}}}}
810+ next := New(retained, SelectionConfig{Mode: SelectNavigationTab})
811+ direct := New(retained, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: key.ID(), RemoteKey: &key}})
812+ directSelected, ok := direct.Selected()
813+ require.True(t, ok)
814+ require.Equal(t, domain.TabStableID("tab"), directSelected.TabID, "retained tabs must not pin the current cursor to the contextual header")
815+
816+ m.ReplaceFrom(next)
817+
818+ selected, ok := m.Selected()
819+ require.True(t, ok)
820+ require.Equal(t, domain.TabStableID("tab"), selected.TabID)
821+ require.Equal(t, 1, m.SelectedIndex(), "the contextual stopped header must not retain focus after real tabs appear")
822+}
823+
824+func TestReplaceFromPreservesStableTabAcrossReordering(t *testing.T) {
825+ m := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "first", Name: "first"}, {TabID: "selected", Name: "selected"}}}}, SelectionConfig{Mode: SelectNavigationTab, Current: SourceFilter{Session: "s", TabID: "selected"}})
826+ next := New([]SessionView{{ID: "s", Name: "session", Tabs: []TabEntry{{TabID: "selected", Name: "selected"}, {TabID: "first", Name: "first"}}}}, SelectionConfig{Mode: SelectNavigationTab})
827+
828+ m.ReplaceFrom(next)
829+
830+ selected, ok := m.Selected()
831+ require.True(t, ok)
832+ require.Equal(t, domain.TabStableID("selected"), selected.TabID)
833+ require.Equal(t, 0, selected.TabIndex)
834+}
835+
836+func TestFilteredSessionDoesNotLeaveOrphanSection(t *testing.T) {
837+ m := New([]SessionView{{ID: "stopped", Section: "LOCAL", Name: "stopped", Stopped: true}}, SelectionConfig{Mode: SelectMovePaneTab})
838+ require.Empty(t, m.rows)
839+}
840+
841+func TestUnavailableStoppedRemoteUsesAvailabilityBadgeAndHint(t *testing.T) {
842+ lifecycle := domain.SessionLifecycleID{1}
843+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
844+ target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", Stopped: true}
845+ m := New([]SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &target, Stopped: true, RemoteReason: "catalog_stale", RemoteDetail: "catalog stale", RemoteActivation: RemoteUnavailable}}, SelectionConfig{Mode: SelectNavigationTab})
846+
847+ frame := m.Render(domain.Size{Cols: 40, Rows: 3}, Preview{})
848+ require.Contains(t, rowText(frame.Row(0)), "[stale]")
849+ require.Contains(t, rowText(frame.Row(2)), "Enter unavailable")
850+ require.NotContains(t, rowText(frame.Row(2)), "Enter restart")
851+}
852+
853+func TestSearchNavigationUsesCanonicalVisualOrderAfterBestRank(t *testing.T) {
854+ m := New([]SessionView{
855+ {ID: "first", Name: "first", Tabs: []TabEntry{{TabID: "zeta", Name: "zeta"}}},
856+ {ID: "second", Name: "second", Tabs: []TabEntry{{TabID: "a", Name: "a"}}},
857+ {ID: "third", Name: "third", Tabs: []TabEntry{{TabID: "alpha", Name: "alpha"}}},
858+ }, SelectionConfig{Mode: SelectNavigationTab})
859+ m.EnterSearch()
860+ m.InsertSearch('a')
861+
862+ selected, ok := m.Selected()
863+ require.True(t, ok)
864+ require.Equal(t, domain.TabStableID("a"), selected.TabID, "exact match wins initial ranking")
865+ m.Up()
866+ selected, ok = m.Selected()
867+ require.True(t, ok)
868+ require.Equal(t, domain.TabStableID("zeta"), selected.TabID, "navigation follows visual order rather than rank order")
869+ m.Down()
870+ m.Down()
871+ selected, ok = m.Selected()
872+ require.True(t, ok)
873+ require.Equal(t, domain.TabStableID("alpha"), selected.TabID)
874+}
875+
667 func cell(r rune) renderer.Cell {876 func cell(r rune) renderer.Cell {
668 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}877 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
669 }878 }
@@ -676,10 +885,11 @@ func TestRenderStylesFillBackgroundRowsAndSelection(t *testing.T) {
676 frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{885 frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}, RenderStyles{
677 Background: background, Base: base, Name: base, Detail: base,886 Background: background, Base: base, Name: base, Detail: base,
678 Selection: selection, SelectionName: selection, SelectionMuted: selection,887 Selection: selection, SelectionName: selection, SelectionMuted: selection,
679- Separator: base,888+ Separator: base, Status: base,
680 })889 })
681 890
682- require.True(t, frame.At(19, 4).Style.Equal(background), "unused interior keeps modal base")891+ require.True(t, frame.At(19, 3).Style.Equal(background), "unused content keeps modal base")
892+ require.True(t, frame.At(19, 4).Style.Equal(base), "status owns the final inner row")
683 require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface")893 require.True(t, frame.At(19, 0).Style.Equal(base), "ordinary row owns inactive surface")
684 require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")894 require.True(t, frame.At(19, 1).Style.Equal(selection), "selected row owns active surface")
685 }895 }
@@ -733,10 +943,10 @@ func TestPickerRowsKeepTerminalBackgroundAcrossAccentFallbacks(t *testing.T) {
733 Background: styles.PickerBase, Base: styles.PickerBase, Name: styles.PickerName,943 Background: styles.PickerBase, Base: styles.PickerBase, Name: styles.PickerName,
734 Detail: styles.PickerDescription, Selection: styles.PickerSelection,944 Detail: styles.PickerDescription, Selection: styles.PickerSelection,
735 SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection,945 SelectionName: styles.PickerSelection, SelectionMuted: styles.PickerSelection,
736- Separator: styles.PickerSeparator,946+ Separator: styles.PickerSeparator, Status: styles.PickerDescription,
737 })947 })
738- require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "description text keeps a contrast-derived foreground without a background tint")948+ require.True(t, frame.At(5, 3).Style.Equal(styles.PickerDescription), "status text keeps a contrast-derived foreground without a background tint")
739- require.True(t, frame.At(31, 3).Style.Equal(styles.PickerBase), "unused row cells retain the terminal background")949+ require.True(t, frame.At(31, 3).Style.Equal(styles.PickerDescription), "status filler owns the final row")
740 })950 })
741 }951 }
742 }952 }
A internal/usecase/picker/search.go +363 -0
@@ -0,0 +1,363 @@
1+package picker
2+
3+import (
4+ "slices"
5+ "strings"
6+ "unicode/utf8"
7+
8+ renderer "github.com/bnema/vev-vt"
9+)
10+
11+const MaxSearchQueryRunes = 256
12+
13+type matchField uint8
14+
15+const (
16+ matchName matchField = iota
17+ matchDetail
18+ matchContext
19+)
20+
21+type fieldMatch struct {
22+ field matchField
23+ positions []int
24+ rank int
25+ span int
26+ first int
27+}
28+
29+type searchMatch struct {
30+ fieldMatches []fieldMatch
31+ best fieldMatch
32+}
33+
34+func (m searchMatch) positions(field matchField) []int {
35+ for _, candidate := range m.fieldMatches {
36+ if candidate.field == field {
37+ return candidate.positions
38+ }
39+ }
40+ return nil
41+}
42+
43+func (m *Model) EnterSearch() {
44+ if m == nil || m.searchActive {
45+ return
46+ }
47+ m.searchActive = true
48+ m.query.SetValue("")
49+ m.refreshSearch(false)
50+}
51+
52+func (m *Model) ExitSearch() {
53+ if m == nil {
54+ return
55+ }
56+ m.searchActive = false
57+ m.query.SetValue("")
58+ m.searchMatches = nil
59+ m.matchRows = nil
60+}
61+
62+func (m *Model) SearchActive() bool { return m != nil && m.searchActive }
63+
64+func (m *Model) Query() string {
65+ if m == nil {
66+ return ""
67+ }
68+ return m.query.Value()
69+}
70+
71+func (m *Model) SearchTitle(width ...int) string {
72+ if m == nil || !m.searchActive {
73+ return ""
74+ }
75+ full := " Search sessions & tabs: " + m.query.Value() + "_ "
76+ if len(width) == 0 || textCellWidth(full) <= width[0] {
77+ return full
78+ }
79+ const (
80+ prefix = " / "
81+ suffix = "_ "
82+ )
83+ fixedWidth := textCellWidth(prefix) + textCellWidth(suffix)
84+ if width[0] < fixedWidth {
85+ return tailCells("_", width[0])
86+ }
87+ available := width[0] - fixedWidth
88+ return prefix + tailCells(m.query.Value(), available) + suffix
89+}
90+
91+func tailCells(text string, width int) string {
92+ runes := []rune(text)
93+ used := 0
94+ start := len(runes)
95+ for start > 0 {
96+ cellWidth := renderer.RuneWidth(runes[start-1])
97+ if used+cellWidth > width {
98+ break
99+ }
100+ used += cellWidth
101+ start--
102+ }
103+ return string(runes[start:])
104+}
105+
106+func textCellWidth(text string) int {
107+ width := 0
108+ for _, r := range text {
109+ width += renderer.RuneWidth(r)
110+ }
111+ return width
112+}
113+
114+func (m *Model) InsertSearch(r rune) {
115+ if m == nil || !m.searchActive || utf8.RuneCountInString(m.query.Value()) >= MaxSearchQueryRunes {
116+ return
117+ }
118+ m.query.Insert(r)
119+ m.refreshSearch(true)
120+}
121+
122+func (m *Model) BackspaceSearch() {
123+ if m == nil || !m.searchActive || m.query.Value() == "" {
124+ return
125+ }
126+ m.query.Backspace()
127+ m.refreshSearch(true)
128+}
129+
130+func (m *Model) ClearSearch() {
131+ if m == nil || !m.searchActive || m.query.Value() == "" {
132+ return
133+ }
134+ m.query.SetValue("")
135+ m.refreshSearch(false)
136+}
137+
138+func (m *Model) MatchCount() int {
139+ if m == nil || !m.searchActive {
140+ return 0
141+ }
142+ return len(m.matchRows)
143+}
144+
145+// ReplaceFrom applies a fresh canonical row snapshot while retaining the
146+// attachment-local search editor and exact cursor when that target still
147+// exists. Callers serialize this mutation with their picker ownership lock.
148+func (m *Model) ReplaceFrom(next *Model) {
149+ if m == nil || next == nil {
150+ return
151+ }
152+ cursor, hadCursor := m.Cursor()
153+ searchActive, query := m.searchActive, m.query.Value()
154+ m.mode = next.mode
155+ m.rows = append(m.rows[:0], next.rows...)
156+ m.selected = next.selected
157+ if hadCursor {
158+ for idx, candidate := range m.rows {
159+ if candidate.focusable && sameTarget(candidate.target(), cursor) {
160+ m.selected = idx
161+ break
162+ }
163+ }
164+ }
165+ m.searchActive = searchActive
166+ m.query.SetValue(query)
167+ m.searchMatches = nil
168+ m.matchRows = nil
169+ if !searchActive {
170+ return
171+ }
172+ best := m.refreshSearch(false)
173+ if query != "" && !m.rowMatches(m.selected) && best >= 0 {
174+ m.selected = best
175+ }
176+}
177+
178+func sameTarget(left, right Target) bool {
179+ if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.Stopped != right.Stopped || !sameOptionalInt64(left.ExpectedCreatedAt, right.ExpectedCreatedAt) {
180+ return false
181+ }
182+ if left.RemoteKey == nil || right.RemoteKey == nil {
183+ if left.RemoteKey != nil || right.RemoteKey != nil {
184+ return false
185+ }
186+ } else if *left.RemoteKey != *right.RemoteKey {
187+ return false
188+ }
189+ if left.RemoteTarget == nil || right.RemoteTarget == nil {
190+ return left.RemoteTarget == nil && right.RemoteTarget == nil
191+ }
192+ return *left.RemoteTarget == *right.RemoteTarget
193+}
194+
195+func sameOptionalInt64(left, right *int64) bool {
196+ if left == nil || right == nil {
197+ return left == nil && right == nil
198+ }
199+ return *left == *right
200+}
201+
202+// SelectionRejectedBySearch reports that the retained cursor is only a hidden
203+// anchor and must not be interpreted as an unavailable activation target.
204+func (m *Model) SelectionRejectedBySearch() bool {
205+ return m != nil && m.searchActive && m.query.Value() != "" && !m.rowMatches(m.selected)
206+}
207+
208+func (m *Model) rowMatches(idx int) bool {
209+ if m == nil || !m.searchActive || m.query.Value() == "" {
210+ return true
211+ }
212+ _, ok := m.searchMatches[idx]
213+ return ok
214+}
215+
216+func (m *Model) moveSearch(delta int) {
217+ if m == nil || len(m.matchRows) == 0 {
218+ return
219+ }
220+ position := slices.Index(m.matchRows, m.selected)
221+ if position < 0 {
222+ if delta > 0 {
223+ m.selected = m.matchRows[0]
224+ } else {
225+ m.selected = m.matchRows[len(m.matchRows)-1]
226+ }
227+ return
228+ }
229+ next := position + delta
230+ if next >= 0 && next < len(m.matchRows) {
231+ m.selected = m.matchRows[next]
232+ }
233+}
234+
235+func (m *Model) refreshSearch(selectBest bool) int {
236+ m.searchMatches = make(map[int]searchMatch)
237+ m.matchRows = make([]int, 0, len(m.rows))
238+ query := strings.ToLower(m.query.Value())
239+ if query == "" {
240+ for idx, row := range m.rows {
241+ if row.focusable {
242+ m.matchRows = append(m.matchRows, idx)
243+ }
244+ }
245+ return -1
246+ }
247+
248+ needleRunes := []rune(query)
249+ bestIdx := -1
250+ var best fieldMatch
251+ for idx, row := range m.rows {
252+ if !row.focusable {
253+ continue
254+ }
255+ matched, ok := matchRow(row, query, needleRunes)
256+ if !ok {
257+ continue
258+ }
259+ m.searchMatches[idx] = matched
260+ m.matchRows = append(m.matchRows, idx)
261+ if bestIdx < 0 || lessFieldMatch(matched.best, best) {
262+ bestIdx, best = idx, matched.best
263+ }
264+ }
265+ if selectBest && bestIdx >= 0 {
266+ m.selected = bestIdx
267+ }
268+ return bestIdx
269+}
270+
271+func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
272+ fields := []struct {
273+ kind matchField
274+ text string
275+ }{
276+ {matchName, row.foldedName},
277+ {matchDetail, row.foldedDetail},
278+ }
279+ if row.kind == rowTab {
280+ fields = append(fields, struct {
281+ kind matchField
282+ text string
283+ }{matchContext, row.foldedSession})
284+ }
285+ if row.foldedHost != "" {
286+ fields = append(fields, struct {
287+ kind matchField
288+ text string
289+ }{matchContext, row.foldedHost})
290+ }
291+
292+ var result searchMatch
293+ for _, field := range fields {
294+ match, ok := scoreField(field.kind, field.text, query, needleRunes)
295+ if !ok {
296+ continue
297+ }
298+ result.fieldMatches = append(result.fieldMatches, match)
299+ if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) {
300+ result.best = match
301+ }
302+ }
303+ return result, len(result.fieldMatches) != 0
304+}
305+
306+func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {
307+ if len(needle) == 0 {
308+ return fieldMatch{field: field}, true
309+ }
310+ if text == query {
311+ positions := rangeRunePositions(len(needle))
312+ return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true
313+ }
314+ if strings.HasPrefix(text, query) {
315+ positions := rangeRunePositions(len(needle))
316+ return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true
317+ }
318+ positions, ok := subsequenceRunePositions([]rune(text), needle)
319+ if !ok {
320+ return fieldMatch{}, false
321+ }
322+ return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true
323+}
324+
325+func lessFieldMatch(left, right fieldMatch) bool {
326+ if left.rank != right.rank {
327+ return left.rank < right.rank
328+ }
329+ if left.span != right.span {
330+ return left.span < right.span
331+ }
332+ if left.first != right.first {
333+ return left.first < right.first
334+ }
335+ return left.field < right.field
336+}
337+
338+func rangeRunePositions(length int) []int {
339+ positions := make([]int, length)
340+ for i := range positions {
341+ positions[i] = i
342+ }
343+ return positions
344+}
345+
346+func subsequenceRunePositions(haystack, needle []rune) ([]int, bool) {
347+ if len(needle) == 0 {
348+ return nil, true
349+ }
350+ positions := make([]int, 0, len(needle))
351+ next := 0
352+ for idx, r := range haystack {
353+ if r != needle[next] {
354+ continue
355+ }
356+ positions = append(positions, idx)
357+ next++
358+ if next == len(needle) {
359+ return positions, true
360+ }
361+ }
362+ return nil, false
363+}
@@ -0,0 +1,363 @@
1+package picker
2+
3+import (
4+ "slices"
5+ "strings"
6+ "unicode/utf8"
7+
8+ renderer "github.com/bnema/vev-vt"
9+)
10+
11+const MaxSearchQueryRunes = 256
12+
13+type matchField uint8
14+
15+const (
16+ matchName matchField = iota
17+ matchDetail
18+ matchContext
19+)
20+
21+type fieldMatch struct {
22+ field matchField
23+ positions []int
24+ rank int
25+ span int
26+ first int
27+}
28+
29+type searchMatch struct {
30+ fieldMatches []fieldMatch
31+ best fieldMatch
32+}
33+
34+func (m searchMatch) positions(field matchField) []int {
35+ for _, candidate := range m.fieldMatches {
36+ if candidate.field == field {
37+ return candidate.positions
38+ }
39+ }
40+ return nil
41+}
42+
43+func (m *Model) EnterSearch() {
44+ if m == nil || m.searchActive {
45+ return
46+ }
47+ m.searchActive = true
48+ m.query.SetValue("")
49+ m.refreshSearch(false)
50+}
51+
52+func (m *Model) ExitSearch() {
53+ if m == nil {
54+ return
55+ }
56+ m.searchActive = false
57+ m.query.SetValue("")
58+ m.searchMatches = nil
59+ m.matchRows = nil
60+}
61+
62+func (m *Model) SearchActive() bool { return m != nil && m.searchActive }
63+
64+func (m *Model) Query() string {
65+ if m == nil {
66+ return ""
67+ }
68+ return m.query.Value()
69+}
70+
71+func (m *Model) SearchTitle(width ...int) string {
72+ if m == nil || !m.searchActive {
73+ return ""
74+ }
75+ full := " Search sessions & tabs: " + m.query.Value() + "_ "
76+ if len(width) == 0 || textCellWidth(full) <= width[0] {
77+ return full
78+ }
79+ const (
80+ prefix = " / "
81+ suffix = "_ "
82+ )
83+ fixedWidth := textCellWidth(prefix) + textCellWidth(suffix)
84+ if width[0] < fixedWidth {
85+ return tailCells("_", width[0])
86+ }
87+ available := width[0] - fixedWidth
88+ return prefix + tailCells(m.query.Value(), available) + suffix
89+}
90+
91+func tailCells(text string, width int) string {
92+ runes := []rune(text)
93+ used := 0
94+ start := len(runes)
95+ for start > 0 {
96+ cellWidth := renderer.RuneWidth(runes[start-1])
97+ if used+cellWidth > width {
98+ break
99+ }
100+ used += cellWidth
101+ start--
102+ }
103+ return string(runes[start:])
104+}
105+
106+func textCellWidth(text string) int {
107+ width := 0
108+ for _, r := range text {
109+ width += renderer.RuneWidth(r)
110+ }
111+ return width
112+}
113+
114+func (m *Model) InsertSearch(r rune) {
115+ if m == nil || !m.searchActive || utf8.RuneCountInString(m.query.Value()) >= MaxSearchQueryRunes {
116+ return
117+ }
118+ m.query.Insert(r)
119+ m.refreshSearch(true)
120+}
121+
122+func (m *Model) BackspaceSearch() {
123+ if m == nil || !m.searchActive || m.query.Value() == "" {
124+ return
125+ }
126+ m.query.Backspace()
127+ m.refreshSearch(true)
128+}
129+
130+func (m *Model) ClearSearch() {
131+ if m == nil || !m.searchActive || m.query.Value() == "" {
132+ return
133+ }
134+ m.query.SetValue("")
135+ m.refreshSearch(false)
136+}
137+
138+func (m *Model) MatchCount() int {
139+ if m == nil || !m.searchActive {
140+ return 0
141+ }
142+ return len(m.matchRows)
143+}
144+
145+// ReplaceFrom applies a fresh canonical row snapshot while retaining the
146+// attachment-local search editor and exact cursor when that target still
147+// exists. Callers serialize this mutation with their picker ownership lock.
148+func (m *Model) ReplaceFrom(next *Model) {
149+ if m == nil || next == nil {
150+ return
151+ }
152+ cursor, hadCursor := m.Cursor()
153+ searchActive, query := m.searchActive, m.query.Value()
154+ m.mode = next.mode
155+ m.rows = append(m.rows[:0], next.rows...)
156+ m.selected = next.selected
157+ if hadCursor {
158+ for idx, candidate := range m.rows {
159+ if candidate.focusable && sameTarget(candidate.target(), cursor) {
160+ m.selected = idx
161+ break
162+ }
163+ }
164+ }
165+ m.searchActive = searchActive
166+ m.query.SetValue(query)
167+ m.searchMatches = nil
168+ m.matchRows = nil
169+ if !searchActive {
170+ return
171+ }
172+ best := m.refreshSearch(false)
173+ if query != "" && !m.rowMatches(m.selected) && best >= 0 {
174+ m.selected = best
175+ }
176+}
177+
178+func sameTarget(left, right Target) bool {
179+ if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.Stopped != right.Stopped || !sameOptionalInt64(left.ExpectedCreatedAt, right.ExpectedCreatedAt) {
180+ return false
181+ }
182+ if left.RemoteKey == nil || right.RemoteKey == nil {
183+ if left.RemoteKey != nil || right.RemoteKey != nil {
184+ return false
185+ }
186+ } else if *left.RemoteKey != *right.RemoteKey {
187+ return false
188+ }
189+ if left.RemoteTarget == nil || right.RemoteTarget == nil {
190+ return left.RemoteTarget == nil && right.RemoteTarget == nil
191+ }
192+ return *left.RemoteTarget == *right.RemoteTarget
193+}
194+
195+func sameOptionalInt64(left, right *int64) bool {
196+ if left == nil || right == nil {
197+ return left == nil && right == nil
198+ }
199+ return *left == *right
200+}
201+
202+// SelectionRejectedBySearch reports that the retained cursor is only a hidden
203+// anchor and must not be interpreted as an unavailable activation target.
204+func (m *Model) SelectionRejectedBySearch() bool {
205+ return m != nil && m.searchActive && m.query.Value() != "" && !m.rowMatches(m.selected)
206+}
207+
208+func (m *Model) rowMatches(idx int) bool {
209+ if m == nil || !m.searchActive || m.query.Value() == "" {
210+ return true
211+ }
212+ _, ok := m.searchMatches[idx]
213+ return ok
214+}
215+
216+func (m *Model) moveSearch(delta int) {
217+ if m == nil || len(m.matchRows) == 0 {
218+ return
219+ }
220+ position := slices.Index(m.matchRows, m.selected)
221+ if position < 0 {
222+ if delta > 0 {
223+ m.selected = m.matchRows[0]
224+ } else {
225+ m.selected = m.matchRows[len(m.matchRows)-1]
226+ }
227+ return
228+ }
229+ next := position + delta
230+ if next >= 0 && next < len(m.matchRows) {
231+ m.selected = m.matchRows[next]
232+ }
233+}
234+
235+func (m *Model) refreshSearch(selectBest bool) int {
236+ m.searchMatches = make(map[int]searchMatch)
237+ m.matchRows = make([]int, 0, len(m.rows))
238+ query := strings.ToLower(m.query.Value())
239+ if query == "" {
240+ for idx, row := range m.rows {
241+ if row.focusable {
242+ m.matchRows = append(m.matchRows, idx)
243+ }
244+ }
245+ return -1
246+ }
247+
248+ needleRunes := []rune(query)
249+ bestIdx := -1
250+ var best fieldMatch
251+ for idx, row := range m.rows {
252+ if !row.focusable {
253+ continue
254+ }
255+ matched, ok := matchRow(row, query, needleRunes)
256+ if !ok {
257+ continue
258+ }
259+ m.searchMatches[idx] = matched
260+ m.matchRows = append(m.matchRows, idx)
261+ if bestIdx < 0 || lessFieldMatch(matched.best, best) {
262+ bestIdx, best = idx, matched.best
263+ }
264+ }
265+ if selectBest && bestIdx >= 0 {
266+ m.selected = bestIdx
267+ }
268+ return bestIdx
269+}
270+
271+func matchRow(row row, query string, needleRunes []rune) (searchMatch, bool) {
272+ fields := []struct {
273+ kind matchField
274+ text string
275+ }{
276+ {matchName, row.foldedName},
277+ {matchDetail, row.foldedDetail},
278+ }
279+ if row.kind == rowTab {
280+ fields = append(fields, struct {
281+ kind matchField
282+ text string
283+ }{matchContext, row.foldedSession})
284+ }
285+ if row.foldedHost != "" {
286+ fields = append(fields, struct {
287+ kind matchField
288+ text string
289+ }{matchContext, row.foldedHost})
290+ }
291+
292+ var result searchMatch
293+ for _, field := range fields {
294+ match, ok := scoreField(field.kind, field.text, query, needleRunes)
295+ if !ok {
296+ continue
297+ }
298+ result.fieldMatches = append(result.fieldMatches, match)
299+ if len(result.fieldMatches) == 1 || lessFieldMatch(match, result.best) {
300+ result.best = match
301+ }
302+ }
303+ return result, len(result.fieldMatches) != 0
304+}
305+
306+func scoreField(field matchField, text, query string, needle []rune) (fieldMatch, bool) {
307+ if len(needle) == 0 {
308+ return fieldMatch{field: field}, true
309+ }
310+ if text == query {
311+ positions := rangeRunePositions(len(needle))
312+ return fieldMatch{field: field, positions: positions, rank: 0, span: len(positions)}, true
313+ }
314+ if strings.HasPrefix(text, query) {
315+ positions := rangeRunePositions(len(needle))
316+ return fieldMatch{field: field, positions: positions, rank: 1, span: len(positions)}, true
317+ }
318+ positions, ok := subsequenceRunePositions([]rune(text), needle)
319+ if !ok {
320+ return fieldMatch{}, false
321+ }
322+ return fieldMatch{field: field, positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0]}, true
323+}
324+
325+func lessFieldMatch(left, right fieldMatch) bool {
326+ if left.rank != right.rank {
327+ return left.rank < right.rank
328+ }
329+ if left.span != right.span {
330+ return left.span < right.span
331+ }
332+ if left.first != right.first {
333+ return left.first < right.first
334+ }
335+ return left.field < right.field
336+}
337+
338+func rangeRunePositions(length int) []int {
339+ positions := make([]int, length)
340+ for i := range positions {
341+ positions[i] = i
342+ }
343+ return positions
344+}
345+
346+func subsequenceRunePositions(haystack, needle []rune) ([]int, bool) {
347+ if len(needle) == 0 {
348+ return nil, true
349+ }
350+ positions := make([]int, 0, len(needle))
351+ next := 0
352+ for idx, r := range haystack {
353+ if r != needle[next] {
354+ continue
355+ }
356+ positions = append(positions, idx)
357+ next++
358+ if next == len(needle) {
359+ return positions, true
360+ }
361+ }
362+ return nil, false
363+}

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