detached cb4ef6c

Merge pull request #95 from bnema/feature/dynamic-session-palette

feat(palette): suggest named sessions
commit
cb4ef6c60679230249d92fc8b94d73fbc8da9cbc
author
bnema · · 2026-07-15 11:31 UTC
committer
GitHub · · 2026-07-15 11:31 UTC
merge of
8d17a79 + aac8318 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
21 files · +1443 -301
M README.md +1 -1
@@ -47,7 +47,7 @@ No prefix key; everything is Alt.
4747 | Alt+h/j/k/l, Alt+Arrow | focus pane |
4848 | Alt+a | jump to a session needing attention |
4949
50-The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
50+The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
5151
5252 ## Remote attach
5353
@@ -47,7 +47,7 @@ No prefix key; everything is Alt.
47 | Alt+h/j/k/l, Alt+Arrow | focus pane |47 | Alt+h/j/k/l, Alt+Arrow | focus pane |
48 | Alt+a | jump to a session needing attention |48 | Alt+a | jump to a session needing attention |
49 49
50-The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.50+The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
51 51
52 ## Remote attach52 ## Remote attach
53 53
M internal/usecase/daemon/client.go +6 -3
@@ -51,7 +51,7 @@ type attachedClient struct {
5151 theme themeui.Theme
5252 clientTheme themeui.Theme
5353 lastCursor cursorOut
54- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
54+ renderStages renderStageHooks // optional render and handoff observability hooks
5555 // previousSession is guarded independently. It is retained through temporary
5656 // setSession(nil) hand-offs and cleared only on terminal teardown.
5757 previousSession Guarded[*session]
@@ -437,13 +437,16 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
437437 }
438438
439439 // handoffCoordinator prepares a cross-session ownership transfer before the
440-// destination publishes sess.client. It never takes daemon/session locks while
441-// taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks.
440+// destination publishes sess.client. Callers may hold d.mu, but this function
441+// acquires no daemon or session locks while it holds sendMu.
442442 func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup {
443443 cleanups := make([]renderLifecycleCleanup, 0, 2)
444444 if rc := from.renderCoordinator(); rc != nil {
445445 cleanups = append(cleanups, rc.beginDetach(current))
446446 }
447+ if current.renderStages.handoffRebase != nil {
448+ current.renderStages.handoffRebase()
449+ }
447450 current.sendMu.Lock()
448451 current.output.rebase()
449452 // Capture snapshots are attachment-owned. Discard them while the attachment
@@ -51,7 +51,7 @@ type attachedClient struct {
51 theme themeui.Theme51 theme themeui.Theme
52 clientTheme themeui.Theme52 clientTheme themeui.Theme
53 lastCursor cursorOut53 lastCursor cursorOut
54- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held54+ renderStages renderStageHooks // optional render and handoff observability hooks
55 // previousSession is guarded independently. It is retained through temporary55 // previousSession is guarded independently. It is retained through temporary
56 // setSession(nil) hand-offs and cleared only on terminal teardown.56 // setSession(nil) hand-offs and cleared only on terminal teardown.
57 previousSession Guarded[*session]57 previousSession Guarded[*session]
@@ -437,13 +437,16 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
437 }437 }
438 438
439 // handoffCoordinator prepares a cross-session ownership transfer before the439 // handoffCoordinator prepares a cross-session ownership transfer before the
440-// destination publishes sess.client. It never takes daemon/session locks while440+// destination publishes sess.client. Callers may hold d.mu, but this function
441-// taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks.441+// acquires no daemon or session locks while it holds sendMu.
442 func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup {442 func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) []renderLifecycleCleanup {
443 cleanups := make([]renderLifecycleCleanup, 0, 2)443 cleanups := make([]renderLifecycleCleanup, 0, 2)
444 if rc := from.renderCoordinator(); rc != nil {444 if rc := from.renderCoordinator(); rc != nil {
445 cleanups = append(cleanups, rc.beginDetach(current))445 cleanups = append(cleanups, rc.beginDetach(current))
446 }446 }
447+ if current.renderStages.handoffRebase != nil {
448+ current.renderStages.handoffRebase()
449+ }
447 current.sendMu.Lock()450 current.sendMu.Lock()
448 current.output.rebase()451 current.output.rebase()
449 // Capture snapshots are attachment-owned. Discard them while the attachment452 // Capture snapshots are attachment-owned. Discard them while the attachment
M internal/usecase/daemon/config_test.go +6 -2
@@ -142,8 +142,12 @@ func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
142142 require.Equal(t, query, ac.overlays.palette.Query())
143143 selectedAfter, ok := ac.overlays.palette.Selected()
144144 require.True(t, ok)
145- require.Equal(t, selected.Slug, selectedAfter.Slug)
146- require.Equal(t, selected.Code, selectedAfter.Code)
145+ selectedCommand, ok := selected.Command()
146+ require.True(t, ok)
147+ selectedAfterCommand, ok := selectedAfter.Command()
148+ require.True(t, ok)
149+ require.Equal(t, selectedCommand.Slug, selectedAfterCommand.Slug)
150+ require.Equal(t, selectedCommand.Code, selectedAfterCommand.Code)
147151 }
148152
149153 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
@@ -142,8 +142,12 @@ func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
142 require.Equal(t, query, ac.overlays.palette.Query())142 require.Equal(t, query, ac.overlays.palette.Query())
143 selectedAfter, ok := ac.overlays.palette.Selected()143 selectedAfter, ok := ac.overlays.palette.Selected()
144 require.True(t, ok)144 require.True(t, ok)
145- require.Equal(t, selected.Slug, selectedAfter.Slug)145+ selectedCommand, ok := selected.Command()
146- require.Equal(t, selected.Code, selectedAfter.Code)146+ require.True(t, ok)
147+ selectedAfterCommand, ok := selectedAfter.Command()
148+ require.True(t, ok)
149+ require.Equal(t, selectedCommand.Slug, selectedAfterCommand.Slug)
150+ require.Equal(t, selectedCommand.Code, selectedAfterCommand.Code)
147 }151 }
148 152
149 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {153 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
M internal/usecase/daemon/daemon.go +34 -1
@@ -5,6 +5,7 @@ import (
55 "context"
66 "errors"
77 "log/slog"
8+ "math"
89 "os"
910 "slices"
1011 "sort"
@@ -67,7 +68,11 @@ type Daemon struct {
6768 sessions map[domain.SessionID]*session
6869 stopped map[string]stoppedSession
6970 nextID uint64
70- mruSeq atomic.Uint64
71+ // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
72+ // mark. It is guarded by mu and prevents a wall-clock regression from
73+ // reusing a lifecycle identity.
74+ lastAllocatedCreatedAt int64
75+ mruSeq atomic.Uint64
7176 // closing marks that shutdown has irreversibly begun. It is set under mu,
7277 // atomically with the event that makes shutdown inevitable (the registry
7378 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -300,6 +305,25 @@ func WithConfig(cfg domain.Config) Option {
300305 }
301306 }
302307
308+// allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller
309+// holds d.mu. The persisted high-water mark is loaded by New before any named
310+// session can be resumed or created.
311+func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) {
312+ now := d.nowUnixNano()
313+ if now <= d.lastAllocatedCreatedAt {
314+ if d.lastAllocatedCreatedAt == math.MaxInt64 {
315+ return 0, errors.New("daemon: lifecycle identities exhausted")
316+ }
317+ now = d.lastAllocatedCreatedAt + 1
318+ }
319+ d.lastAllocatedCreatedAt = now
320+ return now, nil
321+}
322+
323+func (d *Daemon) nowUnixNano() int64 {
324+ return d.clock.Now().UnixNano()
325+}
326+
303327 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
304328 // render debounce, and log receives diagnostics (defaults to slog.Default).
305329 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -366,12 +390,21 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
366390 d.log.Warn("loading persisted sessions failed", "err", err)
367391 } else {
368392 var maxSeq uint64
393+ var maxCreatedAt int64
394+ hasCreatedAt := false
369395 for _, r := range records {
370396 d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
397+ if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
398+ maxCreatedAt = r.CreatedAt
399+ hasCreatedAt = true
400+ }
371401 if r.LastUsedSeq > maxSeq {
372402 maxSeq = r.LastUsedSeq
373403 }
374404 }
405+ if hasCreatedAt {
406+ d.lastAllocatedCreatedAt = maxCreatedAt
407+ }
375408 d.mruSeq.Store(maxSeq)
376409 }
377410 return d
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "errors"6 "errors"
7 "log/slog"7 "log/slog"
8+ "math"
8 "os"9 "os"
9 "slices"10 "slices"
10 "sort"11 "sort"
@@ -67,7 +68,11 @@ type Daemon struct {
67 sessions map[domain.SessionID]*session68 sessions map[domain.SessionID]*session
68 stopped map[string]stoppedSession69 stopped map[string]stoppedSession
69 nextID uint6470 nextID uint64
70- mruSeq atomic.Uint6471+ // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
72+ // mark. It is guarded by mu and prevents a wall-clock regression from
73+ // reusing a lifecycle identity.
74+ lastAllocatedCreatedAt int64
75+ mruSeq atomic.Uint64
71 // closing marks that shutdown has irreversibly begun. It is set under mu,76 // closing marks that shutdown has irreversibly begun. It is set under mu,
72 // atomically with the event that makes shutdown inevitable (the registry77 // atomically with the event that makes shutdown inevitable (the registry
73 // emptying in killSession, or shutdownAll starting), and checked by route78 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -300,6 +305,25 @@ func WithConfig(cfg domain.Config) Option {
300 }305 }
301 }306 }
302 307
308+// allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller
309+// holds d.mu. The persisted high-water mark is loaded by New before any named
310+// session can be resumed or created.
311+func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) {
312+ now := d.nowUnixNano()
313+ if now <= d.lastAllocatedCreatedAt {
314+ if d.lastAllocatedCreatedAt == math.MaxInt64 {
315+ return 0, errors.New("daemon: lifecycle identities exhausted")
316+ }
317+ now = d.lastAllocatedCreatedAt + 1
318+ }
319+ d.lastAllocatedCreatedAt = now
320+ return now, nil
321+}
322+
323+func (d *Daemon) nowUnixNano() int64 {
324+ return d.clock.Now().UnixNano()
325+}
326+
303 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the327 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
304 // render debounce, and log receives diagnostics (defaults to slog.Default).328 // render debounce, and log receives diagnostics (defaults to slog.Default).
305 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {329 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -366,12 +390,21 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
366 d.log.Warn("loading persisted sessions failed", "err", err)390 d.log.Warn("loading persisted sessions failed", "err", err)
367 } else {391 } else {
368 var maxSeq uint64392 var maxSeq uint64
393+ var maxCreatedAt int64
394+ hasCreatedAt := false
369 for _, r := range records {395 for _, r := range records {
370 d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}396 d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
397+ if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
398+ maxCreatedAt = r.CreatedAt
399+ hasCreatedAt = true
400+ }
371 if r.LastUsedSeq > maxSeq {401 if r.LastUsedSeq > maxSeq {
372 maxSeq = r.LastUsedSeq402 maxSeq = r.LastUsedSeq
373 }403 }
374 }404 }
405+ if hasCreatedAt {
406+ d.lastAllocatedCreatedAt = maxCreatedAt
407+ }
375 d.mruSeq.Store(maxSeq)408 d.mruSeq.Store(maxSeq)
376 }409 }
377 return d410 return d
M internal/usecase/daemon/overlay_runtime.go +6 -3
@@ -27,6 +27,7 @@ type overlayRuntime struct {
2727 paletteRecent []recentSession // immutable for this palette interaction
2828 paletteGeneration uint64
2929 paletteHints palette.ContextualHints
30+ paletteFeedback string
3031 palettePending []byte
3132
3233 promptMu sync.Mutex
@@ -224,9 +225,10 @@ type overlayRenderSnapshot struct {
224225 paletteModel *palette.Model
225226 // paletteHints is a copy captured under paletteMu. Rendering must use this
226227 // immutable interaction snapshot rather than consult live session state.
227- paletteHints *palette.ContextualHints
228- paletteRecent []recentSession
229- paletteLocked bool
228+ paletteHints *palette.ContextualHints
229+ paletteFeedback string
230+ paletteRecent []recentSession
231+ paletteLocked bool
230232
231233 promptActive bool
232234 promptModel *promptui.Model
@@ -273,6 +275,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
273275 hints := rt.paletteHints
274276 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
275277 snap.paletteHints = &hints
278+ snap.paletteFeedback = rt.paletteFeedback
276279 snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)
277280 snap.paletteLocked = true
278281 } else {
@@ -27,6 +27,7 @@ type overlayRuntime struct {
27 paletteRecent []recentSession // immutable for this palette interaction27 paletteRecent []recentSession // immutable for this palette interaction
28 paletteGeneration uint6428 paletteGeneration uint64
29 paletteHints palette.ContextualHints29 paletteHints palette.ContextualHints
30+ paletteFeedback string
30 palettePending []byte31 palettePending []byte
31 32
32 promptMu sync.Mutex33 promptMu sync.Mutex
@@ -224,9 +225,10 @@ type overlayRenderSnapshot struct {
224 paletteModel *palette.Model225 paletteModel *palette.Model
225 // paletteHints is a copy captured under paletteMu. Rendering must use this226 // paletteHints is a copy captured under paletteMu. Rendering must use this
226 // immutable interaction snapshot rather than consult live session state.227 // immutable interaction snapshot rather than consult live session state.
227- paletteHints *palette.ContextualHints228+ paletteHints *palette.ContextualHints
228- paletteRecent []recentSession229+ paletteFeedback string
229- paletteLocked bool230+ paletteRecent []recentSession
231+ paletteLocked bool
230 232
231 promptActive bool233 promptActive bool
232 promptModel *promptui.Model234 promptModel *promptui.Model
@@ -273,6 +275,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
273 hints := rt.paletteHints275 hints := rt.paletteHints
274 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)276 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
275 snap.paletteHints = &hints277 snap.paletteHints = &hints
278+ snap.paletteFeedback = rt.paletteFeedback
276 snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)279 snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)
277 snap.paletteLocked = true280 snap.paletteLocked = true
278 } else {281 } else {
M internal/usecase/daemon/palette.go +130 -37
@@ -1,6 +1,9 @@
11 package daemon
22
33 import (
4+ "sort"
5+ "time"
6+
47 "github.com/bnema/vev/internal/domain"
58 "github.com/bnema/vev/internal/usecase/command"
69 "github.com/bnema/vev/internal/usecase/layout"
@@ -35,16 +38,61 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
3538 // holding an overlay lock while inspecting live sessions.
3639 recent := d.recentSessions(sess)
3740 commands := d.paletteCommands()
41+ results := d.paletteResults(sess, commands)
3842 ac.overlays.paletteMu.Lock()
3943 ac.overlays.paletteGeneration++
40- ac.overlays.palette = palette.New(commands)
44+ ac.overlays.palette = palette.New(results)
4145 ac.overlays.paletteRecent = recent
4246 ac.overlays.paletteHints = palette.ContextualHints{}
47+ ac.overlays.paletteFeedback = ""
4348 ac.overlays.palettePending = nil
4449 ac.overlays.paletteMu.Unlock()
4550 d.invalidateRender(sess, ac, true, "palette.go")
4651 }
4752
53+// paletteResults captures eligible named sessions before paletteMu so the
54+// palette has an immutable lifecycle target without violating lock ordering.
55+func (d *Daemon) paletteResults(current *session, commands []command.Command) []palette.Result {
56+ d.mu.Lock()
57+ sessions := d.sessionsSnapshotLocked()
58+ stopped := make([]stoppedSession, 0, len(d.stopped))
59+ for _, entry := range d.stopped {
60+ if !entry.purging {
61+ stopped = append(stopped, entry)
62+ }
63+ }
64+ d.mu.Unlock()
65+
66+ results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped))
67+ for _, cmd := range commands {
68+ results = append(results, palette.NewCommandResult(cmd))
69+ }
70+ active := make([]palette.Result, 0, len(sessions))
71+ for _, candidate := range sessions {
72+ if candidate == current {
73+ continue
74+ }
75+ candidate.mu.Lock()
76+ name, createdAt, ephemeral := candidate.name, candidate.createdAt, candidate.ephemeral
77+ id := candidate.id
78+ candidate.mu.Unlock()
79+ if name != "" && !ephemeral {
80+ active = append(active, palette.NewActiveSessionResult(name, time.Unix(0, createdAt), id))
81+ }
82+ }
83+ sort.Slice(active, func(i, j int) bool {
84+ left, _ := active[i].SessionName()
85+ right, _ := active[j].SessionName()
86+ return left < right
87+ })
88+ sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
89+ results = append(results, active...)
90+ for _, candidate := range stopped {
91+ results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
92+ }
93+ return results
94+}
95+
4896 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
4997 names := make([]string, len(recent))
5098 for i, entry := range recent {
@@ -104,6 +152,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
104152 return
105153 }
106154 var cmd command.Command
155+ var sessionTarget palette.Result
156+ var hasSessionTarget bool
107157 var args []string
108158 var recent []recentSession
109159 var generation uint64
@@ -118,47 +168,78 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
118168 }
119169 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
120170 rune: func(r rune) {
121- ac.overlays.palette.Insert(r)
122- changed = true
171+ if !execute {
172+ ac.overlays.palette.Insert(r)
173+ changed = true
174+ }
123175 },
124176 backspace: func() {
125- ac.overlays.palette.Backspace()
126- changed = true
177+ if !execute {
178+ ac.overlays.palette.Backspace()
179+ changed = true
180+ }
181+ },
182+ tab: func() {
183+ if !execute {
184+ changed = ac.overlays.palette.CompleteSelected() || changed
185+ }
186+ },
187+ up: func() {
188+ if !execute {
189+ ac.overlays.palette.Up()
190+ changed = true
191+ }
192+ },
193+ down: func() {
194+ if !execute {
195+ ac.overlays.palette.Down()
196+ changed = true
197+ }
198+ },
199+ cancel: func() {
200+ if !execute {
201+ cancel = true
202+ }
127203 },
128- tab: func() { changed = ac.overlays.palette.CompleteSelected() || changed },
129- up: func() { ac.overlays.palette.Up(); changed = true },
130- down: func() { ac.overlays.palette.Down(); changed = true },
131- cancel: func() { cancel = true },
132204 enter: func() {
133- var selected bool
134- cmd, selected = ac.overlays.palette.Selected()
135- if !selected {
205+ if execute {
206+ return
207+ }
208+ selected, ok := ac.overlays.palette.Selected()
209+ if !ok {
136210 changed = true
137211 return
138212 }
139213 rawQuery = ac.overlays.palette.Query()
140- // JRS is contextual: it is executable only from its exact token so
141- // fuzzy selection can never turn an unrelated query into a jump.
142- // Static commands retain normal palette behavior and run the selected
143- // fuzzy match.
144- if cmd.Slug == "jump-recent-session" {
145- action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)
146- if !valid {
147- changed = true
148- return
214+ if selectedCommand, ok := selected.Command(); ok {
215+ cmd = selectedCommand
216+ // JRS is contextual: fuzzy selection cannot turn an unrelated
217+ // query into a jump.
218+ if cmd.Slug == "jump-recent-session" {
219+ action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery)
220+ if !valid {
221+ changed = true
222+ return
223+ }
224+ args = action.Args
225+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
149226 }
150- args = action.Args
151- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
227+ } else if _, ok := selected.SessionName(); ok {
228+ sessionTarget = selected
229+ hasSessionTarget = true
230+ } else {
231+ changed = true
232+ return
152233 }
153234 generation = ac.overlays.paletteGeneration
154235 execute = true
155236 },
156237 })
157238 if changed {
239+ ac.overlays.paletteFeedback = ""
158240 active, ok := ac.overlays.palette.ArgumentCommand()
159241 if ok && active.Slug == "jump-recent-session" {
160- hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))
161- ac.overlays.paletteHints = hints
242+ ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))
162243 } else {
163244 ac.overlays.paletteHints = palette.ContextualHints{}
164245 }
@@ -178,8 +259,27 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
178259 return
179260 }
180261
181- // Revalidate live target without paletteMu. A captured ID is never replaced
182- // by a newly-ranked session, so MRU changes cannot shift a requested rank.
262+ if hasSessionTarget {
263+ createdAt, _ := sessionTarget.SessionCreatedAt()
264+ expectedCreatedAt := createdAt.UnixNano()
265+ name, _ := sessionTarget.SessionName()
266+ target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt}
267+ if id, active := sessionTarget.SessionID(); active {
268+ target.Session = id
269+ } else {
270+ target.Stopped = true
271+ }
272+ if !d.switchToTarget(sess, ac, target) {
273+ ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
274+ d.invalidateRender(sess, ac, true, "palette.go")
275+ return
276+ }
277+ if ac.closeExecutedPalette(generation, rawQuery) {
278+ d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
279+ }
280+ return
281+ }
282+
183283 if cmd.Slug == "jump-recent-session" {
184284 rank, err := command.ParsePositiveDecimal(args)
185285 if err != nil {
@@ -187,16 +287,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
187287 d.invalidateRender(sess, ac, true, "palette.go")
188288 return
189289 }
190- // The captured ID is handed off atomically. A target can disappear after
191- // the validation above, so closing is contingent on the committed switch.
192290 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
193291 if err := exec.JumpRecentSession(rank); err != nil {
194292 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
195293 d.invalidateRender(sess, ac, true, "palette.go")
196294 return
197295 }
198- // Publication remains generation-safe; do not let stale work close a
199- // newer palette interaction.
200296 if ac.closeExecutedPalette(generation, rawQuery) {
201297 d.recordPaletteUse(cmd.Code)
202298 d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
@@ -214,7 +310,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
214310 }
215311
216312 func paletteArgs(query string, cmd command.Command) []string {
217- action, ok := palette.ParseAction([]command.Command{cmd}, query)
313+ action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
218314 if !ok {
219315 return nil
220316 }
@@ -226,6 +322,7 @@ func (ac *attachedClient) clearPaletteLocked() {
226322 ac.overlays.palette = nil
227323 ac.overlays.paletteRecent = nil
228324 ac.overlays.paletteHints = palette.ContextualHints{}
325+ ac.overlays.paletteFeedback = ""
229326 ac.overlays.palettePending = nil
230327 }
231328
@@ -235,11 +332,7 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s
235332 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
236333 return
237334 }
238- // Rendering consumes the immutable contextual hint snapshot; preserve ranks
239- // while making stale-target feedback visible.
240- if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions {
241- ac.overlays.paletteHints.Feedback = feedback
242- }
335+ ac.overlays.paletteFeedback = feedback
243336 }
244337
245338 func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "sort"
5+ "time"
6+
4 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
6 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
@@ -35,16 +38,61 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
35 // holding an overlay lock while inspecting live sessions.38 // holding an overlay lock while inspecting live sessions.
36 recent := d.recentSessions(sess)39 recent := d.recentSessions(sess)
37 commands := d.paletteCommands()40 commands := d.paletteCommands()
41+ results := d.paletteResults(sess, commands)
38 ac.overlays.paletteMu.Lock()42 ac.overlays.paletteMu.Lock()
39 ac.overlays.paletteGeneration++43 ac.overlays.paletteGeneration++
40- ac.overlays.palette = palette.New(commands)44+ ac.overlays.palette = palette.New(results)
41 ac.overlays.paletteRecent = recent45 ac.overlays.paletteRecent = recent
42 ac.overlays.paletteHints = palette.ContextualHints{}46 ac.overlays.paletteHints = palette.ContextualHints{}
47+ ac.overlays.paletteFeedback = ""
43 ac.overlays.palettePending = nil48 ac.overlays.palettePending = nil
44 ac.overlays.paletteMu.Unlock()49 ac.overlays.paletteMu.Unlock()
45 d.invalidateRender(sess, ac, true, "palette.go")50 d.invalidateRender(sess, ac, true, "palette.go")
46 }51 }
47 52
53+// paletteResults captures eligible named sessions before paletteMu so the
54+// palette has an immutable lifecycle target without violating lock ordering.
55+func (d *Daemon) paletteResults(current *session, commands []command.Command) []palette.Result {
56+ d.mu.Lock()
57+ sessions := d.sessionsSnapshotLocked()
58+ stopped := make([]stoppedSession, 0, len(d.stopped))
59+ for _, entry := range d.stopped {
60+ if !entry.purging {
61+ stopped = append(stopped, entry)
62+ }
63+ }
64+ d.mu.Unlock()
65+
66+ results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped))
67+ for _, cmd := range commands {
68+ results = append(results, palette.NewCommandResult(cmd))
69+ }
70+ active := make([]palette.Result, 0, len(sessions))
71+ for _, candidate := range sessions {
72+ if candidate == current {
73+ continue
74+ }
75+ candidate.mu.Lock()
76+ name, createdAt, ephemeral := candidate.name, candidate.createdAt, candidate.ephemeral
77+ id := candidate.id
78+ candidate.mu.Unlock()
79+ if name != "" && !ephemeral {
80+ active = append(active, palette.NewActiveSessionResult(name, time.Unix(0, createdAt), id))
81+ }
82+ }
83+ sort.Slice(active, func(i, j int) bool {
84+ left, _ := active[i].SessionName()
85+ right, _ := active[j].SessionName()
86+ return left < right
87+ })
88+ sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
89+ results = append(results, active...)
90+ for _, candidate := range stopped {
91+ results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
92+ }
93+ return results
94+}
95+
48 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {96 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
49 names := make([]string, len(recent))97 names := make([]string, len(recent))
50 for i, entry := range recent {98 for i, entry := range recent {
@@ -104,6 +152,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
104 return152 return
105 }153 }
106 var cmd command.Command154 var cmd command.Command
155+ var sessionTarget palette.Result
156+ var hasSessionTarget bool
107 var args []string157 var args []string
108 var recent []recentSession158 var recent []recentSession
109 var generation uint64159 var generation uint64
@@ -118,47 +168,78 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
118 }168 }
119 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{169 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
120 rune: func(r rune) {170 rune: func(r rune) {
121- ac.overlays.palette.Insert(r)171+ if !execute {
122- changed = true172+ ac.overlays.palette.Insert(r)
173+ changed = true
174+ }
123 },175 },
124 backspace: func() {176 backspace: func() {
125- ac.overlays.palette.Backspace()177+ if !execute {
126- changed = true178+ ac.overlays.palette.Backspace()
179+ changed = true
180+ }
181+ },
182+ tab: func() {
183+ if !execute {
184+ changed = ac.overlays.palette.CompleteSelected() || changed
185+ }
186+ },
187+ up: func() {
188+ if !execute {
189+ ac.overlays.palette.Up()
190+ changed = true
191+ }
192+ },
193+ down: func() {
194+ if !execute {
195+ ac.overlays.palette.Down()
196+ changed = true
197+ }
198+ },
199+ cancel: func() {
200+ if !execute {
201+ cancel = true
202+ }
127 },203 },
128- tab: func() { changed = ac.overlays.palette.CompleteSelected() || changed },
129- up: func() { ac.overlays.palette.Up(); changed = true },
130- down: func() { ac.overlays.palette.Down(); changed = true },
131- cancel: func() { cancel = true },
132 enter: func() {204 enter: func() {
133- var selected bool205+ if execute {
134- cmd, selected = ac.overlays.palette.Selected()206+ return
135- if !selected {207+ }
208+ selected, ok := ac.overlays.palette.Selected()
209+ if !ok {
136 changed = true210 changed = true
137 return211 return
138 }212 }
139 rawQuery = ac.overlays.palette.Query()213 rawQuery = ac.overlays.palette.Query()
140- // JRS is contextual: it is executable only from its exact token so214+ if selectedCommand, ok := selected.Command(); ok {
141- // fuzzy selection can never turn an unrelated query into a jump.215+ cmd = selectedCommand
142- // Static commands retain normal palette behavior and run the selected216+ // JRS is contextual: fuzzy selection cannot turn an unrelated
143- // fuzzy match.217+ // query into a jump.
144- if cmd.Slug == "jump-recent-session" {218+ if cmd.Slug == "jump-recent-session" {
145- action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)219+ action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery)
146- if !valid {220+ if !valid {
147- changed = true221+ changed = true
148- return222+ return
223+ }
224+ args = action.Args
225+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
149 }226 }
150- args = action.Args227+ } else if _, ok := selected.SessionName(); ok {
151- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)228+ sessionTarget = selected
229+ hasSessionTarget = true
230+ } else {
231+ changed = true
232+ return
152 }233 }
153 generation = ac.overlays.paletteGeneration234 generation = ac.overlays.paletteGeneration
154 execute = true235 execute = true
155 },236 },
156 })237 })
157 if changed {238 if changed {
239+ ac.overlays.paletteFeedback = ""
158 active, ok := ac.overlays.palette.ArgumentCommand()240 active, ok := ac.overlays.palette.ArgumentCommand()
159 if ok && active.Slug == "jump-recent-session" {241 if ok && active.Slug == "jump-recent-session" {
160- hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))242+ ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))
161- ac.overlays.paletteHints = hints
162 } else {243 } else {
163 ac.overlays.paletteHints = palette.ContextualHints{}244 ac.overlays.paletteHints = palette.ContextualHints{}
164 }245 }
@@ -178,8 +259,27 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
178 return259 return
179 }260 }
180 261
181- // Revalidate live target without paletteMu. A captured ID is never replaced262+ if hasSessionTarget {
182- // by a newly-ranked session, so MRU changes cannot shift a requested rank.263+ createdAt, _ := sessionTarget.SessionCreatedAt()
264+ expectedCreatedAt := createdAt.UnixNano()
265+ name, _ := sessionTarget.SessionName()
266+ target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt}
267+ if id, active := sessionTarget.SessionID(); active {
268+ target.Session = id
269+ } else {
270+ target.Stopped = true
271+ }
272+ if !d.switchToTarget(sess, ac, target) {
273+ ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
274+ d.invalidateRender(sess, ac, true, "palette.go")
275+ return
276+ }
277+ if ac.closeExecutedPalette(generation, rawQuery) {
278+ d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
279+ }
280+ return
281+ }
282+
183 if cmd.Slug == "jump-recent-session" {283 if cmd.Slug == "jump-recent-session" {
184 rank, err := command.ParsePositiveDecimal(args)284 rank, err := command.ParsePositiveDecimal(args)
185 if err != nil {285 if err != nil {
@@ -187,16 +287,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
187 d.invalidateRender(sess, ac, true, "palette.go")287 d.invalidateRender(sess, ac, true, "palette.go")
188 return288 return
189 }289 }
190- // The captured ID is handed off atomically. A target can disappear after
191- // the validation above, so closing is contingent on the committed switch.
192 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}290 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
193 if err := exec.JumpRecentSession(rank); err != nil {291 if err := exec.JumpRecentSession(rank); err != nil {
194 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")292 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
195 d.invalidateRender(sess, ac, true, "palette.go")293 d.invalidateRender(sess, ac, true, "palette.go")
196 return294 return
197 }295 }
198- // Publication remains generation-safe; do not let stale work close a
199- // newer palette interaction.
200 if ac.closeExecutedPalette(generation, rawQuery) {296 if ac.closeExecutedPalette(generation, rawQuery) {
201 d.recordPaletteUse(cmd.Code)297 d.recordPaletteUse(cmd.Code)
202 d.invalidateRender(ac.currentSession(), ac, true, "palette.go")298 d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
@@ -214,7 +310,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
214 }310 }
215 311
216 func paletteArgs(query string, cmd command.Command) []string {312 func paletteArgs(query string, cmd command.Command) []string {
217- action, ok := palette.ParseAction([]command.Command{cmd}, query)313+ action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
218 if !ok {314 if !ok {
219 return nil315 return nil
220 }316 }
@@ -226,6 +322,7 @@ func (ac *attachedClient) clearPaletteLocked() {
226 ac.overlays.palette = nil322 ac.overlays.palette = nil
227 ac.overlays.paletteRecent = nil323 ac.overlays.paletteRecent = nil
228 ac.overlays.paletteHints = palette.ContextualHints{}324 ac.overlays.paletteHints = palette.ContextualHints{}
325+ ac.overlays.paletteFeedback = ""
229 ac.overlays.palettePending = nil326 ac.overlays.palettePending = nil
230 }327 }
231 328
@@ -235,11 +332,7 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s
235 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {332 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
236 return333 return
237 }334 }
238- // Rendering consumes the immutable contextual hint snapshot; preserve ranks335+ ac.overlays.paletteFeedback = feedback
239- // while making stale-target feedback visible.
240- if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions {
241- ac.overlays.paletteHints.Feedback = feedback
242- }
243 }336 }
244 337
245 func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {338 func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {
M internal/usecase/daemon/palette_test.go +164 -7
@@ -48,6 +48,140 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
4848 awaitFrame(t, sends, ports.MsgOutput)
4949 }
5050
51+func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
52+ p, release := newBlockingPTY(t)
53+ defer release()
54+ d, current, ac, _ := newManualSessionWithPTYs(t, p)
55+ d.sessions["active"] = &session{id: "active", name: "active", createdAt: 10}
56+ d.sessions["ephemeral"] = &session{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}
57+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12}
58+ d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true}
59+
60+ d.enterPalette(current, ac)
61+ ac.overlays.paletteMu.Lock()
62+ defer ac.overlays.paletteMu.Unlock()
63+
64+ got := map[string]palette.ResultKind{}
65+ for _, match := range ac.overlays.palette.Matches() {
66+ if name, ok := match.Result.SessionName(); ok {
67+ got[name] = match.Result.Kind()
68+ }
69+ }
70+ require.Equal(t, map[string]palette.ResultKind{
71+ "active": palette.ResultKindActiveSession,
72+ "stopped": palette.ResultKindStoppedSession,
73+ }, got)
74+}
75+
76+func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
77+ p, release := newBlockingPTY(t)
78+ defer release()
79+ d, current, ac, _ := newManualSessionWithPTYs(t, p)
80+ target := &session{id: "active", name: "active", createdAt: 10}
81+ d.sessions[target.id] = target
82+
83+ d.enterPalette(current, ac)
84+ d.handlePaletteInput(ac, []byte("active"))
85+ ac.overlays.paletteMu.Lock()
86+ selected, ok := ac.overlays.palette.Selected()
87+ ac.overlays.paletteMu.Unlock()
88+ _, isSession := selected.SessionName()
89+ require.True(t, ok)
90+ require.True(t, isSession)
91+ d.mu.Lock()
92+ delete(d.sessions, target.id)
93+ d.mu.Unlock()
94+
95+ d.handlePaletteInput(ac, []byte("\r"))
96+ ac.overlays.paletteMu.Lock()
97+ require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback)
98+ ac.overlays.paletteMu.Unlock()
99+ d.handlePaletteInput(ac, []byte("x"))
100+ ac.overlays.paletteMu.Lock()
101+ require.Empty(t, ac.overlays.paletteFeedback)
102+ ac.overlays.paletteMu.Unlock()
103+}
104+
105+func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) {
106+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
107+ defer releaseAll(releases)
108+ target := d.sessions[domain.SessionID("recent")]
109+ target.createdAt = 42
110+
111+ d.handleInput(current, ac, []byte("\x1b "))
112+ awaitFrame(t, sends, ports.MsgOutput)
113+ d.handleInput(current, ac, []byte("recent\rx"))
114+
115+ require.Same(t, target, ac.currentSession(), "Enter must retain the selected session despite trailing frame bytes")
116+ require.False(t, ac.overlays.paletteActive(), "trailing input must not prevent the captured selection from closing the palette")
117+ awaitFrame(t, sends, ports.MsgOutput)
118+ awaitFrame(t, sends, ports.MsgOutput)
119+}
120+
121+func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) {
122+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
123+ defer releaseAll(releases)
124+ target := d.sessions[domain.SessionID("recent")]
125+ target.createdAt = 42
126+
127+ d.handleInput(current, ac, []byte("\x1b "))
128+ awaitFrame(t, sends, ports.MsgOutput)
129+ d.handleInput(current, ac, []byte("recent\r"))
130+
131+ require.Same(t, target, ac.currentSession())
132+ require.Same(t, ac, target.client, "canonical handoff reuses the attached client")
133+ require.False(t, ac.overlays.paletteActive())
134+ require.Empty(t, d.paletteRecent, "session selections are not command recency")
135+ awaitFrame(t, sends, ports.MsgOutput)
136+ awaitFrame(t, sends, ports.MsgOutput)
137+}
138+
139+func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *testing.T) {
140+ p, release := newBlockingPTY(t)
141+ defer release()
142+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
143+ stopped := stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}}
144+ d.stopped[stopped.name] = stopped
145+ ptys := portsmocks.NewMockPTYFactory(t)
146+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once()
147+ d.ptys = ptys
148+
149+ d.handleInput(current, ac, []byte("\x1b "))
150+ awaitFrame(t, sends, ports.MsgOutput)
151+ d.handleInput(current, ac, []byte("stopped\r"))
152+
153+ require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment")
154+ current.mu.Lock()
155+ require.Same(t, ac, current.client)
156+ current.mu.Unlock()
157+ require.True(t, ac.overlays.paletteActive())
158+ ac.overlays.paletteMu.Lock()
159+ require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback)
160+ ac.overlays.paletteMu.Unlock()
161+ require.Equal(t, stopped, d.stopped[stopped.name], "failed resume must retain stopped lifecycle metadata")
162+ awaitFrame(t, sends, ports.MsgOutput)
163+}
164+
165+func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
166+ p1, release1 := newBlockingPTY(t)
167+ p2, release2 := newBlockingPTY(t)
168+ defer release1()
169+ defer release2()
170+ d, current, ac, sends := newManualSessionWithPTYs(t, p1)
171+ d.ptys = newFactory(t, p2)
172+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42}
173+
174+ d.handleInput(current, ac, []byte("\x1b "))
175+ awaitFrame(t, sends, ports.MsgOutput)
176+ d.handleInput(current, ac, []byte("stopped\r"))
177+
178+ require.Equal(t, "stopped", ac.currentSession().name)
179+ require.Equal(t, int64(42), ac.currentSession().createdAt)
180+ require.False(t, ac.overlays.paletteActive())
181+ awaitFrame(t, sends, ports.MsgOutput)
182+ awaitFrame(t, sends, ports.MsgOutput)
183+}
184+
51185 func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
52186 p, release := newBlockingPTY(t)
53187 defer release()
@@ -172,7 +306,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
172306 require.True(t, ac.overlays.paletteActive())
173307 ac.overlays.paletteMu.Lock()
174308 require.Equal(t, "JRS 1", ac.overlays.palette.Query())
175- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
309+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
176310 ac.overlays.paletteMu.Unlock()
177311 }
178312
@@ -188,7 +322,7 @@ func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
188322 require.True(t, ac.overlays.paletteActive())
189323 ac.overlays.paletteMu.Lock()
190324 require.Equal(t, "JRS 3", ac.overlays.palette.Query())
191- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
325+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
192326 ac.overlays.paletteMu.Unlock()
193327 awaitFrame(t, sends, ports.MsgOutput)
194328 }
@@ -205,11 +339,28 @@ func TestPaletteJRSMalformedRankFeedback(t *testing.T) {
205339 require.True(t, ac.overlays.paletteActive())
206340 ac.overlays.paletteMu.Lock()
207341 require.Equal(t, "JRS 0", ac.overlays.palette.Query())
208- require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteHints.Feedback)
342+ require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteFeedback)
209343 ac.overlays.paletteMu.Unlock()
210344 awaitFrame(t, sends, ports.MsgOutput)
211345 }
212346
347+func TestPaletteFailureDoesNotOverwriteChangedQueryInSameGeneration(t *testing.T) {
348+ p, release := newBlockingPTY(t)
349+ defer release()
350+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
351+
352+ d.enterPalette(sess, ac)
353+ ac.overlays.paletteMu.Lock()
354+ generation := ac.overlays.paletteGeneration
355+ ac.overlays.palette.Insert('x')
356+ ac.overlays.paletteMu.Unlock()
357+ ac.paletteFailure(generation, "", "stale failure")
358+
359+ ac.overlays.paletteMu.Lock()
360+ require.Empty(t, ac.overlays.paletteFeedback)
361+ ac.overlays.paletteMu.Unlock()
362+}
363+
213364 func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
214365 p, release := newBlockingPTY(t)
215366 defer release()
@@ -223,7 +374,7 @@ func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
223374 ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")
224375
225376 ac.overlays.paletteMu.Lock()
226- require.Empty(t, ac.overlays.paletteHints.Feedback)
377+ require.Empty(t, ac.overlays.paletteFeedback)
227378 ac.overlays.paletteMu.Unlock()
228379 awaitFrame(t, sends, ports.MsgOutput)
229380 awaitFrame(t, sends, ports.MsgOutput)
@@ -302,7 +453,9 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
302453
303454 d.handleInput(sess, ac, []byte("\x1b "))
304455 awaitFrame(t, sends, ports.MsgOutput)
305- cmd, ok := ac.overlays.palette.Selected()
456+ result, ok := ac.overlays.palette.Selected()
457+ require.True(t, ok)
458+ cmd, ok := result.Command()
306459 require.True(t, ok)
307460 require.Equal(t, "NXT", cmd.Code)
308461 }
@@ -413,13 +566,17 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
413566
414567 d.handlePaletteInput(ac, []byte{0x0e})
415568 awaitFrame(t, sends, ports.MsgOutput)
416- cmd, ok := ac.overlays.palette.Selected()
569+ result, ok := ac.overlays.palette.Selected()
570+ require.True(t, ok)
571+ cmd, ok := result.Command()
417572 require.True(t, ok)
418573 require.Equal(t, "CNS", cmd.Code)
419574
420575 d.handlePaletteInput(ac, []byte{0x10})
421576 awaitFrame(t, sends, ports.MsgOutput)
422- cmd, ok = ac.overlays.palette.Selected()
577+ result, ok = ac.overlays.palette.Selected()
578+ require.True(t, ok)
579+ cmd, ok = result.Command()
423580 require.True(t, ok)
424581 require.Equal(t, "CNT", cmd.Code)
425582 }
@@ -48,6 +48,140 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
48 awaitFrame(t, sends, ports.MsgOutput)48 awaitFrame(t, sends, ports.MsgOutput)
49 }49 }
50 50
51+func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
52+ p, release := newBlockingPTY(t)
53+ defer release()
54+ d, current, ac, _ := newManualSessionWithPTYs(t, p)
55+ d.sessions["active"] = &session{id: "active", name: "active", createdAt: 10}
56+ d.sessions["ephemeral"] = &session{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}
57+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12}
58+ d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true}
59+
60+ d.enterPalette(current, ac)
61+ ac.overlays.paletteMu.Lock()
62+ defer ac.overlays.paletteMu.Unlock()
63+
64+ got := map[string]palette.ResultKind{}
65+ for _, match := range ac.overlays.palette.Matches() {
66+ if name, ok := match.Result.SessionName(); ok {
67+ got[name] = match.Result.Kind()
68+ }
69+ }
70+ require.Equal(t, map[string]palette.ResultKind{
71+ "active": palette.ResultKindActiveSession,
72+ "stopped": palette.ResultKindStoppedSession,
73+ }, got)
74+}
75+
76+func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
77+ p, release := newBlockingPTY(t)
78+ defer release()
79+ d, current, ac, _ := newManualSessionWithPTYs(t, p)
80+ target := &session{id: "active", name: "active", createdAt: 10}
81+ d.sessions[target.id] = target
82+
83+ d.enterPalette(current, ac)
84+ d.handlePaletteInput(ac, []byte("active"))
85+ ac.overlays.paletteMu.Lock()
86+ selected, ok := ac.overlays.palette.Selected()
87+ ac.overlays.paletteMu.Unlock()
88+ _, isSession := selected.SessionName()
89+ require.True(t, ok)
90+ require.True(t, isSession)
91+ d.mu.Lock()
92+ delete(d.sessions, target.id)
93+ d.mu.Unlock()
94+
95+ d.handlePaletteInput(ac, []byte("\r"))
96+ ac.overlays.paletteMu.Lock()
97+ require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback)
98+ ac.overlays.paletteMu.Unlock()
99+ d.handlePaletteInput(ac, []byte("x"))
100+ ac.overlays.paletteMu.Lock()
101+ require.Empty(t, ac.overlays.paletteFeedback)
102+ ac.overlays.paletteMu.Unlock()
103+}
104+
105+func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) {
106+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
107+ defer releaseAll(releases)
108+ target := d.sessions[domain.SessionID("recent")]
109+ target.createdAt = 42
110+
111+ d.handleInput(current, ac, []byte("\x1b "))
112+ awaitFrame(t, sends, ports.MsgOutput)
113+ d.handleInput(current, ac, []byte("recent\rx"))
114+
115+ require.Same(t, target, ac.currentSession(), "Enter must retain the selected session despite trailing frame bytes")
116+ require.False(t, ac.overlays.paletteActive(), "trailing input must not prevent the captured selection from closing the palette")
117+ awaitFrame(t, sends, ports.MsgOutput)
118+ awaitFrame(t, sends, ports.MsgOutput)
119+}
120+
121+func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) {
122+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
123+ defer releaseAll(releases)
124+ target := d.sessions[domain.SessionID("recent")]
125+ target.createdAt = 42
126+
127+ d.handleInput(current, ac, []byte("\x1b "))
128+ awaitFrame(t, sends, ports.MsgOutput)
129+ d.handleInput(current, ac, []byte("recent\r"))
130+
131+ require.Same(t, target, ac.currentSession())
132+ require.Same(t, ac, target.client, "canonical handoff reuses the attached client")
133+ require.False(t, ac.overlays.paletteActive())
134+ require.Empty(t, d.paletteRecent, "session selections are not command recency")
135+ awaitFrame(t, sends, ports.MsgOutput)
136+ awaitFrame(t, sends, ports.MsgOutput)
137+}
138+
139+func TestPaletteStoppedSessionResumeFailureKeepsPaletteAndSourceAttachment(t *testing.T) {
140+ p, release := newBlockingPTY(t)
141+ defer release()
142+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
143+ stopped := stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42, lastUsedSeq: 7, tabNames: []string{"shell", "logs"}}
144+ d.stopped[stopped.name] = stopped
145+ ptys := portsmocks.NewMockPTYFactory(t)
146+ ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("open failed")).Once()
147+ d.ptys = ptys
148+
149+ d.handleInput(current, ac, []byte("\x1b "))
150+ awaitFrame(t, sends, ports.MsgOutput)
151+ d.handleInput(current, ac, []byte("stopped\r"))
152+
153+ require.Same(t, current, ac.currentSession(), "failed resume must retain the source attachment")
154+ current.mu.Lock()
155+ require.Same(t, ac, current.client)
156+ current.mu.Unlock()
157+ require.True(t, ac.overlays.paletteActive())
158+ ac.overlays.paletteMu.Lock()
159+ require.Equal(t, "requested session is unavailable", ac.overlays.paletteFeedback)
160+ ac.overlays.paletteMu.Unlock()
161+ require.Equal(t, stopped, d.stopped[stopped.name], "failed resume must retain stopped lifecycle metadata")
162+ awaitFrame(t, sends, ports.MsgOutput)
163+}
164+
165+func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
166+ p1, release1 := newBlockingPTY(t)
167+ p2, release2 := newBlockingPTY(t)
168+ defer release1()
169+ defer release2()
170+ d, current, ac, sends := newManualSessionWithPTYs(t, p1)
171+ d.ptys = newFactory(t, p2)
172+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42}
173+
174+ d.handleInput(current, ac, []byte("\x1b "))
175+ awaitFrame(t, sends, ports.MsgOutput)
176+ d.handleInput(current, ac, []byte("stopped\r"))
177+
178+ require.Equal(t, "stopped", ac.currentSession().name)
179+ require.Equal(t, int64(42), ac.currentSession().createdAt)
180+ require.False(t, ac.overlays.paletteActive())
181+ awaitFrame(t, sends, ports.MsgOutput)
182+ awaitFrame(t, sends, ports.MsgOutput)
183+}
184+
51 func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {185 func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
52 p, release := newBlockingPTY(t)186 p, release := newBlockingPTY(t)
53 defer release()187 defer release()
@@ -172,7 +306,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
172 require.True(t, ac.overlays.paletteActive())306 require.True(t, ac.overlays.paletteActive())
173 ac.overlays.paletteMu.Lock()307 ac.overlays.paletteMu.Lock()
174 require.Equal(t, "JRS 1", ac.overlays.palette.Query())308 require.Equal(t, "JRS 1", ac.overlays.palette.Query())
175- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)309+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
176 ac.overlays.paletteMu.Unlock()310 ac.overlays.paletteMu.Unlock()
177 }311 }
178 312
@@ -188,7 +322,7 @@ func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
188 require.True(t, ac.overlays.paletteActive())322 require.True(t, ac.overlays.paletteActive())
189 ac.overlays.paletteMu.Lock()323 ac.overlays.paletteMu.Lock()
190 require.Equal(t, "JRS 3", ac.overlays.palette.Query())324 require.Equal(t, "JRS 3", ac.overlays.palette.Query())
191- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)325+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
192 ac.overlays.paletteMu.Unlock()326 ac.overlays.paletteMu.Unlock()
193 awaitFrame(t, sends, ports.MsgOutput)327 awaitFrame(t, sends, ports.MsgOutput)
194 }328 }
@@ -205,11 +339,28 @@ func TestPaletteJRSMalformedRankFeedback(t *testing.T) {
205 require.True(t, ac.overlays.paletteActive())339 require.True(t, ac.overlays.paletteActive())
206 ac.overlays.paletteMu.Lock()340 ac.overlays.paletteMu.Lock()
207 require.Equal(t, "JRS 0", ac.overlays.palette.Query())341 require.Equal(t, "JRS 0", ac.overlays.palette.Query())
208- require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteHints.Feedback)342+ require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteFeedback)
209 ac.overlays.paletteMu.Unlock()343 ac.overlays.paletteMu.Unlock()
210 awaitFrame(t, sends, ports.MsgOutput)344 awaitFrame(t, sends, ports.MsgOutput)
211 }345 }
212 346
347+func TestPaletteFailureDoesNotOverwriteChangedQueryInSameGeneration(t *testing.T) {
348+ p, release := newBlockingPTY(t)
349+ defer release()
350+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
351+
352+ d.enterPalette(sess, ac)
353+ ac.overlays.paletteMu.Lock()
354+ generation := ac.overlays.paletteGeneration
355+ ac.overlays.palette.Insert('x')
356+ ac.overlays.paletteMu.Unlock()
357+ ac.paletteFailure(generation, "", "stale failure")
358+
359+ ac.overlays.paletteMu.Lock()
360+ require.Empty(t, ac.overlays.paletteFeedback)
361+ ac.overlays.paletteMu.Unlock()
362+}
363+
213 func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {364 func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
214 p, release := newBlockingPTY(t)365 p, release := newBlockingPTY(t)
215 defer release()366 defer release()
@@ -223,7 +374,7 @@ func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
223 ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")374 ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")
224 375
225 ac.overlays.paletteMu.Lock()376 ac.overlays.paletteMu.Lock()
226- require.Empty(t, ac.overlays.paletteHints.Feedback)377+ require.Empty(t, ac.overlays.paletteFeedback)
227 ac.overlays.paletteMu.Unlock()378 ac.overlays.paletteMu.Unlock()
228 awaitFrame(t, sends, ports.MsgOutput)379 awaitFrame(t, sends, ports.MsgOutput)
229 awaitFrame(t, sends, ports.MsgOutput)380 awaitFrame(t, sends, ports.MsgOutput)
@@ -302,7 +453,9 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
302 453
303 d.handleInput(sess, ac, []byte("\x1b "))454 d.handleInput(sess, ac, []byte("\x1b "))
304 awaitFrame(t, sends, ports.MsgOutput)455 awaitFrame(t, sends, ports.MsgOutput)
305- cmd, ok := ac.overlays.palette.Selected()456+ result, ok := ac.overlays.palette.Selected()
457+ require.True(t, ok)
458+ cmd, ok := result.Command()
306 require.True(t, ok)459 require.True(t, ok)
307 require.Equal(t, "NXT", cmd.Code)460 require.Equal(t, "NXT", cmd.Code)
308 }461 }
@@ -413,13 +566,17 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
413 566
414 d.handlePaletteInput(ac, []byte{0x0e})567 d.handlePaletteInput(ac, []byte{0x0e})
415 awaitFrame(t, sends, ports.MsgOutput)568 awaitFrame(t, sends, ports.MsgOutput)
416- cmd, ok := ac.overlays.palette.Selected()569+ result, ok := ac.overlays.palette.Selected()
570+ require.True(t, ok)
571+ cmd, ok := result.Command()
417 require.True(t, ok)572 require.True(t, ok)
418 require.Equal(t, "CNS", cmd.Code)573 require.Equal(t, "CNS", cmd.Code)
419 574
420 d.handlePaletteInput(ac, []byte{0x10})575 d.handlePaletteInput(ac, []byte{0x10})
421 awaitFrame(t, sends, ports.MsgOutput)576 awaitFrame(t, sends, ports.MsgOutput)
422- cmd, ok = ac.overlays.palette.Selected()577+ result, ok = ac.overlays.palette.Selected()
578+ require.True(t, ok)
579+ cmd, ok = result.Command()
423 require.True(t, ok)580 require.True(t, ok)
424 require.Equal(t, "CNT", cmd.Code)581 require.Equal(t, "CNT", cmd.Code)
425 }582 }
M internal/usecase/daemon/picker.go +136 -47
@@ -49,6 +49,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
4949 for _, s := range sessions {
5050 s.mu.Lock()
5151 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}
52+ if !s.ephemeral {
53+ createdAt := s.createdAt
54+ view.TargetName = s.name
55+ view.ExpectedCreatedAt = &createdAt
56+ }
5257 sessionAttention := false
5358 for i, tb := range s.tabs {
5459 name := tabDisplayName(tb, i)
@@ -71,11 +76,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7176 views = append(views, view)
7277 }
7378 for _, s := range stopped {
79+ createdAt := s.createdAt
7480 views = append(views, picker.SessionView{
75- ID: domain.SessionID("stopped:" + s.name),
76- Name: s.name,
77- Tabs: []picker.TabEntry{{}},
78- Stopped: true,
81+ ID: domain.SessionID("stopped:" + s.name),
82+ Name: s.name,
83+ TargetName: s.name,
84+ Tabs: []picker.TabEntry{{}},
85+ Stopped: true,
86+ ExpectedCreatedAt: &createdAt,
7987 })
8088 }
8189 return views, curTab
@@ -383,48 +391,102 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
383391 return d.sessions[id]
384392 }
385393
386-func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool {
387- if target.Stopped {
388- return d.resumeStoppedAndSwitch(sess, ac, target)
389- }
390- targetSess := d.sessionByID(target.Session)
391- if targetSess == nil {
392- d.invalidateRender(sess, ac, true, "picker.go")
393- return false
394- }
395- if targetSess == sess {
396- if sess.switchTab(target.TabIndex) {
397- d.activateTab(sess, sess.activeTab())
394+// switchToTarget resolves named lifecycle targets and commits their transition
395+// while d.mu is held. A named palette result is allowed to cross an
396+// active/stopped transition, but it never follows a same-name replacement.
397+func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool {
398+ d.mu.Lock()
399+ var (
400+ targetSess *session
401+ old *attachedClient
402+ cleanups []renderLifecycleCleanup
403+ switched bool
404+ )
405+ if target.Name != "" {
406+ active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
407+ if ok {
408+ if isStopped {
409+ targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
410+ } else {
411+ // The snapshot ID may describe the stopped representation. The
412+ // locked name/lifecycle resolver chose this active incarnation.
413+ resolvedTarget := target
414+ resolvedTarget.Session = active.id
415+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget)
416+ }
398417 }
399- d.invalidateRender(sess, ac, true, "picker.go")
400- return true
418+ } else if active := d.sessions[target.Session]; active != nil {
419+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target)
401420 }
402- old := d.stealClientForTarget(sess, ac, targetSess, target)
403- if ac.currentSession() != targetSess {
404- d.invalidateRender(sess, ac, true, "picker.go")
421+ d.mu.Unlock()
422+
423+ if !switched {
424+ d.invalidateRender(from, ac, true, "picker.go")
405425 return false
406426 }
427+ finishRenderLifecycleCleanups(cleanups)
407428 if old != nil && old != ac {
408429 d.unregisterPreview(old)
409430 old.clearPreviousSession()
410431 old.setSession(nil)
411432 d.notifyDetachedAsync(old, ports.ReasonDetach)
412433 }
413- d.firstPaint(targetSess, ac, ac.size)
434+ if targetSess == from {
435+ d.activateTab(from, from.activeTab())
436+ d.invalidateRender(from, ac, true, "picker.go")
437+ } else {
438+ d.firstPaint(targetSess, ac, ac.size)
439+ }
414440 return true
415441 }
416442
417-func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
418- d.mu.Lock()
443+// resolveNamedLifecycleTargetLocked chooses exactly one current representation
444+// of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle
445+// identity here closes the lookup-to-handoff window for palette targets.
446+func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) {
447+ if target.Name == "" {
448+ return nil, stoppedSession{}, false, false
449+ }
450+ if active := d.findByNameLocked(target.Name); active != nil {
451+ active.mu.Lock()
452+ matches := targetMatchesLifecycle(target, active.name, active.createdAt)
453+ active.mu.Unlock()
454+ return active, stoppedSession{}, false, matches
455+ }
456+ stopped, ok := d.stopped[target.Name]
457+ if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
458+ return nil, stoppedSession{}, false, false
459+ }
460+ return nil, stopped, true, true
461+}
462+
463+// switchToActiveTargetLocked commits an active target handoff. Caller holds
464+// d.mu and releases it before completing render cleanup or first paint.
465+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
419466 if d.sessions[target.Session] != targetSess {
420- d.mu.Unlock()
421- return nil
467+ return nil, nil, nil, false
468+ }
469+ if targetSess == from {
470+ targetSess.mu.Lock()
471+ defer targetSess.mu.Unlock()
472+ if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
473+ return nil, nil, nil, false
474+ }
475+ targetSess.active = target.TabIndex
476+ return targetSess, nil, nil, true
477+ }
478+
479+ targetSess.mu.Lock()
480+ matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt)
481+ if !matches {
482+ targetSess.mu.Unlock()
483+ return nil, nil, nil, false
422484 }
423485 from.mu.Lock()
424486 if from.client != ac {
425487 from.mu.Unlock()
426- d.mu.Unlock()
427- return nil
488+ targetSess.mu.Unlock()
489+ return nil, nil, nil, false
428490 }
429491 term := from.terminal
430492 env := copyEnvironment(from.env)
@@ -432,7 +494,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
432494 ac.setSession(nil)
433495 from.mu.Unlock()
434496
435- targetSess.mu.Lock()
436497 old := targetSess.client
437498 targetSess.terminal = term
438499 targetSess.env = copyEnvironment(env)
@@ -441,8 +502,8 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
441502 }
442503 targetSess.mu.Unlock()
443504
444- // Do not expose targetSess.client until the target coordinator has claimed
445- // this attachment and the output dependency chain has been rebased.
505+ // Prepare source invalidation, output rebase, and destination coordinator
506+ // identity before publishing the destination attachment.
446507 cleanups := d.handoffCoordinator(from, targetSess, old, ac)
447508 ac.setSession(targetSess)
448509 targetSess.mu.Lock()
@@ -450,24 +511,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
450511 targetSess.mu.Unlock()
451512 d.touchMRU(targetSess)
452513 ac.recordPreviousSession(from)
453- d.mu.Unlock()
454- finishRenderLifecycleCleanups(cleanups)
455- return old
514+ return targetSess, old, cleanups, true
456515 }
457516
458-func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
459- d.mu.Lock()
460- stopped, ok := d.stopped[target.Name]
461- if !ok || stopped.purging {
462- d.mu.Unlock()
463- d.invalidateRender(from, ac, true, "picker.go")
464- return false
465- }
517+// resumeStoppedAndSwitchLocked creates the stopped representation and commits
518+// the handoff while d.mu is held. Creation failure leaves the source client
519+// and stopped record untouched.
520+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) {
466521 from.mu.Lock()
467522 if from.client != ac {
468523 from.mu.Unlock()
469- d.mu.Unlock()
470- return false
524+ return nil, nil, false
471525 }
472526 term := from.terminal
473527 env := copyEnvironment(from.env)
@@ -475,10 +529,8 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
475529 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
476530 if err != nil {
477531 from.mu.Unlock()
478- d.mu.Unlock()
479532 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
480- d.invalidateRender(from, ac, true, "picker.go")
481- return false
533+ return nil, nil, false
482534 }
483535 from.client = nil
484536 ac.setSession(nil)
@@ -491,7 +543,44 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
491543 targetSess.mu.Unlock()
492544 d.touchMRU(targetSess)
493545 ac.recordPreviousSession(from)
546+ return targetSess, cleanups, true
547+}
548+
549+func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
550+ return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
551+}
552+
553+// stealClientForTarget is retained for direct-ID callers and tests. Named
554+// targets must use switchToTarget so resolution and commit share d.mu.
555+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
556+ d.mu.Lock()
557+ _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target)
558+ d.mu.Unlock()
559+ if !switched {
560+ return nil
561+ }
562+ finishRenderLifecycleCleanups(cleanups)
563+ return old
564+}
565+
566+// resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
567+// its stopped target and commits creation under one d.mu critical section.
568+func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
569+ d.mu.Lock()
570+ stopped, ok := d.stopped[target.Name]
571+ var (
572+ targetSess *session
573+ cleanups []renderLifecycleCleanup
574+ switched bool
575+ )
576+ if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
577+ targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
578+ }
494579 d.mu.Unlock()
580+ if !switched {
581+ d.invalidateRender(from, ac, true, "picker.go")
582+ return false
583+ }
495584 finishRenderLifecycleCleanups(cleanups)
496585 d.firstPaint(targetSess, ac, ac.size)
497586 return true
@@ -49,6 +49,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
49 for _, s := range sessions {49 for _, s := range sessions {
50 s.mu.Lock()50 s.mu.Lock()
51 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}51 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))}
52+ if !s.ephemeral {
53+ createdAt := s.createdAt
54+ view.TargetName = s.name
55+ view.ExpectedCreatedAt = &createdAt
56+ }
52 sessionAttention := false57 sessionAttention := false
53 for i, tb := range s.tabs {58 for i, tb := range s.tabs {
54 name := tabDisplayName(tb, i)59 name := tabDisplayName(tb, i)
@@ -71,11 +76,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
71 views = append(views, view)76 views = append(views, view)
72 }77 }
73 for _, s := range stopped {78 for _, s := range stopped {
79+ createdAt := s.createdAt
74 views = append(views, picker.SessionView{80 views = append(views, picker.SessionView{
75- ID: domain.SessionID("stopped:" + s.name),81+ ID: domain.SessionID("stopped:" + s.name),
76- Name: s.name,82+ Name: s.name,
77- Tabs: []picker.TabEntry{{}},83+ TargetName: s.name,
78- Stopped: true,84+ Tabs: []picker.TabEntry{{}},
85+ Stopped: true,
86+ ExpectedCreatedAt: &createdAt,
79 })87 })
80 }88 }
81 return views, curTab89 return views, curTab
@@ -383,48 +391,102 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
383 return d.sessions[id]391 return d.sessions[id]
384 }392 }
385 393
386-func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool {394+// switchToTarget resolves named lifecycle targets and commits their transition
387- if target.Stopped {395+// while d.mu is held. A named palette result is allowed to cross an
388- return d.resumeStoppedAndSwitch(sess, ac, target)396+// active/stopped transition, but it never follows a same-name replacement.
389- }397+func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool {
390- targetSess := d.sessionByID(target.Session)398+ d.mu.Lock()
391- if targetSess == nil {399+ var (
392- d.invalidateRender(sess, ac, true, "picker.go")400+ targetSess *session
393- return false401+ old *attachedClient
394- }402+ cleanups []renderLifecycleCleanup
395- if targetSess == sess {403+ switched bool
396- if sess.switchTab(target.TabIndex) {404+ )
397- d.activateTab(sess, sess.activeTab())405+ if target.Name != "" {
406+ active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
407+ if ok {
408+ if isStopped {
409+ targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
410+ } else {
411+ // The snapshot ID may describe the stopped representation. The
412+ // locked name/lifecycle resolver chose this active incarnation.
413+ resolvedTarget := target
414+ resolvedTarget.Session = active.id
415+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget)
416+ }
398 }417 }
399- d.invalidateRender(sess, ac, true, "picker.go")418+ } else if active := d.sessions[target.Session]; active != nil {
400- return true419+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target)
401 }420 }
402- old := d.stealClientForTarget(sess, ac, targetSess, target)421+ d.mu.Unlock()
403- if ac.currentSession() != targetSess {422+
404- d.invalidateRender(sess, ac, true, "picker.go")423+ if !switched {
424+ d.invalidateRender(from, ac, true, "picker.go")
405 return false425 return false
406 }426 }
427+ finishRenderLifecycleCleanups(cleanups)
407 if old != nil && old != ac {428 if old != nil && old != ac {
408 d.unregisterPreview(old)429 d.unregisterPreview(old)
409 old.clearPreviousSession()430 old.clearPreviousSession()
410 old.setSession(nil)431 old.setSession(nil)
411 d.notifyDetachedAsync(old, ports.ReasonDetach)432 d.notifyDetachedAsync(old, ports.ReasonDetach)
412 }433 }
413- d.firstPaint(targetSess, ac, ac.size)434+ if targetSess == from {
435+ d.activateTab(from, from.activeTab())
436+ d.invalidateRender(from, ac, true, "picker.go")
437+ } else {
438+ d.firstPaint(targetSess, ac, ac.size)
439+ }
414 return true440 return true
415 }441 }
416 442
417-func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {443+// resolveNamedLifecycleTargetLocked chooses exactly one current representation
418- d.mu.Lock()444+// of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle
445+// identity here closes the lookup-to-handoff window for palette targets.
446+func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) {
447+ if target.Name == "" {
448+ return nil, stoppedSession{}, false, false
449+ }
450+ if active := d.findByNameLocked(target.Name); active != nil {
451+ active.mu.Lock()
452+ matches := targetMatchesLifecycle(target, active.name, active.createdAt)
453+ active.mu.Unlock()
454+ return active, stoppedSession{}, false, matches
455+ }
456+ stopped, ok := d.stopped[target.Name]
457+ if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
458+ return nil, stoppedSession{}, false, false
459+ }
460+ return nil, stopped, true, true
461+}
462+
463+// switchToActiveTargetLocked commits an active target handoff. Caller holds
464+// d.mu and releases it before completing render cleanup or first paint.
465+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
419 if d.sessions[target.Session] != targetSess {466 if d.sessions[target.Session] != targetSess {
420- d.mu.Unlock()467+ return nil, nil, nil, false
421- return nil468+ }
469+ if targetSess == from {
470+ targetSess.mu.Lock()
471+ defer targetSess.mu.Unlock()
472+ if targetSess.client != ac || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
473+ return nil, nil, nil, false
474+ }
475+ targetSess.active = target.TabIndex
476+ return targetSess, nil, nil, true
477+ }
478+
479+ targetSess.mu.Lock()
480+ matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt)
481+ if !matches {
482+ targetSess.mu.Unlock()
483+ return nil, nil, nil, false
422 }484 }
423 from.mu.Lock()485 from.mu.Lock()
424 if from.client != ac {486 if from.client != ac {
425 from.mu.Unlock()487 from.mu.Unlock()
426- d.mu.Unlock()488+ targetSess.mu.Unlock()
427- return nil489+ return nil, nil, nil, false
428 }490 }
429 term := from.terminal491 term := from.terminal
430 env := copyEnvironment(from.env)492 env := copyEnvironment(from.env)
@@ -432,7 +494,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
432 ac.setSession(nil)494 ac.setSession(nil)
433 from.mu.Unlock()495 from.mu.Unlock()
434 496
435- targetSess.mu.Lock()
436 old := targetSess.client497 old := targetSess.client
437 targetSess.terminal = term498 targetSess.terminal = term
438 targetSess.env = copyEnvironment(env)499 targetSess.env = copyEnvironment(env)
@@ -441,8 +502,8 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
441 }502 }
442 targetSess.mu.Unlock()503 targetSess.mu.Unlock()
443 504
444- // Do not expose targetSess.client until the target coordinator has claimed505+ // Prepare source invalidation, output rebase, and destination coordinator
445- // this attachment and the output dependency chain has been rebased.506+ // identity before publishing the destination attachment.
446 cleanups := d.handoffCoordinator(from, targetSess, old, ac)507 cleanups := d.handoffCoordinator(from, targetSess, old, ac)
447 ac.setSession(targetSess)508 ac.setSession(targetSess)
448 targetSess.mu.Lock()509 targetSess.mu.Lock()
@@ -450,24 +511,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
450 targetSess.mu.Unlock()511 targetSess.mu.Unlock()
451 d.touchMRU(targetSess)512 d.touchMRU(targetSess)
452 ac.recordPreviousSession(from)513 ac.recordPreviousSession(from)
453- d.mu.Unlock()514+ return targetSess, old, cleanups, true
454- finishRenderLifecycleCleanups(cleanups)
455- return old
456 }515 }
457 516
458-func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {517+// resumeStoppedAndSwitchLocked creates the stopped representation and commits
459- d.mu.Lock()518+// the handoff while d.mu is held. Creation failure leaves the source client
460- stopped, ok := d.stopped[target.Name]519+// and stopped record untouched.
461- if !ok || stopped.purging {520+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) {
462- d.mu.Unlock()
463- d.invalidateRender(from, ac, true, "picker.go")
464- return false
465- }
466 from.mu.Lock()521 from.mu.Lock()
467 if from.client != ac {522 if from.client != ac {
468 from.mu.Unlock()523 from.mu.Unlock()
469- d.mu.Unlock()524+ return nil, nil, false
470- return false
471 }525 }
472 term := from.terminal526 term := from.terminal
473 env := copyEnvironment(from.env)527 env := copyEnvironment(from.env)
@@ -475,10 +529,8 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
475 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)529 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
476 if err != nil {530 if err != nil {
477 from.mu.Unlock()531 from.mu.Unlock()
478- d.mu.Unlock()
479 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)532 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
480- d.invalidateRender(from, ac, true, "picker.go")533+ return nil, nil, false
481- return false
482 }534 }
483 from.client = nil535 from.client = nil
484 ac.setSession(nil)536 ac.setSession(nil)
@@ -491,7 +543,44 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
491 targetSess.mu.Unlock()543 targetSess.mu.Unlock()
492 d.touchMRU(targetSess)544 d.touchMRU(targetSess)
493 ac.recordPreviousSession(from)545 ac.recordPreviousSession(from)
546+ return targetSess, cleanups, true
547+}
548+
549+func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
550+ return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
551+}
552+
553+// stealClientForTarget is retained for direct-ID callers and tests. Named
554+// targets must use switchToTarget so resolution and commit share d.mu.
555+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
556+ d.mu.Lock()
557+ _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target)
558+ d.mu.Unlock()
559+ if !switched {
560+ return nil
561+ }
562+ finishRenderLifecycleCleanups(cleanups)
563+ return old
564+}
565+
566+// resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
567+// its stopped target and commits creation under one d.mu critical section.
568+func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
569+ d.mu.Lock()
570+ stopped, ok := d.stopped[target.Name]
571+ var (
572+ targetSess *session
573+ cleanups []renderLifecycleCleanup
574+ switched bool
575+ )
576+ if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
577+ targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped)
578+ }
494 d.mu.Unlock()579 d.mu.Unlock()
580+ if !switched {
581+ d.invalidateRender(from, ac, true, "picker.go")
582+ return false
583+ }
495 finishRenderLifecycleCleanups(cleanups)584 finishRenderLifecycleCleanups(cleanups)
496 d.firstPaint(targetSess, ac, ac.size)585 d.firstPaint(targetSess, ac, ac.size)
497 return true586 return true
M internal/usecase/daemon/picker_test.go +82 -14
@@ -67,6 +67,72 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
6767 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
6868 require.Equal(t, "beta ", views[1].Name)
6969 require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
70+
71+ model := picker.New(views, current.id, 0)
72+ model.Down()
73+ model.Down()
74+ target, ok := model.Selected()
75+ require.True(t, ok)
76+ require.Equal(t, "beta", target.Name)
77+ require.NotNil(t, target.ExpectedCreatedAt)
78+}
79+
80+func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
81+ d := newTestDaemon(t, nil, stubClock{})
82+ ctx, cancel := context.WithCancel(d.serveCtx)
83+ defer cancel()
84+ active := &session{id: "active", name: "active", createdAt: 23, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
85+ d.sessions[active.id] = active
86+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}
87+
88+ views, _ := d.pickerViews(active)
89+
90+ require.Len(t, views, 2)
91+ require.NotNil(t, views[0].ExpectedCreatedAt)
92+ require.Equal(t, int64(23), *views[0].ExpectedCreatedAt)
93+ require.NotNil(t, views[1].ExpectedCreatedAt)
94+ require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)
95+}
96+
97+func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
98+ p, release := newBlockingPTY(t)
99+ defer release()
100+ d, current, ac, _ := newManualSessionWithPTYs(t, p)
101+ ctx, cancel := context.WithCancel(d.serveCtx)
102+ defer cancel()
103+ original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
104+ d.sessions[original.id] = original
105+
106+ views, _ := d.pickerViews(current)
107+ var originalView picker.SessionView
108+ for _, view := range views {
109+ if view.ID == original.id {
110+ originalView = view
111+ break
112+ }
113+ }
114+ model := picker.New([]picker.SessionView{originalView}, original.id, 0)
115+ target, ok := model.Selected()
116+ require.True(t, ok)
117+
118+ delete(d.sessions, original.id)
119+ replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
120+ d.sessions[replacement.id] = replacement
121+
122+ require.False(t, d.switchToTarget(current, ac, target))
123+ require.Same(t, current, ac.currentSession())
124+}
125+
126+func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) {
127+ p1, release1 := newBlockingPTY(t)
128+ p2, release2 := newBlockingPTY(t)
129+ defer release1()
130+ defer release2()
131+ d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2)
132+ current.client = &attachedClient{}
133+
134+ require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
135+ require.Equal(t, 0, current.active)
70136 }
71137
72138 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
@@ -396,6 +462,10 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
396462 tr.EXPECT().Close().Return(nil).Maybe()
397463 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
398464 ac.initOverlays()
465+ handoffAtSendMu := make(chan struct{})
466+ ac.renderStages = renderStageHooks{
467+ handoffRebase: func() { close(handoffAtSendMu) },
468+ }
399469 sctx1, cancel1 := context.WithCancel(d.serveCtx)
400470 sctx2, cancel2 := context.WithCancel(d.serveCtx)
401471 defer cancel1()
@@ -403,6 +473,8 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
403473 sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac}
404474 sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
405475 ac.setSession(sess1)
476+ d.sessions[sess1.id] = sess1
477+ d.sessions[sess2.id] = sess2
406478
407479 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))
408480 paintDone := make(chan struct{})
@@ -414,27 +486,23 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
414486
415487 switchDone := make(chan struct{})
416488 go func() {
417- ac.setSession(sess2)
489+ cleanups := d.handoffCoordinator(sess1, sess2, nil, ac)
490+ finishRenderLifecycleCleanups(cleanups)
418491 close(switchDone)
419492 }()
493+ awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary")
494+
495+ // The handoff is immediately about to acquire sendMu, which paint still owns
496+ // through its blocked transport send.
420497 select {
421498 case <-switchDone:
422- t.Fatal("session switch completed while paint Send was still in flight")
423- case <-time.After(50 * time.Millisecond):
499+ t.Fatal("session switch completed while paint owned sendMu")
500+ default:
424501 }
425502
426503 close(releaseSend)
427- select {
428- case <-paintDone:
429- case <-time.After(time.Second):
430- t.Fatal("paint did not finish after Send was released")
431- }
432- select {
433- case <-switchDone:
434- case <-time.After(time.Second):
435- t.Fatal("session switch did not complete after paint finished")
436- }
437- require.Same(t, sess2, ac.currentSession())
504+ awaitTestCompletion(t, paintDone, "paint did not finish after Send was released")
505+ awaitTestCompletion(t, switchDone, "session handoff did not complete after paint finished")
438506 }
439507
440508 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
@@ -67,6 +67,72 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
67 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)67 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
68 require.Equal(t, "beta ", views[1].Name)68 require.Equal(t, "beta ", views[1].Name)
69 require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)69 require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs)
70+
71+ model := picker.New(views, current.id, 0)
72+ model.Down()
73+ model.Down()
74+ target, ok := model.Selected()
75+ require.True(t, ok)
76+ require.Equal(t, "beta", target.Name)
77+ require.NotNil(t, target.ExpectedCreatedAt)
78+}
79+
80+func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
81+ d := newTestDaemon(t, nil, stubClock{})
82+ ctx, cancel := context.WithCancel(d.serveCtx)
83+ defer cancel()
84+ active := &session{id: "active", name: "active", createdAt: 23, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
85+ d.sessions[active.id] = active
86+ d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}
87+
88+ views, _ := d.pickerViews(active)
89+
90+ require.Len(t, views, 2)
91+ require.NotNil(t, views[0].ExpectedCreatedAt)
92+ require.Equal(t, int64(23), *views[0].ExpectedCreatedAt)
93+ require.NotNil(t, views[1].ExpectedCreatedAt)
94+ require.Equal(t, int64(24), *views[1].ExpectedCreatedAt)
95+}
96+
97+func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
98+ p, release := newBlockingPTY(t)
99+ defer release()
100+ d, current, ac, _ := newManualSessionWithPTYs(t, p)
101+ ctx, cancel := context.WithCancel(d.serveCtx)
102+ defer cancel()
103+ original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
104+ d.sessions[original.id] = original
105+
106+ views, _ := d.pickerViews(current)
107+ var originalView picker.SessionView
108+ for _, view := range views {
109+ if view.ID == original.id {
110+ originalView = view
111+ break
112+ }
113+ }
114+ model := picker.New([]picker.SessionView{originalView}, original.id, 0)
115+ target, ok := model.Selected()
116+ require.True(t, ok)
117+
118+ delete(d.sessions, original.id)
119+ replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
120+ d.sessions[replacement.id] = replacement
121+
122+ require.False(t, d.switchToTarget(current, ac, target))
123+ require.Same(t, current, ac.currentSession())
124+}
125+
126+func TestSameSessionSwitchRejectsReplacedClient(t *testing.T) {
127+ p1, release1 := newBlockingPTY(t)
128+ p2, release2 := newBlockingPTY(t)
129+ defer release1()
130+ defer release2()
131+ d, current, ac, _ := newManualSessionWithPTYs(t, p1, p2)
132+ current.client = &attachedClient{}
133+
134+ require.False(t, d.switchToTarget(current, ac, picker.Target{Session: current.id, TabIndex: 1}))
135+ require.Equal(t, 0, current.active)
70 }136 }
71 137
72 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {138 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
@@ -396,6 +462,10 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
396 tr.EXPECT().Close().Return(nil).Maybe()462 tr.EXPECT().Close().Return(nil).Maybe()
397 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}463 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
398 ac.initOverlays()464 ac.initOverlays()
465+ handoffAtSendMu := make(chan struct{})
466+ ac.renderStages = renderStageHooks{
467+ handoffRebase: func() { close(handoffAtSendMu) },
468+ }
399 sctx1, cancel1 := context.WithCancel(d.serveCtx)469 sctx1, cancel1 := context.WithCancel(d.serveCtx)
400 sctx2, cancel2 := context.WithCancel(d.serveCtx)470 sctx2, cancel2 := context.WithCancel(d.serveCtx)
401 defer cancel1()471 defer cancel1()
@@ -403,6 +473,8 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
403 sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac}473 sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac}
404 sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}474 sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
405 ac.setSession(sess1)475 ac.setSession(sess1)
476+ d.sessions[sess1.id] = sess1
477+ d.sessions[sess2.id] = sess2
406 478
407 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))479 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))
408 paintDone := make(chan struct{})480 paintDone := make(chan struct{})
@@ -414,27 +486,23 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
414 486
415 switchDone := make(chan struct{})487 switchDone := make(chan struct{})
416 go func() {488 go func() {
417- ac.setSession(sess2)489+ cleanups := d.handoffCoordinator(sess1, sess2, nil, ac)
490+ finishRenderLifecycleCleanups(cleanups)
418 close(switchDone)491 close(switchDone)
419 }()492 }()
493+ awaitTestCompletion(t, handoffAtSendMu, "session switch did not reach the handoff sendMu boundary")
494+
495+ // The handoff is immediately about to acquire sendMu, which paint still owns
496+ // through its blocked transport send.
420 select {497 select {
421 case <-switchDone:498 case <-switchDone:
422- t.Fatal("session switch completed while paint Send was still in flight")499+ t.Fatal("session switch completed while paint owned sendMu")
423- case <-time.After(50 * time.Millisecond):500+ default:
424 }501 }
425 502
426 close(releaseSend)503 close(releaseSend)
427- select {504+ awaitTestCompletion(t, paintDone, "paint did not finish after Send was released")
428- case <-paintDone:505+ awaitTestCompletion(t, switchDone, "session handoff did not complete after paint finished")
429- case <-time.After(time.Second):
430- t.Fatal("paint did not finish after Send was released")
431- }
432- select {
433- case <-switchDone:
434- case <-time.After(time.Second):
435- t.Fatal("session switch did not complete after paint finished")
436- }
437- require.Same(t, sess2, ac.currentSession())
438 }506 }
439 507
440 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {508 func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
M internal/usecase/daemon/render_pipeline.go +12 -10
@@ -16,15 +16,17 @@ import (
1616
1717 // composeCacheInput is an attachment-local value snapshot. composeFrame never
1818 // retains or accesses the attachment that owns it.
19-// renderStageHooks are optional observability hooks. They are invoked only at
20-// completed production boundaries: capture and compose after their result is
21-// available, and emit after prepare plus transport success (including a
22-// successful no-byte emission). Failed attempts count for capture/compose but
23-// never for emit.
19+// renderStageHooks are optional observability hooks. capture and compose run
20+// after their result is available, and emit after prepare plus transport success
21+// (including a successful no-byte emission); those hooks run while sendMu is
22+// held. Failed attempts count for capture/compose but never for emit.
23+// handoffRebase marks the boundary immediately before a handoff acquires sendMu
24+// to rebase attachment-owned output state, so it intentionally runs unlocked.
2425 type renderStageHooks struct {
25- capture func()
26- compose func()
27- emit func()
26+ capture func()
27+ compose func()
28+ emit func()
29+ handoffRebase func()
2830 }
2931
3032 type composeCacheInput struct {
@@ -206,8 +208,8 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
206208 if snap.paletteHints != nil {
207209 guidance = snap.paletteHints.Feedback
208210 }
209- o.paletteGuidance = guidance
210- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance})
211+ o.paletteGuidance = snap.paletteFeedback
212+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback})
211213 }
212214 if snap.promptActive && snap.promptModel != nil {
213215 o.prompt.modal = promptModalFor(snap.promptModel.Title())
@@ -16,15 +16,17 @@ import (
16 16
17 // composeCacheInput is an attachment-local value snapshot. composeFrame never17 // composeCacheInput is an attachment-local value snapshot. composeFrame never
18 // retains or accesses the attachment that owns it.18 // retains or accesses the attachment that owns it.
19-// renderStageHooks are optional observability hooks. They are invoked only at19+// renderStageHooks are optional observability hooks. capture and compose run
20-// completed production boundaries: capture and compose after their result is20+// after their result is available, and emit after prepare plus transport success
21-// available, and emit after prepare plus transport success (including a21+// (including a successful no-byte emission); those hooks run while sendMu is
22-// successful no-byte emission). Failed attempts count for capture/compose but22+// held. Failed attempts count for capture/compose but never for emit.
23-// never for emit.23+// handoffRebase marks the boundary immediately before a handoff acquires sendMu
24+// to rebase attachment-owned output state, so it intentionally runs unlocked.
24 type renderStageHooks struct {25 type renderStageHooks struct {
25- capture func()26+ capture func()
26- compose func()27+ compose func()
27- emit func()28+ emit func()
29+ handoffRebase func()
28 }30 }
29 31
30 type composeCacheInput struct {32 type composeCacheInput struct {
@@ -206,8 +208,8 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
206 if snap.paletteHints != nil {208 if snap.paletteHints != nil {
207 guidance = snap.paletteHints.Feedback209 guidance = snap.paletteHints.Feedback
208 }210 }
209- o.paletteGuidance = guidance211+ o.paletteGuidance = snap.paletteFeedback
210- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance})212+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance, Feedback: snap.paletteFeedback})
211 }213 }
212 if snap.promptActive && snap.promptModel != nil {214 if snap.promptActive && snap.promptModel != nil {
213 o.prompt.modal = promptModalFor(snap.promptModel.Title())215 o.prompt.modal = promptModalFor(snap.promptModel.Title())
M internal/usecase/daemon/session.go +30 -13
@@ -131,6 +131,25 @@ func (d *Daemon) touchMRU(sess *session) {
131131
132132 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
133133 env = copyEnvironment(env)
134+ stopped, resuming := d.stopped[name]
135+ var createdAt int64
136+ if !ephemeral {
137+ if resuming {
138+ // A stopped session is the same lifecycle when resumed; it must retain
139+ // the persisted identity rather than receive a fresh timestamp.
140+ createdAt = stopped.createdAt
141+ if createdAt > d.lastAllocatedCreatedAt {
142+ d.lastAllocatedCreatedAt = createdAt
143+ }
144+ } else {
145+ var err error
146+ createdAt, err = d.allocateLifecycleCreatedAtLocked()
147+ if err != nil {
148+ return nil, err
149+ }
150+ }
151+ }
152+
134153 tbSize := tabSize(sz)
135154 var names []string
136155 if len(restoredTabNames) > 0 {
@@ -159,17 +178,14 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
159178
160179 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
161180 d.nextID++
162- createdAt := time.Now().UnixNano()
163181
164182 sctx, cancel := context.WithCancel(d.serveCtx)
165183 for _, tb := range tabs {
166184 tb.ctx, tb.cancel = context.WithCancel(sctx)
167185 }
168186 lastUsedSeq := uint64(0)
169- if !ephemeral {
170- if stopped, ok := d.stopped[name]; ok {
171- lastUsedSeq = stopped.lastUsedSeq
172- }
187+ if !ephemeral && resuming {
188+ lastUsedSeq = stopped.lastUsedSeq
173189 }
174190 sess := &session{
175191 id: id,
@@ -495,13 +511,17 @@ func (d *Daemon) renameSession(sess *session, name string) error {
495511 oldName := sess.name
496512 wasEphemeral := sess.ephemeral
497513 createdAt := sess.createdAt
498- if createdAt == 0 {
499- createdAt = time.Now().UnixNano()
500- sess.createdAt = createdAt
514+ if wasEphemeral {
515+ var err error
516+ createdAt, err = d.allocateLifecycleCreatedAtLocked()
517+ if err != nil {
518+ sess.mu.Unlock()
519+ return err
520+ }
501521 }
502522 lastUsedSeq := sess.mruAt.Load()
503523 if wasEphemeral || oldName != name {
504- record := sess.persistRecordLocked(time.Now().UnixNano())
524+ record := sess.persistRecordLocked(d.nowUnixNano())
505525 record.Name = name
506526 record.CreatedAt = createdAt
507527 record.LastUsedSeq = lastUsedSeq
@@ -531,6 +551,7 @@ func (d *Daemon) renameSession(sess *session, name string) error {
531551 delete(d.stopped, oldName)
532552 delete(d.stopped, name)
533553 sess.name = name
554+ sess.createdAt = createdAt
534555 sess.ephemeral = false
535556 sess.snapEligible.Store(name != "")
536557 sess.mu.Unlock()
@@ -572,10 +593,6 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
572593
573594 func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
574595 createdAt := s.createdAt
575- if createdAt == 0 {
576- createdAt = updatedAt
577- s.createdAt = createdAt
578- }
579596 tabNames := make([]string, len(s.tabs))
580597 lastCustom := -1
581598 for i, tb := range s.tabs {
@@ -131,6 +131,25 @@ func (d *Daemon) touchMRU(sess *session) {
131 131
132 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {132 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
133 env = copyEnvironment(env)133 env = copyEnvironment(env)
134+ stopped, resuming := d.stopped[name]
135+ var createdAt int64
136+ if !ephemeral {
137+ if resuming {
138+ // A stopped session is the same lifecycle when resumed; it must retain
139+ // the persisted identity rather than receive a fresh timestamp.
140+ createdAt = stopped.createdAt
141+ if createdAt > d.lastAllocatedCreatedAt {
142+ d.lastAllocatedCreatedAt = createdAt
143+ }
144+ } else {
145+ var err error
146+ createdAt, err = d.allocateLifecycleCreatedAtLocked()
147+ if err != nil {
148+ return nil, err
149+ }
150+ }
151+ }
152+
134 tbSize := tabSize(sz)153 tbSize := tabSize(sz)
135 var names []string154 var names []string
136 if len(restoredTabNames) > 0 {155 if len(restoredTabNames) > 0 {
@@ -159,17 +178,14 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
159 178
160 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))179 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
161 d.nextID++180 d.nextID++
162- createdAt := time.Now().UnixNano()
163 181
164 sctx, cancel := context.WithCancel(d.serveCtx)182 sctx, cancel := context.WithCancel(d.serveCtx)
165 for _, tb := range tabs {183 for _, tb := range tabs {
166 tb.ctx, tb.cancel = context.WithCancel(sctx)184 tb.ctx, tb.cancel = context.WithCancel(sctx)
167 }185 }
168 lastUsedSeq := uint64(0)186 lastUsedSeq := uint64(0)
169- if !ephemeral {187+ if !ephemeral && resuming {
170- if stopped, ok := d.stopped[name]; ok {188+ lastUsedSeq = stopped.lastUsedSeq
171- lastUsedSeq = stopped.lastUsedSeq
172- }
173 }189 }
174 sess := &session{190 sess := &session{
175 id: id,191 id: id,
@@ -495,13 +511,17 @@ func (d *Daemon) renameSession(sess *session, name string) error {
495 oldName := sess.name511 oldName := sess.name
496 wasEphemeral := sess.ephemeral512 wasEphemeral := sess.ephemeral
497 createdAt := sess.createdAt513 createdAt := sess.createdAt
498- if createdAt == 0 {514+ if wasEphemeral {
499- createdAt = time.Now().UnixNano()515+ var err error
500- sess.createdAt = createdAt516+ createdAt, err = d.allocateLifecycleCreatedAtLocked()
517+ if err != nil {
518+ sess.mu.Unlock()
519+ return err
520+ }
501 }521 }
502 lastUsedSeq := sess.mruAt.Load()522 lastUsedSeq := sess.mruAt.Load()
503 if wasEphemeral || oldName != name {523 if wasEphemeral || oldName != name {
504- record := sess.persistRecordLocked(time.Now().UnixNano())524+ record := sess.persistRecordLocked(d.nowUnixNano())
505 record.Name = name525 record.Name = name
506 record.CreatedAt = createdAt526 record.CreatedAt = createdAt
507 record.LastUsedSeq = lastUsedSeq527 record.LastUsedSeq = lastUsedSeq
@@ -531,6 +551,7 @@ func (d *Daemon) renameSession(sess *session, name string) error {
531 delete(d.stopped, oldName)551 delete(d.stopped, oldName)
532 delete(d.stopped, name)552 delete(d.stopped, name)
533 sess.name = name553 sess.name = name
554+ sess.createdAt = createdAt
534 sess.ephemeral = false555 sess.ephemeral = false
535 sess.snapEligible.Store(name != "")556 sess.snapEligible.Store(name != "")
536 sess.mu.Unlock()557 sess.mu.Unlock()
@@ -572,10 +593,6 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
572 593
573 func (s *session) persistRecordLocked(updatedAt int64) persist.Record {594 func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
574 createdAt := s.createdAt595 createdAt := s.createdAt
575- if createdAt == 0 {
576- createdAt = updatedAt
577- s.createdAt = createdAt
578- }
579 tabNames := make([]string, len(s.tabs))596 tabNames := make([]string, len(s.tabs))
580 lastCustom := -1597 lastCustom := -1
581 for i, tb := range s.tabs {598 for i, tb := range s.tabs {
M internal/usecase/daemon/session_test.go +248 -0
@@ -2,8 +2,10 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "io"
67 "log/slog"
8+ "math"
79 "strings"
810 "sync"
911 "sync/atomic"
@@ -1029,6 +1031,111 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
10291031 require.True(t, state.has("named"))
10301032 }
10311033
1034+func TestEphemeralPromotionAssignsAndPersistsLifecycleIdentity(t *testing.T) {
1035+ p, release := newBlockingPTY(t)
1036+ defer release()
1037+ store, state := newMockStore(t)
1038+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}}
1039+ d := newTestDaemon(t, newFactory(t, p), clock)
1040+ WithStore(store)(d)
1041+
1042+ sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1043+ require.NoError(t, err)
1044+ require.Zero(t, sess.createdAt, "regression fixture: ephemerals have no lifecycle identity")
1045+
1046+ require.NoError(t, d.renameSession(sess, "named"))
1047+
1048+ require.False(t, sess.ephemeral)
1049+ require.Equal(t, "named", sess.name)
1050+ require.Equal(t, int64(100), sess.createdAt)
1051+ require.Equal(t, sess.createdAt, state.record(t, "named").CreatedAt)
1052+}
1053+
1054+func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testing.T) {
1055+ fromPTY, releaseFrom := newBlockingPTY(t)
1056+ firstPTY, releaseFirst := newBlockingPTY(t)
1057+ secondPTY, releaseSecond := newBlockingPTY(t)
1058+ defer releaseFrom()
1059+ defer releaseFirst()
1060+ defer releaseSecond()
1061+
1062+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1063+ store, _ := newMockStore(t)
1064+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
1065+ d.clock = clock
1066+ d.ptys = newFactorySeq(t, firstPTY, secondPTY)
1067+ WithStore(store)(d)
1068+
1069+ first, err := d.createSessionLocked("0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
1070+ require.NoError(t, err)
1071+ require.NoError(t, d.renameSession(first, "named"))
1072+ staleCreatedAt := first.createdAt
1073+ require.NotZero(t, staleCreatedAt)
1074+ require.NoError(t, d.killSession(first, ports.ReasonSessionKilled, true))
1075+ d.sessWg.Wait()
1076+
1077+ second, err := d.createSessionLocked("0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
1078+ require.NoError(t, err)
1079+ require.NoError(t, d.renameSession(second, "named"))
1080+ require.NotEqual(t, staleCreatedAt, second.createdAt)
1081+
1082+ require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt}))
1083+ require.Same(t, from, ac.currentSession())
1084+ releaseSecond()
1085+ d.sessWg.Wait()
1086+}
1087+
1088+func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T) {
1089+ t.Run("allocator exhaustion", func(t *testing.T) {
1090+ p, release := newBlockingPTY(t)
1091+ defer release()
1092+ store, state := newMockStore(t)
1093+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
1094+ WithStore(store)(d)
1095+ d.lastAllocatedCreatedAt = math.MaxInt64
1096+
1097+ sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1098+ require.NoError(t, err)
1099+ require.EqualError(t, d.renameSession(sess, "named"), "daemon: lifecycle identities exhausted")
1100+ require.Equal(t, "0", sess.name)
1101+ require.True(t, sess.ephemeral)
1102+ require.Zero(t, sess.createdAt)
1103+ require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt)
1104+ require.False(t, state.has("named"))
1105+ })
1106+
1107+ t.Run("persistence failure", func(t *testing.T) {
1108+ p, release := newBlockingPTY(t)
1109+ defer release()
1110+ store := portsmocks.NewMockStore(t)
1111+ var attempted map[string][]byte
1112+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
1113+ attempted = map[string][]byte{string(key): append([]byte(nil), value...)}
1114+ return errors.New("disk full")
1115+ }).Once()
1116+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
1117+ d := newTestDaemon(t, newFactory(t, p), clock)
1118+ WithStore(store)(d)
1119+
1120+ sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1121+ require.NoError(t, err)
1122+ require.EqualError(t, d.renameSession(sess, "named"), "disk full")
1123+ require.Equal(t, "0", sess.name)
1124+ require.True(t, sess.ephemeral)
1125+ require.Zero(t, sess.createdAt)
1126+ require.Equal(t, int64(100), d.lastAllocatedCreatedAt, "a failed durable write may be ambiguous, so its lifecycle identity remains reserved")
1127+ records, err := persist.New(newStaticStore(attempted)).LoadAll()
1128+ require.NoError(t, err)
1129+ require.Len(t, records, 1)
1130+ require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity")
1131+
1132+ store.EXPECT().Set(mock.Anything, mock.Anything).Return(nil).Once()
1133+ store.EXPECT().Sync().Return(nil).Once()
1134+ require.NoError(t, d.renameSession(sess, "named"))
1135+ require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion")
1136+ })
1137+}
1138+
10321139 func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
10331140 sz := domain.Size{Cols: 80, Rows: 24}
10341141 p, release := newBlockingPTY(t)
@@ -1498,3 +1605,144 @@ func TestShellFromEnvironmentFallsBackOnlyWhenAbsentOrEmpty(t *testing.T) {
14981605 })
14991606 }
15001607 }
1608+
1609+type lifecycleClock struct {
1610+ nows []time.Time
1611+ next int
1612+}
1613+
1614+func (c *lifecycleClock) Now() time.Time {
1615+ i := min(c.next, len(c.nows)-1)
1616+ c.next++
1617+ return c.nows[i]
1618+}
1619+func (*lifecycleClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
1620+
1621+func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *testing.T) {
1622+ ptys := make([]ports.PTY, 3)
1623+ var releases []func()
1624+ for i := range ptys {
1625+ p, release := newBlockingPTY(t)
1626+ ptys[i] = p
1627+ releases = append(releases, release)
1628+ }
1629+ defer releaseAll(releases)
1630+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100), time.Unix(0, 50)}}
1631+ d := newTestDaemon(t, newFactorySeq(t, ptys...), clock)
1632+ sz := domain.Size{Cols: 80, Rows: 24}
1633+
1634+ var got []int64
1635+ for _, name := range []string{"one", "two", "three"} {
1636+ sess, err := d.createSessionLocked(name, false, "/tmp", sz, terminalEnv{}, d.baseEnv)
1637+ require.NoError(t, err)
1638+ got = append(got, sess.createdAt)
1639+ }
1640+ require.Equal(t, []int64{100, 101, 102}, got)
1641+}
1642+
1643+func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T) {
1644+ d := newTestDaemon(t, nil, stubClock{})
1645+ d.lastAllocatedCreatedAt = math.MaxInt64
1646+ d.nextID = 17
1647+ d.stopped["retained"] = stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}
1648+
1649+ sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1650+
1651+ require.ErrorContains(t, err, "lifecycle identities exhausted")
1652+ require.Nil(t, sess)
1653+ require.Empty(t, d.sessions)
1654+ require.Equal(t, uint64(17), d.nextID)
1655+ require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt)
1656+ require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"])
1657+
1658+ _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil)
1659+ require.ErrorContains(t, err, "lifecycle identities exhausted")
1660+ require.Empty(t, d.sessions)
1661+ require.Equal(t, uint64(17), d.nextID)
1662+ require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"])
1663+}
1664+
1665+func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
1666+ store, _ := newMockStore(t)
1667+ seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1668+ require.NoError(t, seed.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))
1669+
1670+ p, release := newBlockingPTY(t)
1671+ defer release()
1672+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}}
1673+ // Constructing with persistence must establish the lifecycle high-water mark.
1674+ d := New(newFactory(t, p), clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1675+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
1676+ t.Cleanup(d.serveCancel)
1677+
1678+ sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1679+ require.NoError(t, err)
1680+ require.Equal(t, int64(901), sess.createdAt)
1681+}
1682+
1683+func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testing.T) {
1684+ fromPTY, releaseFrom := newBlockingPTY(t)
1685+ targetPTY, releaseTarget := newBlockingPTY(t)
1686+ defer releaseFrom()
1687+ defer releaseTarget()
1688+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1689+ store, state := newMockStore(t)
1690+ WithStore(store)(d)
1691+ d.ptys = newFactory(t, targetPTY)
1692+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 77}
1693+
1694+ require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true}))
1695+ resumed := ac.currentSession()
1696+ require.Equal(t, int64(77), resumed.createdAt)
1697+ require.Equal(t, int64(77), state.record(t, "stopped").CreatedAt)
1698+}
1699+
1700+func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing.T) {
1701+ t.Run("active replacement is rejected", func(t *testing.T) {
1702+ d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1703+ defer releaseAll(releases)
1704+ target := d.sessions["recent"]
1705+ target.createdAt = 22
1706+ expected := int64(21)
1707+
1708+ require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))
1709+ require.Same(t, from, ac.currentSession())
1710+ })
1711+
1712+ t.Run("active target that stopped resumes same lifecycle", func(t *testing.T) {
1713+ fromPTY, releaseFrom := newBlockingPTY(t)
1714+ targetPTY, releaseTarget := newBlockingPTY(t)
1715+ defer releaseFrom()
1716+ defer releaseTarget()
1717+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1718+ d.ptys = newFactory(t, targetPTY)
1719+ expected := int64(31)
1720+ d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected}
1721+
1722+ require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
1723+ require.Equal(t, "target", ac.currentSession().name)
1724+ require.Equal(t, expected, ac.currentSession().createdAt)
1725+ })
1726+
1727+ t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) {
1728+ d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1729+ defer releaseAll(releases)
1730+ target := d.sessions["recent"]
1731+ target.createdAt = 41
1732+ expected := int64(41)
1733+
1734+ require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1735+ require.Same(t, target, ac.currentSession())
1736+ })
1737+
1738+ t.Run("stopped deletion and recreation is rejected", func(t *testing.T) {
1739+ fromPTY, releaseFrom := newBlockingPTY(t)
1740+ defer releaseFrom()
1741+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1742+ d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52}
1743+ expected := int64(51)
1744+
1745+ require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1746+ require.Same(t, from, ac.currentSession())
1747+ })
1748+}
@@ -2,8 +2,10 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "io"6 "io"
6 "log/slog"7 "log/slog"
8+ "math"
7 "strings"9 "strings"
8 "sync"10 "sync"
9 "sync/atomic"11 "sync/atomic"
@@ -1029,6 +1031,111 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
1029 require.True(t, state.has("named"))1031 require.True(t, state.has("named"))
1030 }1032 }
1031 1033
1034+func TestEphemeralPromotionAssignsAndPersistsLifecycleIdentity(t *testing.T) {
1035+ p, release := newBlockingPTY(t)
1036+ defer release()
1037+ store, state := newMockStore(t)
1038+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}}
1039+ d := newTestDaemon(t, newFactory(t, p), clock)
1040+ WithStore(store)(d)
1041+
1042+ sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1043+ require.NoError(t, err)
1044+ require.Zero(t, sess.createdAt, "regression fixture: ephemerals have no lifecycle identity")
1045+
1046+ require.NoError(t, d.renameSession(sess, "named"))
1047+
1048+ require.False(t, sess.ephemeral)
1049+ require.Equal(t, "named", sess.name)
1050+ require.Equal(t, int64(100), sess.createdAt)
1051+ require.Equal(t, sess.createdAt, state.record(t, "named").CreatedAt)
1052+}
1053+
1054+func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testing.T) {
1055+ fromPTY, releaseFrom := newBlockingPTY(t)
1056+ firstPTY, releaseFirst := newBlockingPTY(t)
1057+ secondPTY, releaseSecond := newBlockingPTY(t)
1058+ defer releaseFrom()
1059+ defer releaseFirst()
1060+ defer releaseSecond()
1061+
1062+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1063+ store, _ := newMockStore(t)
1064+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
1065+ d.clock = clock
1066+ d.ptys = newFactorySeq(t, firstPTY, secondPTY)
1067+ WithStore(store)(d)
1068+
1069+ first, err := d.createSessionLocked("0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
1070+ require.NoError(t, err)
1071+ require.NoError(t, d.renameSession(first, "named"))
1072+ staleCreatedAt := first.createdAt
1073+ require.NotZero(t, staleCreatedAt)
1074+ require.NoError(t, d.killSession(first, ports.ReasonSessionKilled, true))
1075+ d.sessWg.Wait()
1076+
1077+ second, err := d.createSessionLocked("0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
1078+ require.NoError(t, err)
1079+ require.NoError(t, d.renameSession(second, "named"))
1080+ require.NotEqual(t, staleCreatedAt, second.createdAt)
1081+
1082+ require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "named", TabIndex: -1, ExpectedCreatedAt: &staleCreatedAt}))
1083+ require.Same(t, from, ac.currentSession())
1084+ releaseSecond()
1085+ d.sessWg.Wait()
1086+}
1087+
1088+func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T) {
1089+ t.Run("allocator exhaustion", func(t *testing.T) {
1090+ p, release := newBlockingPTY(t)
1091+ defer release()
1092+ store, state := newMockStore(t)
1093+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
1094+ WithStore(store)(d)
1095+ d.lastAllocatedCreatedAt = math.MaxInt64
1096+
1097+ sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1098+ require.NoError(t, err)
1099+ require.EqualError(t, d.renameSession(sess, "named"), "daemon: lifecycle identities exhausted")
1100+ require.Equal(t, "0", sess.name)
1101+ require.True(t, sess.ephemeral)
1102+ require.Zero(t, sess.createdAt)
1103+ require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt)
1104+ require.False(t, state.has("named"))
1105+ })
1106+
1107+ t.Run("persistence failure", func(t *testing.T) {
1108+ p, release := newBlockingPTY(t)
1109+ defer release()
1110+ store := portsmocks.NewMockStore(t)
1111+ var attempted map[string][]byte
1112+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
1113+ attempted = map[string][]byte{string(key): append([]byte(nil), value...)}
1114+ return errors.New("disk full")
1115+ }).Once()
1116+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
1117+ d := newTestDaemon(t, newFactory(t, p), clock)
1118+ WithStore(store)(d)
1119+
1120+ sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1121+ require.NoError(t, err)
1122+ require.EqualError(t, d.renameSession(sess, "named"), "disk full")
1123+ require.Equal(t, "0", sess.name)
1124+ require.True(t, sess.ephemeral)
1125+ require.Zero(t, sess.createdAt)
1126+ require.Equal(t, int64(100), d.lastAllocatedCreatedAt, "a failed durable write may be ambiguous, so its lifecycle identity remains reserved")
1127+ records, err := persist.New(newStaticStore(attempted)).LoadAll()
1128+ require.NoError(t, err)
1129+ require.Len(t, records, 1)
1130+ require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity")
1131+
1132+ store.EXPECT().Set(mock.Anything, mock.Anything).Return(nil).Once()
1133+ store.EXPECT().Sync().Return(nil).Once()
1134+ require.NoError(t, d.renameSession(sess, "named"))
1135+ require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion")
1136+ })
1137+}
1138+
1032 func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {1139 func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
1033 sz := domain.Size{Cols: 80, Rows: 24}1140 sz := domain.Size{Cols: 80, Rows: 24}
1034 p, release := newBlockingPTY(t)1141 p, release := newBlockingPTY(t)
@@ -1498,3 +1605,144 @@ func TestShellFromEnvironmentFallsBackOnlyWhenAbsentOrEmpty(t *testing.T) {
1498 })1605 })
1499 }1606 }
1500 }1607 }
1608+
1609+type lifecycleClock struct {
1610+ nows []time.Time
1611+ next int
1612+}
1613+
1614+func (c *lifecycleClock) Now() time.Time {
1615+ i := min(c.next, len(c.nows)-1)
1616+ c.next++
1617+ return c.nows[i]
1618+}
1619+func (*lifecycleClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
1620+
1621+func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *testing.T) {
1622+ ptys := make([]ports.PTY, 3)
1623+ var releases []func()
1624+ for i := range ptys {
1625+ p, release := newBlockingPTY(t)
1626+ ptys[i] = p
1627+ releases = append(releases, release)
1628+ }
1629+ defer releaseAll(releases)
1630+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100), time.Unix(0, 50)}}
1631+ d := newTestDaemon(t, newFactorySeq(t, ptys...), clock)
1632+ sz := domain.Size{Cols: 80, Rows: 24}
1633+
1634+ var got []int64
1635+ for _, name := range []string{"one", "two", "three"} {
1636+ sess, err := d.createSessionLocked(name, false, "/tmp", sz, terminalEnv{}, d.baseEnv)
1637+ require.NoError(t, err)
1638+ got = append(got, sess.createdAt)
1639+ }
1640+ require.Equal(t, []int64{100, 101, 102}, got)
1641+}
1642+
1643+func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T) {
1644+ d := newTestDaemon(t, nil, stubClock{})
1645+ d.lastAllocatedCreatedAt = math.MaxInt64
1646+ d.nextID = 17
1647+ d.stopped["retained"] = stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}
1648+
1649+ sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1650+
1651+ require.ErrorContains(t, err, "lifecycle identities exhausted")
1652+ require.Nil(t, sess)
1653+ require.Empty(t, d.sessions)
1654+ require.Equal(t, uint64(17), d.nextID)
1655+ require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt)
1656+ require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"])
1657+
1658+ _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil)
1659+ require.ErrorContains(t, err, "lifecycle identities exhausted")
1660+ require.Empty(t, d.sessions)
1661+ require.Equal(t, uint64(17), d.nextID)
1662+ require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"])
1663+}
1664+
1665+func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
1666+ store, _ := newMockStore(t)
1667+ seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1668+ require.NoError(t, seed.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))
1669+
1670+ p, release := newBlockingPTY(t)
1671+ defer release()
1672+ clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}}
1673+ // Constructing with persistence must establish the lifecycle high-water mark.
1674+ d := New(newFactory(t, p), clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1675+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
1676+ t.Cleanup(d.serveCancel)
1677+
1678+ sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1679+ require.NoError(t, err)
1680+ require.Equal(t, int64(901), sess.createdAt)
1681+}
1682+
1683+func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testing.T) {
1684+ fromPTY, releaseFrom := newBlockingPTY(t)
1685+ targetPTY, releaseTarget := newBlockingPTY(t)
1686+ defer releaseFrom()
1687+ defer releaseTarget()
1688+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1689+ store, state := newMockStore(t)
1690+ WithStore(store)(d)
1691+ d.ptys = newFactory(t, targetPTY)
1692+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 77}
1693+
1694+ require.True(t, d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "stopped", Stopped: true}))
1695+ resumed := ac.currentSession()
1696+ require.Equal(t, int64(77), resumed.createdAt)
1697+ require.Equal(t, int64(77), state.record(t, "stopped").CreatedAt)
1698+}
1699+
1700+func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing.T) {
1701+ t.Run("active replacement is rejected", func(t *testing.T) {
1702+ d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1703+ defer releaseAll(releases)
1704+ target := d.sessions["recent"]
1705+ target.createdAt = 22
1706+ expected := int64(21)
1707+
1708+ require.False(t, d.switchToTarget(from, ac, picker.Target{Session: target.id, Name: "recent", TabIndex: 0, ExpectedCreatedAt: &expected}))
1709+ require.Same(t, from, ac.currentSession())
1710+ })
1711+
1712+ t.Run("active target that stopped resumes same lifecycle", func(t *testing.T) {
1713+ fromPTY, releaseFrom := newBlockingPTY(t)
1714+ targetPTY, releaseTarget := newBlockingPTY(t)
1715+ defer releaseFrom()
1716+ defer releaseTarget()
1717+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1718+ d.ptys = newFactory(t, targetPTY)
1719+ expected := int64(31)
1720+ d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: expected}
1721+
1722+ require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "old-active-id", Name: "target", TabIndex: 0, ExpectedCreatedAt: &expected}))
1723+ require.Equal(t, "target", ac.currentSession().name)
1724+ require.Equal(t, expected, ac.currentSession().createdAt)
1725+ })
1726+
1727+ t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) {
1728+ d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1729+ defer releaseAll(releases)
1730+ target := d.sessions["recent"]
1731+ target.createdAt = 41
1732+ expected := int64(41)
1733+
1734+ require.True(t, d.switchToTarget(from, ac, picker.Target{Session: "stopped:recent", Name: "recent", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1735+ require.Same(t, target, ac.currentSession())
1736+ })
1737+
1738+ t.Run("stopped deletion and recreation is rejected", func(t *testing.T) {
1739+ fromPTY, releaseFrom := newBlockingPTY(t)
1740+ defer releaseFrom()
1741+ d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
1742+ d.stopped["target"] = stoppedSession{name: "target", cwd: "/tmp", createdAt: 52}
1743+ expected := int64(51)
1744+
1745+ require.False(t, d.switchToTarget(from, ac, picker.Target{Name: "target", TabIndex: 0, Stopped: true, ExpectedCreatedAt: &expected}))
1746+ require.Same(t, from, ac.currentSession())
1747+ })
1748+}
M internal/usecase/palette/action.go +21 -12
@@ -13,16 +13,17 @@ type Action struct {
1313 Args []string
1414 }
1515
16-// ParseAction recognizes an exact effective command token. It accepts outer
17-// and repeated whitespace but never treats a prefix or concatenated token as
18-// a command. Static commands reject arguments.
19-func ParseAction(commands []command.Command, input string) (Action, bool) {
16+// ParseAction recognizes an exact effective command result token. It accepts
17+// outer and repeated whitespace but never treats a prefix or concatenated token
18+// as a command. Static commands reject arguments.
19+func ParseAction(results []Result, input string) (Action, bool) {
2020 fields := strings.Fields(input)
2121 if len(fields) == 0 {
2222 return Action{}, false
2323 }
24- for _, cmd := range commands {
25- if !strings.EqualFold(fields[0], cmd.Code) {
24+ for _, result := range results {
25+ cmd, ok := result.Command()
26+ if !ok || !strings.EqualFold(fields[0], cmd.Code) {
2627 continue
2728 }
2829 args := append([]string(nil), fields[1:]...)
@@ -43,15 +44,23 @@ func ParseAction(commands []command.Command, input string) (Action, bool) {
4344
4445 // ArgumentCommand returns the exact argument-taking command whose token is
4546 // being typed, including the whitespace-before-argument state.
46-func ArgumentCommand(commands []command.Command, input string) (command.Command, bool) {
47+func ArgumentCommand(results []Result, input string) (command.Command, bool) {
48+ _, cmd, ok := argumentResult(results, input)
49+ return cmd, ok
50+}
51+
52+// argumentResult finds the exact argument-taking command and its source row in
53+// one pass so callers can reuse the immutable result without another scan.
54+func argumentResult(results []Result, input string) (Result, command.Command, bool) {
4755 fields := strings.Fields(input)
4856 if len(fields) == 0 {
49- return command.Command{}, false
57+ return Result{}, command.Command{}, false
5058 }
51- for _, cmd := range commands {
52- if cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
53- return cmd, true
59+ for _, result := range results {
60+ cmd, ok := result.Command()
61+ if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
62+ return result, cmd, true
5463 }
5564 }
56- return command.Command{}, false
65+ return Result{}, command.Command{}, false
5766 }
@@ -13,16 +13,17 @@ type Action struct {
13 Args []string13 Args []string
14 }14 }
15 15
16-// ParseAction recognizes an exact effective command token. It accepts outer16+// ParseAction recognizes an exact effective command result token. It accepts
17-// and repeated whitespace but never treats a prefix or concatenated token as17+// outer and repeated whitespace but never treats a prefix or concatenated token
18-// a command. Static commands reject arguments.18+// as a command. Static commands reject arguments.
19-func ParseAction(commands []command.Command, input string) (Action, bool) {19+func ParseAction(results []Result, input string) (Action, bool) {
20 fields := strings.Fields(input)20 fields := strings.Fields(input)
21 if len(fields) == 0 {21 if len(fields) == 0 {
22 return Action{}, false22 return Action{}, false
23 }23 }
24- for _, cmd := range commands {24+ for _, result := range results {
25- if !strings.EqualFold(fields[0], cmd.Code) {25+ cmd, ok := result.Command()
26+ if !ok || !strings.EqualFold(fields[0], cmd.Code) {
26 continue27 continue
27 }28 }
28 args := append([]string(nil), fields[1:]...)29 args := append([]string(nil), fields[1:]...)
@@ -43,15 +44,23 @@ func ParseAction(commands []command.Command, input string) (Action, bool) {
43 44
44 // ArgumentCommand returns the exact argument-taking command whose token is45 // ArgumentCommand returns the exact argument-taking command whose token is
45 // being typed, including the whitespace-before-argument state.46 // being typed, including the whitespace-before-argument state.
46-func ArgumentCommand(commands []command.Command, input string) (command.Command, bool) {47+func ArgumentCommand(results []Result, input string) (command.Command, bool) {
48+ _, cmd, ok := argumentResult(results, input)
49+ return cmd, ok
50+}
51+
52+// argumentResult finds the exact argument-taking command and its source row in
53+// one pass so callers can reuse the immutable result without another scan.
54+func argumentResult(results []Result, input string) (Result, command.Command, bool) {
47 fields := strings.Fields(input)55 fields := strings.Fields(input)
48 if len(fields) == 0 {56 if len(fields) == 0 {
49- return command.Command{}, false57+ return Result{}, command.Command{}, false
50 }58 }
51- for _, cmd := range commands {59+ for _, result := range results {
52- if cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {60+ cmd, ok := result.Command()
53- return cmd, true61+ if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
62+ return result, cmd, true
54 }63 }
55 }64 }
56- return command.Command{}, false65+ return Result{}, command.Command{}, false
57 }66 }
M internal/usecase/palette/action_test.go +16 -4
@@ -2,15 +2,16 @@ package palette
22
33 import (
44 "testing"
5+ "time"
56
67 "github.com/bnema/vev/internal/usecase/command"
78 )
89
910 func TestParseActionExactEffectiveToken(t *testing.T) {
10- commands := []command.Command{
11+ results := CommandResults([]command.Command{
1112 {Code: "BSK", Arguments: command.ArgumentsNone},
1213 {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},
13- }
14+ })
1415 tests := []struct {
1516 name string
1617 input string
@@ -27,7 +28,7 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
2728 }
2829 for _, tt := range tests {
2930 t.Run(tt.name, func(t *testing.T) {
30- action, ok := ParseAction(commands, tt.input)
31+ action, ok := ParseAction(results, tt.input)
3132 if ok != tt.ok {
3233 t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok)
3334 }
@@ -43,11 +44,22 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
4344
4445 func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
4546 cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
46- if got, ok := ArgumentCommand([]command.Command{cmd}, "JRS 1"); !ok || got.Code != "JRS" {
47+ if got, ok := ArgumentCommand(CommandResults([]command.Command{cmd}), "JRS 1"); !ok || got.Code != "JRS" {
4748 t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok)
4849 }
4950 }
5051
52+func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) {
53+ jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
54+ results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)}
55+
56+ result, got, ok := argumentResult(results, "jrs 1")
57+
58+ if !ok || result.Kind() != ResultKindCommand || got.Code != jrs.Code {
59+ t.Fatalf("argumentResult() = %#v, %#v, %v; want matching JRS row and command", result, got, ok)
60+ }
61+}
62+
5163 func sameStrings(a, b []string) bool {
5264 if len(a) != len(b) {
5365 return false
@@ -2,15 +2,16 @@ package palette
2 2
3 import (3 import (
4 "testing"4 "testing"
5+ "time"
5 6
6 "github.com/bnema/vev/internal/usecase/command"7 "github.com/bnema/vev/internal/usecase/command"
7 )8 )
8 9
9 func TestParseActionExactEffectiveToken(t *testing.T) {10 func TestParseActionExactEffectiveToken(t *testing.T) {
10- commands := []command.Command{11+ results := CommandResults([]command.Command{
11 {Code: "BSK", Arguments: command.ArgumentsNone},12 {Code: "BSK", Arguments: command.ArgumentsNone},
12 {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},13 {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},
13- }14+ })
14 tests := []struct {15 tests := []struct {
15 name string16 name string
16 input string17 input string
@@ -27,7 +28,7 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
27 }28 }
28 for _, tt := range tests {29 for _, tt := range tests {
29 t.Run(tt.name, func(t *testing.T) {30 t.Run(tt.name, func(t *testing.T) {
30- action, ok := ParseAction(commands, tt.input)31+ action, ok := ParseAction(results, tt.input)
31 if ok != tt.ok {32 if ok != tt.ok {
32 t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok)33 t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok)
33 }34 }
@@ -43,11 +44,22 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
43 44
44 func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {45 func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
45 cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}46 cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
46- if got, ok := ArgumentCommand([]command.Command{cmd}, "JRS 1"); !ok || got.Code != "JRS" {47+ if got, ok := ArgumentCommand(CommandResults([]command.Command{cmd}), "JRS 1"); !ok || got.Code != "JRS" {
47 t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok)48 t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok)
48 }49 }
49 }50 }
50 51
52+func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) {
53+ jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
54+ results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)}
55+
56+ result, got, ok := argumentResult(results, "jrs 1")
57+
58+ if !ok || result.Kind() != ResultKindCommand || got.Code != jrs.Code {
59+ t.Fatalf("argumentResult() = %#v, %#v, %v; want matching JRS row and command", result, got, ok)
60+ }
61+}
62+
51 func sameStrings(a, b []string) bool {63 func sameStrings(a, b []string) bool {
52 if len(a) != len(b) {64 if len(a) != len(b) {
53 return false65 return false
M internal/usecase/palette/fuzzy.go +56 -30
@@ -3,31 +3,36 @@ package palette
33 import (
44 "sort"
55 "strings"
6-
7- "github.com/bnema/vev/internal/usecase/command"
6+ "unicode/utf8"
87 )
98
9+// Match is a fuzzy-matched immutable palette result.
1010 type Match struct {
11- Command command.Command
11+ Result Result
1212 Positions []int
1313 rank int
1414 span int
1515 first int
1616 order int
17+ search string
1718 }
1819
19-func Fuzzy(commands []command.Command, query string) []Match {
20+// Fuzzy searches immutable palette results.
21+func Fuzzy(results []Result, query string) []Match {
22+ needle := strings.ToLower(query)
2023 if query == "" {
21- out := make([]Match, len(commands))
22- for i, cmd := range commands {
23- out[i] = Match{Command: cmd, order: i}
24+ out := make([]Match, 0, len(results))
25+ for i, result := range results {
26+ out = append(out, newMatch(result, i))
2427 }
2528 return out
2629 }
30+
31+ needleRunes := []rune(needle)
2732 var out []Match
28- for i, cmd := range commands {
29- if m, ok := score(cmd, query, i); ok {
30- out = append(out, m)
33+ for i, result := range results {
34+ if match, ok := score(result, needle, needleRunes, i); ok {
35+ out = append(out, match)
3136 }
3237 }
3338 sort.SliceStable(out, func(i, j int) bool {
@@ -41,41 +46,62 @@ func Fuzzy(commands []command.Command, query string) []Match {
4146 if a.first != b.first {
4247 return a.first < b.first
4348 }
44- if a.Command.Code != b.Command.Code {
45- return a.Command.Code < b.Command.Code
49+ if a.Result.Kind() != b.Result.Kind() {
50+ return a.Result.Kind() < b.Result.Kind()
51+ }
52+ if a.search != b.search {
53+ return a.search < b.search
4654 }
4755 return a.order < b.order
4856 })
4957 return out
5058 }
5159
52-func score(cmd command.Command, query string, order int) (Match, bool) {
53- code := []rune(cmd.Code)
54- q := []rune(query)
55- codeLower := strings.ToLower(cmd.Code)
56- qLower := strings.ToLower(query)
57- if codeLower == qLower {
58- return Match{Command: cmd, Positions: rangePositions(len(code)), rank: 0, span: len(code), first: 0, order: order}, true
60+func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) {
61+ match := newMatch(result, order)
62+ text := match.search
63+
64+ // An exact command shortcode always wins over every other match. The
65+ // remaining results then share exact/prefix/subsequence scoring.
66+ if result.Kind() == ResultKindCommand && text == needle {
67+ match.Positions = rangePositions(utf8.RuneCountInString(text))
68+ match.rank, match.span, match.first = 0, len(match.Positions), 0
69+ return match, true
5970 }
60- if strings.HasPrefix(codeLower, qLower) {
61- return Match{Command: cmd, Positions: rangePositions(len(q)), rank: 1, span: len(q), first: 0, order: order}, true
71+ if text == needle {
72+ match.Positions = rangePositions(utf8.RuneCountInString(text))
73+ match.rank, match.span, match.first = 1, len(match.Positions), 0
74+ return match, true
6275 }
63- if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {
64- return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
76+ if strings.HasPrefix(text, needle) {
77+ match.Positions = rangePositions(utf8.RuneCountInString(needle))
78+ match.rank, match.span, match.first = 2, len(match.Positions), 0
79+ return match, true
6580 }
66- text := strings.ToLower(cmd.Desc)
67- if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {
68- return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
81+ if positions, ok := subsequencePositions([]rune(text), needleRunes); ok {
82+ match.Positions = positions
83+ match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0]
84+ return match, true
85+ }
86+ if cmd, ok := result.Command(); ok {
87+ if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
88+ match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0]
89+ return match, true
90+ }
6991 }
7092 return Match{}, false
7193 }
7294
95+func newMatch(result Result, order int) Match {
96+ return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}
97+}
98+
7399 func rangePositions(n int) []int {
74- p := make([]int, n)
75- for i := range p {
76- p[i] = i
100+ positions := make([]int, n)
101+ for i := range positions {
102+ positions[i] = i
77103 }
78- return p
104+ return positions
79105 }
80106
81107 func subsequencePositions(haystack, needle []rune) ([]int, bool) {
@@ -3,31 +3,36 @@ package palette
3 import (3 import (
4 "sort"4 "sort"
5 "strings"5 "strings"
6-6+ "unicode/utf8"
7- "github.com/bnema/vev/internal/usecase/command"
8 )7 )
9 8
9+// Match is a fuzzy-matched immutable palette result.
10 type Match struct {10 type Match struct {
11- Command command.Command11+ Result Result
12 Positions []int12 Positions []int
13 rank int13 rank int
14 span int14 span int
15 first int15 first int
16 order int16 order int
17+ search string
17 }18 }
18 19
19-func Fuzzy(commands []command.Command, query string) []Match {20+// Fuzzy searches immutable palette results.
21+func Fuzzy(results []Result, query string) []Match {
22+ needle := strings.ToLower(query)
20 if query == "" {23 if query == "" {
21- out := make([]Match, len(commands))24+ out := make([]Match, 0, len(results))
22- for i, cmd := range commands {25+ for i, result := range results {
23- out[i] = Match{Command: cmd, order: i}26+ out = append(out, newMatch(result, i))
24 }27 }
25 return out28 return out
26 }29 }
30+
31+ needleRunes := []rune(needle)
27 var out []Match32 var out []Match
28- for i, cmd := range commands {33+ for i, result := range results {
29- if m, ok := score(cmd, query, i); ok {34+ if match, ok := score(result, needle, needleRunes, i); ok {
30- out = append(out, m)35+ out = append(out, match)
31 }36 }
32 }37 }
33 sort.SliceStable(out, func(i, j int) bool {38 sort.SliceStable(out, func(i, j int) bool {
@@ -41,41 +46,62 @@ func Fuzzy(commands []command.Command, query string) []Match {
41 if a.first != b.first {46 if a.first != b.first {
42 return a.first < b.first47 return a.first < b.first
43 }48 }
44- if a.Command.Code != b.Command.Code {49+ if a.Result.Kind() != b.Result.Kind() {
45- return a.Command.Code < b.Command.Code50+ return a.Result.Kind() < b.Result.Kind()
51+ }
52+ if a.search != b.search {
53+ return a.search < b.search
46 }54 }
47 return a.order < b.order55 return a.order < b.order
48 })56 })
49 return out57 return out
50 }58 }
51 59
52-func score(cmd command.Command, query string, order int) (Match, bool) {60+func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) {
53- code := []rune(cmd.Code)61+ match := newMatch(result, order)
54- q := []rune(query)62+ text := match.search
55- codeLower := strings.ToLower(cmd.Code)63+
56- qLower := strings.ToLower(query)64+ // An exact command shortcode always wins over every other match. The
57- if codeLower == qLower {65+ // remaining results then share exact/prefix/subsequence scoring.
58- return Match{Command: cmd, Positions: rangePositions(len(code)), rank: 0, span: len(code), first: 0, order: order}, true66+ if result.Kind() == ResultKindCommand && text == needle {
67+ match.Positions = rangePositions(utf8.RuneCountInString(text))
68+ match.rank, match.span, match.first = 0, len(match.Positions), 0
69+ return match, true
59 }70 }
60- if strings.HasPrefix(codeLower, qLower) {71+ if text == needle {
61- return Match{Command: cmd, Positions: rangePositions(len(q)), rank: 1, span: len(q), first: 0, order: order}, true72+ match.Positions = rangePositions(utf8.RuneCountInString(text))
73+ match.rank, match.span, match.first = 1, len(match.Positions), 0
74+ return match, true
62 }75 }
63- if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {76+ if strings.HasPrefix(text, needle) {
64- return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true77+ match.Positions = rangePositions(utf8.RuneCountInString(needle))
78+ match.rank, match.span, match.first = 2, len(match.Positions), 0
79+ return match, true
65 }80 }
66- text := strings.ToLower(cmd.Desc)81+ if positions, ok := subsequencePositions([]rune(text), needleRunes); ok {
67- if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {82+ match.Positions = positions
68- return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true83+ match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0]
84+ return match, true
85+ }
86+ if cmd, ok := result.Command(); ok {
87+ if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
88+ match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0]
89+ return match, true
90+ }
69 }91 }
70 return Match{}, false92 return Match{}, false
71 }93 }
72 94
95+func newMatch(result Result, order int) Match {
96+ return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}
97+}
98+
73 func rangePositions(n int) []int {99 func rangePositions(n int) []int {
74- p := make([]int, n)100+ positions := make([]int, n)
75- for i := range p {101+ for i := range positions {
76- p[i] = i102+ positions[i] = i
77 }103 }
78- return p104+ return positions
79 }105 }
80 106
81 func subsequencePositions(haystack, needle []rune) ([]int, bool) {107 func subsequencePositions(haystack, needle []rune) ([]int, bool) {
M internal/usecase/palette/fuzzy_test.go +105 -10
@@ -2,7 +2,9 @@ package palette
22
33 import (
44 "testing"
5+ "time"
56
7+ "github.com/bnema/vev/internal/domain"
68 "github.com/bnema/vev/internal/usecase/command"
79 "github.com/stretchr/testify/require"
810 )
@@ -14,52 +16,56 @@ func cmd(code, name, desc string) command.Command {
1416 func codes(matches []Match) []string {
1517 out := make([]string, len(matches))
1618 for i, match := range matches {
17- out[i] = match.Command.Code
19+ cmd, ok := match.Result.Command()
20+ if !ok {
21+ continue
22+ }
23+ out[i] = cmd.Code
1824 }
1925 return out
2026 }
2127
2228 func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {
23- matches := Fuzzy([]command.Command{
29+ matches := Fuzzy(CommandResults([]command.Command{
2430 cmd("ZQQ", "unused", "alpha beta"),
2531 cmd("XAB", "unused", "subsequence code"),
2632 cmd("ABX", "unused", "prefix code"),
2733 cmd("AB", "unused", "exact code"),
28- }, "ab")
34+ }), "ab")
2935
3036 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
3137 require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")
3238 }
3339
3440 func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {
35- matches := Fuzzy([]command.Command{
41+ matches := Fuzzy(CommandResults([]command.Command{
3642 cmd("CPY", "Create Alpha Beta", "Enter copy mode"),
3743 cmd("ZQQ", "unused", "Alpha Beta tools"),
38- }, "ab")
44+ }), "ab")
3945
4046 require.Equal(t, []string{"ZQQ"}, codes(matches))
4147 }
4248
4349 func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {
44- matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy")
50+ matches := Fuzzy(CommandResults([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}), "copy")
4551
4652 require.Len(t, matches, 1)
4753 require.Empty(t, matches[0].Positions)
4854 }
4955
5056 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
51- matches := Fuzzy([]command.Command{
57+ matches := Fuzzy(CommandResults([]command.Command{
5258 cmd("ZAXQB", "", ""), // span 3, first 1
5359 cmd("BAXQ", "", ""), // span 3, first 1, code sorts first
5460 cmd("PXAQ", "", ""), // span 2, first 2
5561 cmd("BAQ", "", ""), // span 2, first 0
56- }, "aq")
62+ }), "aq")
5763
5864 require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches))
5965 }
6066
6167 func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
62- matches := Fuzzy([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}, "cy")
68+ matches := Fuzzy(CommandResults([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}), "cy")
6369
6470 require.Len(t, matches, 1)
6571 require.Equal(t, []int{0, 2}, matches[0].Positions)
@@ -67,7 +73,96 @@ func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
6773
6874 func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
6975 commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")}
70- matches := Fuzzy(commands, "")
76+ matches := Fuzzy(CommandResults(commands), "")
7177
7278 require.Equal(t, []string{"B", "A"}, codes(matches))
7379 }
80+
81+func TestFuzzyOrdersMixedResults(t *testing.T) {
82+ created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
83+ tests := []struct {
84+ name string
85+ results []Result
86+ query string
87+ wantText []string
88+ wantKinds []ResultKind
89+ wantPositions [][]int
90+ wantActiveIDs map[int]domain.SessionID
91+ }{
92+ {
93+ name: "command shortcode precedes sessions and description matches",
94+ results: []Result{
95+ NewStoppedSessionResult("work", created),
96+ NewCommandResult(cmd("WORK", "", "Create a workspace")),
97+ NewActiveSessionResult("work", created, domain.SessionID("work-id")),
98+ NewCommandResult(cmd("WQORRK", "", "")),
99+ NewCommandResult(cmd("ZZZ", "", "work tools")),
100+ },
101+ query: "work",
102+ wantText: []string{"WORK", "work", "work", "WQORRK", "ZZZ"},
103+ wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand},
104+ wantPositions: [][]int{{0, 1, 2, 3}, {0, 1, 2, 3}, {0, 1, 2, 3}, {0, 2, 3, 5}, nil},
105+ },
106+ {
107+ name: "equivalent sessions sort by normalized text",
108+ results: []Result{
109+ NewStoppedSessionResult("aBravo", time.Time{}),
110+ NewStoppedSessionResult("aAlpha", time.Time{}),
111+ },
112+ query: "a",
113+ wantText: []string{"aAlpha", "aBravo"},
114+ wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession},
115+ wantPositions: [][]int{{0}, {0}},
116+ },
117+ {
118+ name: "kind then normalized text then stable order",
119+ results: []Result{
120+ NewStoppedSessionResult("aBravo", time.Time{}),
121+ NewStoppedSessionResult("aAlpha", time.Time{}),
122+ NewActiveSessionResult("aZulu", time.Time{}, "z"),
123+ NewActiveSessionResult("aEcho", time.Time{}, "e"),
124+ NewActiveSessionResult("aEcho", time.Time{}, "e2"),
125+ NewCommandResult(cmd("AX", "", "")),
126+ },
127+ query: "a",
128+ wantText: []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"},
129+ wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession},
130+ wantPositions: [][]int{{0}, {0}, {0}, {0}, {0}, {0}},
131+ wantActiveIDs: map[int]domain.SessionID{1: "e", 2: "e2", 3: "z"},
132+ },
133+ }
134+
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ matches := Fuzzy(tt.results, tt.query)
138+ require.Equal(t, tt.wantText, matchSearchText(matches))
139+ require.Equal(t, tt.wantKinds, matchKinds(matches))
140+ positions := make([][]int, len(matches))
141+ for i := range matches {
142+ positions[i] = matches[i].Positions
143+ }
144+ require.Equal(t, tt.wantPositions, positions)
145+ for index, wantID := range tt.wantActiveIDs {
146+ gotID, ok := matches[index].Result.SessionID()
147+ require.True(t, ok)
148+ require.Equal(t, wantID, gotID)
149+ }
150+ })
151+ }
152+}
153+
154+func matchSearchText(matches []Match) []string {
155+ out := make([]string, len(matches))
156+ for i, match := range matches {
157+ out[i] = match.Result.SearchText()
158+ }
159+ return out
160+}
161+
162+func matchKinds(matches []Match) []ResultKind {
163+ out := make([]ResultKind, len(matches))
164+ for i, match := range matches {
165+ out[i] = match.Result.Kind()
166+ }
167+ return out
168+}
@@ -2,7 +2,9 @@ package palette
2 2
3 import (3 import (
4 "testing"4 "testing"
5+ "time"
5 6
7+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
7 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
8 )10 )
@@ -14,52 +16,56 @@ func cmd(code, name, desc string) command.Command {
14 func codes(matches []Match) []string {16 func codes(matches []Match) []string {
15 out := make([]string, len(matches))17 out := make([]string, len(matches))
16 for i, match := range matches {18 for i, match := range matches {
17- out[i] = match.Command.Code19+ cmd, ok := match.Result.Command()
20+ if !ok {
21+ continue
22+ }
23+ out[i] = cmd.Code
18 }24 }
19 return out25 return out
20 }26 }
21 27
22 func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {28 func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {
23- matches := Fuzzy([]command.Command{29+ matches := Fuzzy(CommandResults([]command.Command{
24 cmd("ZQQ", "unused", "alpha beta"),30 cmd("ZQQ", "unused", "alpha beta"),
25 cmd("XAB", "unused", "subsequence code"),31 cmd("XAB", "unused", "subsequence code"),
26 cmd("ABX", "unused", "prefix code"),32 cmd("ABX", "unused", "prefix code"),
27 cmd("AB", "unused", "exact code"),33 cmd("AB", "unused", "exact code"),
28- }, "ab")34+ }), "ab")
29 35
30 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))36 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
31 require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")37 require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")
32 }38 }
33 39
34 func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {40 func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {
35- matches := Fuzzy([]command.Command{41+ matches := Fuzzy(CommandResults([]command.Command{
36 cmd("CPY", "Create Alpha Beta", "Enter copy mode"),42 cmd("CPY", "Create Alpha Beta", "Enter copy mode"),
37 cmd("ZQQ", "unused", "Alpha Beta tools"),43 cmd("ZQQ", "unused", "Alpha Beta tools"),
38- }, "ab")44+ }), "ab")
39 45
40 require.Equal(t, []string{"ZQQ"}, codes(matches))46 require.Equal(t, []string{"ZQQ"}, codes(matches))
41 }47 }
42 48
43 func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {49 func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {
44- matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy")50+ matches := Fuzzy(CommandResults([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}), "copy")
45 51
46 require.Len(t, matches, 1)52 require.Len(t, matches, 1)
47 require.Empty(t, matches[0].Positions)53 require.Empty(t, matches[0].Positions)
48 }54 }
49 55
50 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {56 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
51- matches := Fuzzy([]command.Command{57+ matches := Fuzzy(CommandResults([]command.Command{
52 cmd("ZAXQB", "", ""), // span 3, first 158 cmd("ZAXQB", "", ""), // span 3, first 1
53 cmd("BAXQ", "", ""), // span 3, first 1, code sorts first59 cmd("BAXQ", "", ""), // span 3, first 1, code sorts first
54 cmd("PXAQ", "", ""), // span 2, first 260 cmd("PXAQ", "", ""), // span 2, first 2
55 cmd("BAQ", "", ""), // span 2, first 061 cmd("BAQ", "", ""), // span 2, first 0
56- }, "aq")62+ }), "aq")
57 63
58 require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches))64 require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches))
59 }65 }
60 66
61 func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {67 func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
62- matches := Fuzzy([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}, "cy")68+ matches := Fuzzy(CommandResults([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}), "cy")
63 69
64 require.Len(t, matches, 1)70 require.Len(t, matches, 1)
65 require.Equal(t, []int{0, 2}, matches[0].Positions)71 require.Equal(t, []int{0, 2}, matches[0].Positions)
@@ -67,7 +73,96 @@ func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
67 73
68 func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {74 func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
69 commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")}75 commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")}
70- matches := Fuzzy(commands, "")76+ matches := Fuzzy(CommandResults(commands), "")
71 77
72 require.Equal(t, []string{"B", "A"}, codes(matches))78 require.Equal(t, []string{"B", "A"}, codes(matches))
73 }79 }
80+
81+func TestFuzzyOrdersMixedResults(t *testing.T) {
82+ created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
83+ tests := []struct {
84+ name string
85+ results []Result
86+ query string
87+ wantText []string
88+ wantKinds []ResultKind
89+ wantPositions [][]int
90+ wantActiveIDs map[int]domain.SessionID
91+ }{
92+ {
93+ name: "command shortcode precedes sessions and description matches",
94+ results: []Result{
95+ NewStoppedSessionResult("work", created),
96+ NewCommandResult(cmd("WORK", "", "Create a workspace")),
97+ NewActiveSessionResult("work", created, domain.SessionID("work-id")),
98+ NewCommandResult(cmd("WQORRK", "", "")),
99+ NewCommandResult(cmd("ZZZ", "", "work tools")),
100+ },
101+ query: "work",
102+ wantText: []string{"WORK", "work", "work", "WQORRK", "ZZZ"},
103+ wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand},
104+ wantPositions: [][]int{{0, 1, 2, 3}, {0, 1, 2, 3}, {0, 1, 2, 3}, {0, 2, 3, 5}, nil},
105+ },
106+ {
107+ name: "equivalent sessions sort by normalized text",
108+ results: []Result{
109+ NewStoppedSessionResult("aBravo", time.Time{}),
110+ NewStoppedSessionResult("aAlpha", time.Time{}),
111+ },
112+ query: "a",
113+ wantText: []string{"aAlpha", "aBravo"},
114+ wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession},
115+ wantPositions: [][]int{{0}, {0}},
116+ },
117+ {
118+ name: "kind then normalized text then stable order",
119+ results: []Result{
120+ NewStoppedSessionResult("aBravo", time.Time{}),
121+ NewStoppedSessionResult("aAlpha", time.Time{}),
122+ NewActiveSessionResult("aZulu", time.Time{}, "z"),
123+ NewActiveSessionResult("aEcho", time.Time{}, "e"),
124+ NewActiveSessionResult("aEcho", time.Time{}, "e2"),
125+ NewCommandResult(cmd("AX", "", "")),
126+ },
127+ query: "a",
128+ wantText: []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"},
129+ wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession},
130+ wantPositions: [][]int{{0}, {0}, {0}, {0}, {0}, {0}},
131+ wantActiveIDs: map[int]domain.SessionID{1: "e", 2: "e2", 3: "z"},
132+ },
133+ }
134+
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ matches := Fuzzy(tt.results, tt.query)
138+ require.Equal(t, tt.wantText, matchSearchText(matches))
139+ require.Equal(t, tt.wantKinds, matchKinds(matches))
140+ positions := make([][]int, len(matches))
141+ for i := range matches {
142+ positions[i] = matches[i].Positions
143+ }
144+ require.Equal(t, tt.wantPositions, positions)
145+ for index, wantID := range tt.wantActiveIDs {
146+ gotID, ok := matches[index].Result.SessionID()
147+ require.True(t, ok)
148+ require.Equal(t, wantID, gotID)
149+ }
150+ })
151+ }
152+}
153+
154+func matchSearchText(matches []Match) []string {
155+ out := make([]string, len(matches))
156+ for i, match := range matches {
157+ out[i] = match.Result.SearchText()
158+ }
159+ return out
160+}
161+
162+func matchKinds(matches []Match) []ResultKind {
163+ out := make([]ResultKind, len(matches))
164+ for i, match := range matches {
165+ out[i] = match.Result.Kind()
166+ }
167+ return out
168+}
M internal/usecase/palette/model.go +123 -71
@@ -2,6 +2,7 @@ package palette
22
33 import (
44 "unicode"
5+ "unicode/utf8"
56
67 "github.com/bnema/vev/internal/domain"
78 "github.com/bnema/vev/internal/usecase/command"
@@ -20,23 +21,32 @@ type RenderStyles struct {
2021 type RenderOptions struct {
2122 Styles RenderStyles
2223 Guidance string
24+ // Feedback is interaction-scoped daemon feedback. It replaces the selected
25+ // row's contextual detail without adding a searchable result or row.
26+ Feedback string
2327 }
2428
2529 type Model struct {
26- commands []command.Command
27- input ui.TextInput
28- matches []Match
29- selected int
30- scroll int
30+ results []Result
31+ argumentResults []Result
32+ input ui.TextInput
33+ matches []Match
34+ selected int
35+ scroll int
3136 }
3237
33-func New(commands []command.Command) *Model {
34- m := &Model{commands: append([]command.Command(nil), commands...)}
38+// New accepts typed immutable palette results.
39+func New(results []Result) *Model {
40+ copied := append([]Result(nil), results...)
41+ m := &Model{
42+ results: copied,
43+ argumentResults: requiredArgumentResults(copied),
44+ }
3545 m.refresh()
3646 return m
3747 }
3848
39-func NewRegistry() *Model { return New(command.Registry()) }
49+func NewRegistry() *Model { return New(CommandResults(command.Registry())) }
4050
4151 func DefaultRenderStyles() RenderStyles {
4252 selection := renderer.DefaultStyle()
@@ -49,16 +59,14 @@ func DefaultRenderStyles() RenderStyles {
4959 func (m *Model) Insert(r rune) {
5060 if m != nil {
5161 m.input.Insert(r)
52- m.selected = 0
53- m.scroll = 0
62+ m.selected, m.scroll = 0, 0
5463 m.refresh()
5564 }
5665 }
5766 func (m *Model) Backspace() {
5867 if m != nil && m.input.Value() != "" {
5968 m.input.Backspace()
60- m.selected = 0
61- m.scroll = 0
69+ m.selected, m.scroll = 0, 0
6270 m.refresh()
6371 }
6472 }
@@ -81,26 +89,32 @@ func (m *Model) Down() {
8189 m.clamp()
8290 }
8391 }
84-func (m *Model) Selected() (command.Command, bool) {
92+
93+// Selected returns the typed immutable target, not an executable command.
94+func (m *Model) Selected() (Result, bool) {
8595 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
86- return command.Command{}, false
96+ return Result{}, false
8797 }
88- return m.matches[m.selected].Command, true
98+ return m.matches[m.selected].Result, true
8999 }
90100
91-// CompleteSelected replaces the query with the selected command's effective
92-// code. Required-argument commands retain the typed argument bytes.
101+// CompleteSelected only completes static command results. Session targets can
102+// never be parsed, completed, or otherwise treated as commands.
93103 func (m *Model) CompleteSelected() bool {
94104 selected, ok := m.Selected()
95105 if !ok {
96106 return false
97107 }
108+ cmd, ok := selected.Command()
109+ if !ok {
110+ return false
111+ }
98112
99113 query := m.Query()
100- completed := selected.Code
101- if selected.Arguments == command.ArgumentsRequired {
114+ completed := cmd.Code
115+ if cmd.Arguments == command.ArgumentsRequired {
102116 token, args, hasSeparator := completionParts(query)
103- if token == selected.Code && hasSeparator && args != "" {
117+ if token == cmd.Code && hasSeparator && args != "" {
104118 return false
105119 }
106120 completed += " "
@@ -113,8 +127,7 @@ func (m *Model) CompleteSelected() bool {
113127 }
114128
115129 m.input.SetValue(completed)
116- m.selected = 0
117- m.scroll = 0
130+ m.selected, m.scroll = 0, 0
118131 m.refresh()
119132 return true
120133 }
@@ -136,12 +149,12 @@ func completionParts(query string) (token, args string, hasSeparator bool) {
136149 return query, "", false
137150 }
138151
139-// ArgumentCommand returns the exact argument-taking command being entered.
152+// ArgumentCommand returns the exact argument-taking static command being entered.
140153 func (m *Model) ArgumentCommand() (command.Command, bool) {
141154 if m == nil {
142155 return command.Command{}, false
143156 }
144- return ArgumentCommand(m.commands, m.Query())
157+ return ArgumentCommand(m.results, m.Query())
145158 }
146159
147160 func (m *Model) Matches() []Match {
@@ -158,30 +171,39 @@ func (m *Model) Matches() []Match {
158171
159172 func (m *Model) refresh() {
160173 query := m.input.Value()
161- m.matches = Fuzzy(m.commands, query)
162- // Once an argument-taking token is exact, retain its row while its
174+ m.matches = Fuzzy(m.results, query)
175+ // Once an argument-taking static token is exact, retain its row while its
163176 // arguments make ordinary fuzzy matching inapplicable.
164- if cmd, ok := ArgumentCommand(m.commands, query); ok {
165- m.prependMatch(cmd)
177+ if result, _, ok := argumentResult(m.results, query); ok {
178+ m.prependMatch(result)
166179 } else if len(m.matches) == 0 {
167180 // Arguments are not part of static command matching. When they would
168181 // otherwise clear the list, keep required-argument candidates matched by
169182 // the partial first token so Tab can complete them.
170183 token, _, hasSeparator := completionParts(query)
171184 if hasSeparator {
172- for _, match := range Fuzzy(m.commands, token) {
173- if match.Command.Arguments == command.ArgumentsRequired {
174- m.matches = append(m.matches, match)
175- }
176- }
185+ m.matches = Fuzzy(m.argumentResults, token)
177186 }
178187 }
179188 m.clamp()
180189 }
181190
182-func (m *Model) prependMatch(cmd command.Command) {
191+func requiredArgumentResults(results []Result) []Result {
192+ commands := make([]Result, 0, len(results))
193+ for _, result := range results {
194+ cmd, ok := result.Command()
195+ if ok && cmd.Arguments == command.ArgumentsRequired {
196+ commands = append(commands, result)
197+ }
198+ }
199+ return commands
200+}
201+
202+func (m *Model) prependMatch(result Result) {
203+ cmd, _ := result.Command()
183204 for i, match := range m.matches {
184- if match.Command.Code != cmd.Code {
205+ candidate, ok := match.Result.Command()
206+ if !ok || candidate.Code != cmd.Code {
185207 continue
186208 }
187209 if i > 0 {
@@ -190,12 +212,11 @@ func (m *Model) prependMatch(cmd command.Command) {
190212 }
191213 return
192214 }
193- m.matches = append([]Match{{Command: cmd}}, m.matches...)
215+ m.matches = append([]Match{newMatch(result, 0)}, m.matches...)
194216 }
195217 func (m *Model) clamp() {
196218 if len(m.matches) == 0 {
197- m.selected = -1
198- m.scroll = 0
219+ m.selected, m.scroll = -1, 0
199220 return
200221 }
201222 if m.selected < 0 {
@@ -218,21 +239,15 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
218239 if frame.Width == 0 || frame.Height == 0 {
219240 return frame
220241 }
221- base := renderer.DefaultStyle()
222- selection := styles.Selection
223- desc := styles.Description
242+ base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description
224243 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
225244 if m == nil {
226245 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
227246 return frame
228247 }
229248 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
230- start := 1
231- visible := frame.Height - start
232- if visible <= 0 {
233- return frame
234- }
235- if len(m.matches) == 0 {
249+ start, visible := 1, frame.Height-1
250+ if visible <= 0 || len(m.matches) == 0 {
236251 return frame
237252 }
238253 if m.selected < m.scroll {
@@ -243,8 +258,8 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
243258 }
244259 codeWidth := 0
245260 for _, match := range m.matches {
246- if len([]rune(match.Command.Code)) > codeWidth {
247- codeWidth = len([]rune(match.Command.Code))
261+ if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
262+ codeWidth = utf8.RuneCountInString(cmd.Code)
248263 }
249264 }
250265 activeCmd, activeOK := m.ArgumentCommand()
@@ -259,34 +274,71 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
259274 style = selection
260275 }
261276 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
262- x := 0
263- highlight := map[int]bool{}
264- for _, p := range match.Positions {
265- highlight[p] = true
277+ if cmd, ok := match.Result.Command(); ok {
278+ m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
279+ continue
266280 }
267- for i, r := range []rune(match.Command.Code) {
268- cellStyle := style
281+ m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
282+ }
283+ return frame
284+}
285+
286+func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {
287+ x, nextHighlight := 0, 0
288+ for _, r := range cmd.Code {
289+ cellStyle := style
290+ cellStyle.Bold = true
291+ if nextHighlight < len(positions) && positions[nextHighlight] == x {
292+ cellStyle = selection
269293 cellStyle.Bold = true
270- if highlight[i] {
271- cellStyle = selection
272- cellStyle.Bold = true
273- }
274- if x < frame.Width {
275- frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle})
276- }
277- x++
294+ nextHighlight++
278295 }
279- for x < codeWidth+1 && x < frame.Width {
280- frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style})
281- x++
296+ if x < frame.Width {
297+ frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
282298 }
283- description := match.Command.Desc
284- if opts.Guidance != "" && activeOK && match.Command.ContextHint != command.ContextHintNone && activeCmd.Code == match.Command.Code {
285- description = opts.Guidance
299+ x++
300+ }
301+ for x < codeWidth+1 && x < frame.Width {
302+ frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
303+ x++
304+ }
305+ description := cmd.Desc
306+ if feedback != "" && selected {
307+ description = feedback
308+ } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
309+ description = guidance
310+ }
311+ ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc))
312+}
313+
314+func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
315+ x, nextHighlight := 0, 0
316+ prefixWidth := utf8.RuneCountInString(match.Result.sessionDisplayPrefix())
317+ for _, r := range match.Result.DisplayText() {
318+ cellStyle := style
319+ if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x {
320+ cellStyle = selection
321+ cellStyle.Bold = true
322+ nextHighlight++
323+ }
324+ if x < frame.Width {
325+ frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
286326 }
287- ui.DrawText(frame, x, y+start, frame.Width, description, mergePaletteDescStyle(style, desc))
327+ x++
328+ }
329+ if feedback == "" || !selected {
330+ return
331+ }
332+ if x < frame.Width {
333+ frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
334+ }
335+ x++
336+ for _, r := range feedback {
337+ if x < frame.Width {
338+ frame.Set(x, y, renderer.Cell{Rune: r, Style: style})
339+ }
340+ x++
288341 }
289- return frame
290342 }
291343
292344 func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {
@@ -2,6 +2,7 @@ package palette
2 2
3 import (3 import (
4 "unicode"4 "unicode"
5+ "unicode/utf8"
5 6
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
@@ -20,23 +21,32 @@ type RenderStyles struct {
20 type RenderOptions struct {21 type RenderOptions struct {
21 Styles RenderStyles22 Styles RenderStyles
22 Guidance string23 Guidance string
24+ // Feedback is interaction-scoped daemon feedback. It replaces the selected
25+ // row's contextual detail without adding a searchable result or row.
26+ Feedback string
23 }27 }
24 28
25 type Model struct {29 type Model struct {
26- commands []command.Command30+ results []Result
27- input ui.TextInput31+ argumentResults []Result
28- matches []Match32+ input ui.TextInput
29- selected int33+ matches []Match
30- scroll int34+ selected int
35+ scroll int
31 }36 }
32 37
33-func New(commands []command.Command) *Model {38+// New accepts typed immutable palette results.
34- m := &Model{commands: append([]command.Command(nil), commands...)}39+func New(results []Result) *Model {
40+ copied := append([]Result(nil), results...)
41+ m := &Model{
42+ results: copied,
43+ argumentResults: requiredArgumentResults(copied),
44+ }
35 m.refresh()45 m.refresh()
36 return m46 return m
37 }47 }
38 48
39-func NewRegistry() *Model { return New(command.Registry()) }49+func NewRegistry() *Model { return New(CommandResults(command.Registry())) }
40 50
41 func DefaultRenderStyles() RenderStyles {51 func DefaultRenderStyles() RenderStyles {
42 selection := renderer.DefaultStyle()52 selection := renderer.DefaultStyle()
@@ -49,16 +59,14 @@ func DefaultRenderStyles() RenderStyles {
49 func (m *Model) Insert(r rune) {59 func (m *Model) Insert(r rune) {
50 if m != nil {60 if m != nil {
51 m.input.Insert(r)61 m.input.Insert(r)
52- m.selected = 062+ m.selected, m.scroll = 0, 0
53- m.scroll = 0
54 m.refresh()63 m.refresh()
55 }64 }
56 }65 }
57 func (m *Model) Backspace() {66 func (m *Model) Backspace() {
58 if m != nil && m.input.Value() != "" {67 if m != nil && m.input.Value() != "" {
59 m.input.Backspace()68 m.input.Backspace()
60- m.selected = 069+ m.selected, m.scroll = 0, 0
61- m.scroll = 0
62 m.refresh()70 m.refresh()
63 }71 }
64 }72 }
@@ -81,26 +89,32 @@ func (m *Model) Down() {
81 m.clamp()89 m.clamp()
82 }90 }
83 }91 }
84-func (m *Model) Selected() (command.Command, bool) {92+
93+// Selected returns the typed immutable target, not an executable command.
94+func (m *Model) Selected() (Result, bool) {
85 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {95 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
86- return command.Command{}, false96+ return Result{}, false
87 }97 }
88- return m.matches[m.selected].Command, true98+ return m.matches[m.selected].Result, true
89 }99 }
90 100
91-// CompleteSelected replaces the query with the selected command's effective101+// CompleteSelected only completes static command results. Session targets can
92-// code. Required-argument commands retain the typed argument bytes.102+// never be parsed, completed, or otherwise treated as commands.
93 func (m *Model) CompleteSelected() bool {103 func (m *Model) CompleteSelected() bool {
94 selected, ok := m.Selected()104 selected, ok := m.Selected()
95 if !ok {105 if !ok {
96 return false106 return false
97 }107 }
108+ cmd, ok := selected.Command()
109+ if !ok {
110+ return false
111+ }
98 112
99 query := m.Query()113 query := m.Query()
100- completed := selected.Code114+ completed := cmd.Code
101- if selected.Arguments == command.ArgumentsRequired {115+ if cmd.Arguments == command.ArgumentsRequired {
102 token, args, hasSeparator := completionParts(query)116 token, args, hasSeparator := completionParts(query)
103- if token == selected.Code && hasSeparator && args != "" {117+ if token == cmd.Code && hasSeparator && args != "" {
104 return false118 return false
105 }119 }
106 completed += " "120 completed += " "
@@ -113,8 +127,7 @@ func (m *Model) CompleteSelected() bool {
113 }127 }
114 128
115 m.input.SetValue(completed)129 m.input.SetValue(completed)
116- m.selected = 0130+ m.selected, m.scroll = 0, 0
117- m.scroll = 0
118 m.refresh()131 m.refresh()
119 return true132 return true
120 }133 }
@@ -136,12 +149,12 @@ func completionParts(query string) (token, args string, hasSeparator bool) {
136 return query, "", false149 return query, "", false
137 }150 }
138 151
139-// ArgumentCommand returns the exact argument-taking command being entered.152+// ArgumentCommand returns the exact argument-taking static command being entered.
140 func (m *Model) ArgumentCommand() (command.Command, bool) {153 func (m *Model) ArgumentCommand() (command.Command, bool) {
141 if m == nil {154 if m == nil {
142 return command.Command{}, false155 return command.Command{}, false
143 }156 }
144- return ArgumentCommand(m.commands, m.Query())157+ return ArgumentCommand(m.results, m.Query())
145 }158 }
146 159
147 func (m *Model) Matches() []Match {160 func (m *Model) Matches() []Match {
@@ -158,30 +171,39 @@ func (m *Model) Matches() []Match {
158 171
159 func (m *Model) refresh() {172 func (m *Model) refresh() {
160 query := m.input.Value()173 query := m.input.Value()
161- m.matches = Fuzzy(m.commands, query)174+ m.matches = Fuzzy(m.results, query)
162- // Once an argument-taking token is exact, retain its row while its175+ // Once an argument-taking static token is exact, retain its row while its
163 // arguments make ordinary fuzzy matching inapplicable.176 // arguments make ordinary fuzzy matching inapplicable.
164- if cmd, ok := ArgumentCommand(m.commands, query); ok {177+ if result, _, ok := argumentResult(m.results, query); ok {
165- m.prependMatch(cmd)178+ m.prependMatch(result)
166 } else if len(m.matches) == 0 {179 } else if len(m.matches) == 0 {
167 // Arguments are not part of static command matching. When they would180 // Arguments are not part of static command matching. When they would
168 // otherwise clear the list, keep required-argument candidates matched by181 // otherwise clear the list, keep required-argument candidates matched by
169 // the partial first token so Tab can complete them.182 // the partial first token so Tab can complete them.
170 token, _, hasSeparator := completionParts(query)183 token, _, hasSeparator := completionParts(query)
171 if hasSeparator {184 if hasSeparator {
172- for _, match := range Fuzzy(m.commands, token) {185+ m.matches = Fuzzy(m.argumentResults, token)
173- if match.Command.Arguments == command.ArgumentsRequired {
174- m.matches = append(m.matches, match)
175- }
176- }
177 }186 }
178 }187 }
179 m.clamp()188 m.clamp()
180 }189 }
181 190
182-func (m *Model) prependMatch(cmd command.Command) {191+func requiredArgumentResults(results []Result) []Result {
192+ commands := make([]Result, 0, len(results))
193+ for _, result := range results {
194+ cmd, ok := result.Command()
195+ if ok && cmd.Arguments == command.ArgumentsRequired {
196+ commands = append(commands, result)
197+ }
198+ }
199+ return commands
200+}
201+
202+func (m *Model) prependMatch(result Result) {
203+ cmd, _ := result.Command()
183 for i, match := range m.matches {204 for i, match := range m.matches {
184- if match.Command.Code != cmd.Code {205+ candidate, ok := match.Result.Command()
206+ if !ok || candidate.Code != cmd.Code {
185 continue207 continue
186 }208 }
187 if i > 0 {209 if i > 0 {
@@ -190,12 +212,11 @@ func (m *Model) prependMatch(cmd command.Command) {
190 }212 }
191 return213 return
192 }214 }
193- m.matches = append([]Match{{Command: cmd}}, m.matches...)215+ m.matches = append([]Match{newMatch(result, 0)}, m.matches...)
194 }216 }
195 func (m *Model) clamp() {217 func (m *Model) clamp() {
196 if len(m.matches) == 0 {218 if len(m.matches) == 0 {
197- m.selected = -1219+ m.selected, m.scroll = -1, 0
198- m.scroll = 0
199 return220 return
200 }221 }
201 if m.selected < 0 {222 if m.selected < 0 {
@@ -218,21 +239,15 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
218 if frame.Width == 0 || frame.Height == 0 {239 if frame.Width == 0 || frame.Height == 0 {
219 return frame240 return frame
220 }241 }
221- base := renderer.DefaultStyle()242+ base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description
222- selection := styles.Selection
223- desc := styles.Description
224 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})243 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
225 if m == nil {244 if m == nil {
226 ui.DrawInputLine(frame, 0, "> ", "", base, selection)245 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
227 return frame246 return frame
228 }247 }
229 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)248 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
230- start := 1249+ start, visible := 1, frame.Height-1
231- visible := frame.Height - start250+ if visible <= 0 || len(m.matches) == 0 {
232- if visible <= 0 {
233- return frame
234- }
235- if len(m.matches) == 0 {
236 return frame251 return frame
237 }252 }
238 if m.selected < m.scroll {253 if m.selected < m.scroll {
@@ -243,8 +258,8 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
243 }258 }
244 codeWidth := 0259 codeWidth := 0
245 for _, match := range m.matches {260 for _, match := range m.matches {
246- if len([]rune(match.Command.Code)) > codeWidth {261+ if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
247- codeWidth = len([]rune(match.Command.Code))262+ codeWidth = utf8.RuneCountInString(cmd.Code)
248 }263 }
249 }264 }
250 activeCmd, activeOK := m.ArgumentCommand()265 activeCmd, activeOK := m.ArgumentCommand()
@@ -259,34 +274,71 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
259 style = selection274 style = selection
260 }275 }
261 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})276 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
262- x := 0277+ if cmd, ok := match.Result.Command(); ok {
263- highlight := map[int]bool{}278+ m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
264- for _, p := range match.Positions {279+ continue
265- highlight[p] = true
266 }280 }
267- for i, r := range []rune(match.Command.Code) {281+ m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
268- cellStyle := style282+ }
283+ return frame
284+}
285+
286+func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {
287+ x, nextHighlight := 0, 0
288+ for _, r := range cmd.Code {
289+ cellStyle := style
290+ cellStyle.Bold = true
291+ if nextHighlight < len(positions) && positions[nextHighlight] == x {
292+ cellStyle = selection
269 cellStyle.Bold = true293 cellStyle.Bold = true
270- if highlight[i] {294+ nextHighlight++
271- cellStyle = selection
272- cellStyle.Bold = true
273- }
274- if x < frame.Width {
275- frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle})
276- }
277- x++
278 }295 }
279- for x < codeWidth+1 && x < frame.Width {296+ if x < frame.Width {
280- frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style})297+ frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
281- x++
282 }298 }
283- description := match.Command.Desc299+ x++
284- if opts.Guidance != "" && activeOK && match.Command.ContextHint != command.ContextHintNone && activeCmd.Code == match.Command.Code {300+ }
285- description = opts.Guidance301+ for x < codeWidth+1 && x < frame.Width {
302+ frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
303+ x++
304+ }
305+ description := cmd.Desc
306+ if feedback != "" && selected {
307+ description = feedback
308+ } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
309+ description = guidance
310+ }
311+ ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc))
312+}
313+
314+func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
315+ x, nextHighlight := 0, 0
316+ prefixWidth := utf8.RuneCountInString(match.Result.sessionDisplayPrefix())
317+ for _, r := range match.Result.DisplayText() {
318+ cellStyle := style
319+ if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x {
320+ cellStyle = selection
321+ cellStyle.Bold = true
322+ nextHighlight++
323+ }
324+ if x < frame.Width {
325+ frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
286 }326 }
287- ui.DrawText(frame, x, y+start, frame.Width, description, mergePaletteDescStyle(style, desc))327+ x++
328+ }
329+ if feedback == "" || !selected {
330+ return
331+ }
332+ if x < frame.Width {
333+ frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
334+ }
335+ x++
336+ for _, r := range feedback {
337+ if x < frame.Width {
338+ frame.Set(x, y, renderer.Cell{Rune: r, Style: style})
339+ }
340+ x++
288 }341 }
289- return frame
290 }342 }
291 343
292 func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {344 func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {
M internal/usecase/palette/model_test.go +89 -22
@@ -3,6 +3,7 @@ package palette
33 import (
44 "fmt"
55 "testing"
6+ "time"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/usecase/command"
@@ -11,33 +12,33 @@ import (
1112 )
1213
1314 func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
14- m := New([]command.Command{
15+ m := New(CommandResults([]command.Command{
1516 cmd("ABC", "Alpha", "first"),
1617 cmd("DEF", "Delta", "second"),
1718 cmd("AXY", "Other", "third"),
18- })
19+ }))
1920
2021 require.Equal(t, "", m.Query())
2122 selected, ok := m.Selected()
2223 require.True(t, ok)
23- require.Equal(t, "ABC", selected.Code)
24+ require.Equal(t, "ABC", selectedCommandCode(t, selected))
2425
2526 m.Down()
2627 m.Down()
2728 selected, ok = m.Selected()
2829 require.True(t, ok)
29- require.Equal(t, "AXY", selected.Code)
30+ require.Equal(t, "AXY", selectedCommandCode(t, selected))
3031
3132 m.Insert('d')
3233 require.Equal(t, "d", m.Query())
3334 selected, ok = m.Selected()
3435 require.True(t, ok)
35- require.Equal(t, "DEF", selected.Code, "selection clamps to only match after query changes")
36+ require.Equal(t, "DEF", selectedCommandCode(t, selected), "selection clamps to only match after query changes")
3637
3738 m.Up()
3839 selected, ok = m.Selected()
3940 require.True(t, ok)
40- require.Equal(t, "DEF", selected.Code, "up at first match clamps")
41+ require.Equal(t, "DEF", selectedCommandCode(t, selected), "up at first match clamps")
4142
4243 m.Backspace()
4344 require.Equal(t, "", m.Query())
@@ -45,7 +46,7 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
4546 }
4647
4748 func TestModelNoMatchesClearsSelection(t *testing.T) {
48- m := New([]command.Command{cmd("ABC", "Alpha", "first")})
49+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
4950 m.Insert('z')
5051
5152 _, ok := m.Selected()
@@ -54,7 +55,7 @@ func TestModelNoMatchesClearsSelection(t *testing.T) {
5455 }
5556
5657 func TestModelMatchesDeepCopiesPositions(t *testing.T) {
57- m := New([]command.Command{cmd("ABC", "Alpha", "first")})
58+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
5859 m.Insert('a')
5960
6061 matches := m.Matches()
@@ -74,8 +75,8 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
7475 cmd("AAA", "", "ZZZ 1"),
7576 zzz,
7677 }
77- want := Fuzzy(commands, "ZZZ 1")[1]
78- m := New(commands)
78+ want := Fuzzy(CommandResults(commands), "ZZZ 1")[1]
79+ m := New(CommandResults(commands))
7980
8081 for _, r := range "ZZZ 1" {
8182 m.Insert(r)
@@ -83,11 +84,11 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
8384
8485 matches := m.Matches()
8586 require.Len(t, matches, 2)
86- require.Equal(t, "ZZZ", matches[0].Command.Code)
87+ require.Equal(t, "ZZZ", matchCommandCode(t, matches[0]))
8788 require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")
8889 selected, ok := m.Selected()
8990 require.True(t, ok)
90- require.Equal(t, "ZZZ", selected.Code)
91+ require.Equal(t, "ZZZ", selectedCommandCode(t, selected))
9192 }
9293
9394 func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
@@ -102,11 +103,11 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
102103 commands: func(zzz command.Command) []command.Command {
103104 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
104105 },
105- want: Match{Command: command.Command{
106+ want: newMatch(NewCommandResult(command.Command{
106107 Code: "ZZZ",
107108 Desc: "unmatched",
108109 Arguments: command.ArgumentsRequired,
109- }},
110+ }), 0),
110111 wantLen: 2,
111112 },
112113 {
@@ -118,7 +119,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
118119 want: func() Match {
119120 zzz := cmd("ZZZ", "", "ZZZ 1")
120121 zzz.Arguments = command.ArgumentsRequired
121- return Fuzzy([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}, "ZZZ 1")[0]
122+ return Fuzzy(CommandResults([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}), "ZZZ 1")[0]
122123 }(),
123124 wantLen: 2,
124125 },
@@ -126,7 +127,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
126127 t.Run(tt.name, func(t *testing.T) {
127128 zzz := cmd("ZZZ", "", "unmatched")
128129 zzz.Arguments = command.ArgumentsRequired
129- m := New(tt.commands(zzz))
130+ m := New(CommandResults(tt.commands(zzz)))
130131 for _, r := range "ZZZ 1" {
131132 m.Insert(r)
132133 }
@@ -139,10 +140,10 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
139140 }
140141
141142 func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
142- m := New([]command.Command{
143+ m := New(CommandResults([]command.Command{
143144 cmd("CPY", "Copy", "Enter copy mode"),
144145 cmd("CNT", "New", "Create tab"),
145- })
146+ }))
146147 m.Insert('c')
147148 m.Insert('y')
148149 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})
@@ -165,7 +166,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
165166 }
166167
167168 func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
168- m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
169+ m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
169170 want := []rune("CPY Enter copy mode")
170171
171172 for _, cols := range []int{0, 1, 3, 4, 7} {
@@ -179,6 +180,18 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
179180 }
180181 }
181182
183+func selectedCommandCode(t *testing.T, result Result) string {
184+ t.Helper()
185+ cmd, ok := result.Command()
186+ require.True(t, ok, "selected result is a command")
187+ return cmd.Code
188+}
189+
190+func matchCommandCode(t *testing.T, match Match) string {
191+ t.Helper()
192+ return selectedCommandCode(t, match.Result)
193+}
194+
182195 func frameRow(frame renderer.Frame, y int) string {
183196 row := make([]rune, frame.Width)
184197 for x := range frame.Width {
@@ -247,7 +260,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
247260
248261 for _, tt := range tests {
249262 t.Run(tt.name, func(t *testing.T) {
250- m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
263+ m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
251264 m.Insert('c')
252265 m.Insert('y')
253266 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
@@ -294,7 +307,7 @@ func TestModelCompleteSelected(t *testing.T) {
294307 if tt.registry {
295308 m = NewRegistry()
296309 } else {
297- m = New(tt.commands)
310+ m = New(CommandResults(tt.commands))
298311 }
299312 for _, r := range tt.query {
300313 m.Insert(r)
@@ -319,11 +332,65 @@ func TestModelCompleteSelected(t *testing.T) {
319332 }
320333 }
321334
335+func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
336+ created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
337+ results := []Result{
338+ NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
339+ NewActiveSessionResult("work", created, "work-id"),
340+ NewStoppedSessionResult("work", created),
341+ }
342+ m := New(results)
343+ results[1] = NewActiveSessionResult("changed", created, "changed-id")
344+
345+ for _, r := range "work" {
346+ m.Insert(r)
347+ }
348+ matches := m.Matches()
349+ require.Len(t, matches, 2)
350+ require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
351+ matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
352+ require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
353+
354+ selected, ok := m.Selected()
355+ require.True(t, ok)
356+ require.Equal(t, ResultKindActiveSession, selected.Kind())
357+ id, active := selected.SessionID()
358+ require.True(t, active)
359+ require.Equal(t, domain.SessionID("work-id"), id)
360+ require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
361+ require.Equal(t, "work", m.Query())
362+ _, argument := m.ArgumentCommand()
363+ require.False(t, argument)
364+
365+ frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
366+ require.Equal(t, "Switch to session work ", frameRow(frame, 1))
367+}
368+
369+func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
370+ m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))})
371+ m.Insert('w')
372+ m.Insert('k')
373+
374+ frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
375+
376+ require.Equal(t, "Resume session work ", frameRow(frame, 1))
377+ require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
378+ require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
379+ require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
380+}
381+
382+func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
383+ m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
384+ frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
385+ require.Len(t, m.Matches(), 1)
386+ require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
387+}
388+
322389 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
323390 jrs := cmd("JRS", "Jump", "Jump to recent session")
324391 jrs.Arguments = command.ArgumentsRequired
325392 jrs.ContextHint = command.ContextHintRecentSessions
326- m := New([]command.Command{jrs})
393+ m := New(CommandResults([]command.Command{jrs}))
327394 for _, r := range "JRS 1" {
328395 m.Insert(r)
329396 }
@@ -3,6 +3,7 @@ package palette
3 import (3 import (
4 "fmt"4 "fmt"
5 "testing"5 "testing"
6+ "time"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/usecase/command"9 "github.com/bnema/vev/internal/usecase/command"
@@ -11,33 +12,33 @@ import (
11 )12 )
12 13
13 func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {14 func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
14- m := New([]command.Command{15+ m := New(CommandResults([]command.Command{
15 cmd("ABC", "Alpha", "first"),16 cmd("ABC", "Alpha", "first"),
16 cmd("DEF", "Delta", "second"),17 cmd("DEF", "Delta", "second"),
17 cmd("AXY", "Other", "third"),18 cmd("AXY", "Other", "third"),
18- })19+ }))
19 20
20 require.Equal(t, "", m.Query())21 require.Equal(t, "", m.Query())
21 selected, ok := m.Selected()22 selected, ok := m.Selected()
22 require.True(t, ok)23 require.True(t, ok)
23- require.Equal(t, "ABC", selected.Code)24+ require.Equal(t, "ABC", selectedCommandCode(t, selected))
24 25
25 m.Down()26 m.Down()
26 m.Down()27 m.Down()
27 selected, ok = m.Selected()28 selected, ok = m.Selected()
28 require.True(t, ok)29 require.True(t, ok)
29- require.Equal(t, "AXY", selected.Code)30+ require.Equal(t, "AXY", selectedCommandCode(t, selected))
30 31
31 m.Insert('d')32 m.Insert('d')
32 require.Equal(t, "d", m.Query())33 require.Equal(t, "d", m.Query())
33 selected, ok = m.Selected()34 selected, ok = m.Selected()
34 require.True(t, ok)35 require.True(t, ok)
35- require.Equal(t, "DEF", selected.Code, "selection clamps to only match after query changes")36+ require.Equal(t, "DEF", selectedCommandCode(t, selected), "selection clamps to only match after query changes")
36 37
37 m.Up()38 m.Up()
38 selected, ok = m.Selected()39 selected, ok = m.Selected()
39 require.True(t, ok)40 require.True(t, ok)
40- require.Equal(t, "DEF", selected.Code, "up at first match clamps")41+ require.Equal(t, "DEF", selectedCommandCode(t, selected), "up at first match clamps")
41 42
42 m.Backspace()43 m.Backspace()
43 require.Equal(t, "", m.Query())44 require.Equal(t, "", m.Query())
@@ -45,7 +46,7 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
45 }46 }
46 47
47 func TestModelNoMatchesClearsSelection(t *testing.T) {48 func TestModelNoMatchesClearsSelection(t *testing.T) {
48- m := New([]command.Command{cmd("ABC", "Alpha", "first")})49+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
49 m.Insert('z')50 m.Insert('z')
50 51
51 _, ok := m.Selected()52 _, ok := m.Selected()
@@ -54,7 +55,7 @@ func TestModelNoMatchesClearsSelection(t *testing.T) {
54 }55 }
55 56
56 func TestModelMatchesDeepCopiesPositions(t *testing.T) {57 func TestModelMatchesDeepCopiesPositions(t *testing.T) {
57- m := New([]command.Command{cmd("ABC", "Alpha", "first")})58+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
58 m.Insert('a')59 m.Insert('a')
59 60
60 matches := m.Matches()61 matches := m.Matches()
@@ -74,8 +75,8 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
74 cmd("AAA", "", "ZZZ 1"),75 cmd("AAA", "", "ZZZ 1"),
75 zzz,76 zzz,
76 }77 }
77- want := Fuzzy(commands, "ZZZ 1")[1]78+ want := Fuzzy(CommandResults(commands), "ZZZ 1")[1]
78- m := New(commands)79+ m := New(CommandResults(commands))
79 80
80 for _, r := range "ZZZ 1" {81 for _, r := range "ZZZ 1" {
81 m.Insert(r)82 m.Insert(r)
@@ -83,11 +84,11 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
83 84
84 matches := m.Matches()85 matches := m.Matches()
85 require.Len(t, matches, 2)86 require.Len(t, matches, 2)
86- require.Equal(t, "ZZZ", matches[0].Command.Code)87+ require.Equal(t, "ZZZ", matchCommandCode(t, matches[0]))
87 require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")88 require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")
88 selected, ok := m.Selected()89 selected, ok := m.Selected()
89 require.True(t, ok)90 require.True(t, ok)
90- require.Equal(t, "ZZZ", selected.Code)91+ require.Equal(t, "ZZZ", selectedCommandCode(t, selected))
91 }92 }
92 93
93 func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {94 func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
@@ -102,11 +103,11 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
102 commands: func(zzz command.Command) []command.Command {103 commands: func(zzz command.Command) []command.Command {
103 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}104 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
104 },105 },
105- want: Match{Command: command.Command{106+ want: newMatch(NewCommandResult(command.Command{
106 Code: "ZZZ",107 Code: "ZZZ",
107 Desc: "unmatched",108 Desc: "unmatched",
108 Arguments: command.ArgumentsRequired,109 Arguments: command.ArgumentsRequired,
109- }},110+ }), 0),
110 wantLen: 2,111 wantLen: 2,
111 },112 },
112 {113 {
@@ -118,7 +119,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
118 want: func() Match {119 want: func() Match {
119 zzz := cmd("ZZZ", "", "ZZZ 1")120 zzz := cmd("ZZZ", "", "ZZZ 1")
120 zzz.Arguments = command.ArgumentsRequired121 zzz.Arguments = command.ArgumentsRequired
121- return Fuzzy([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}, "ZZZ 1")[0]122+ return Fuzzy(CommandResults([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}), "ZZZ 1")[0]
122 }(),123 }(),
123 wantLen: 2,124 wantLen: 2,
124 },125 },
@@ -126,7 +127,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
126 t.Run(tt.name, func(t *testing.T) {127 t.Run(tt.name, func(t *testing.T) {
127 zzz := cmd("ZZZ", "", "unmatched")128 zzz := cmd("ZZZ", "", "unmatched")
128 zzz.Arguments = command.ArgumentsRequired129 zzz.Arguments = command.ArgumentsRequired
129- m := New(tt.commands(zzz))130+ m := New(CommandResults(tt.commands(zzz)))
130 for _, r := range "ZZZ 1" {131 for _, r := range "ZZZ 1" {
131 m.Insert(r)132 m.Insert(r)
132 }133 }
@@ -139,10 +140,10 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
139 }140 }
140 141
141 func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {142 func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
142- m := New([]command.Command{143+ m := New(CommandResults([]command.Command{
143 cmd("CPY", "Copy", "Enter copy mode"),144 cmd("CPY", "Copy", "Enter copy mode"),
144 cmd("CNT", "New", "Create tab"),145 cmd("CNT", "New", "Create tab"),
145- })146+ }))
146 m.Insert('c')147 m.Insert('c')
147 m.Insert('y')148 m.Insert('y')
148 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})149 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})
@@ -165,7 +166,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
165 }166 }
166 167
167 func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {168 func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
168- m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})169+ m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
169 want := []rune("CPY Enter copy mode")170 want := []rune("CPY Enter copy mode")
170 171
171 for _, cols := range []int{0, 1, 3, 4, 7} {172 for _, cols := range []int{0, 1, 3, 4, 7} {
@@ -179,6 +180,18 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
179 }180 }
180 }181 }
181 182
183+func selectedCommandCode(t *testing.T, result Result) string {
184+ t.Helper()
185+ cmd, ok := result.Command()
186+ require.True(t, ok, "selected result is a command")
187+ return cmd.Code
188+}
189+
190+func matchCommandCode(t *testing.T, match Match) string {
191+ t.Helper()
192+ return selectedCommandCode(t, match.Result)
193+}
194+
182 func frameRow(frame renderer.Frame, y int) string {195 func frameRow(frame renderer.Frame, y int) string {
183 row := make([]rune, frame.Width)196 row := make([]rune, frame.Width)
184 for x := range frame.Width {197 for x := range frame.Width {
@@ -247,7 +260,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
247 260
248 for _, tt := range tests {261 for _, tt := range tests {
249 t.Run(tt.name, func(t *testing.T) {262 t.Run(tt.name, func(t *testing.T) {
250- m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})263+ m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
251 m.Insert('c')264 m.Insert('c')
252 m.Insert('y')265 m.Insert('y')
253 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})266 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
@@ -294,7 +307,7 @@ func TestModelCompleteSelected(t *testing.T) {
294 if tt.registry {307 if tt.registry {
295 m = NewRegistry()308 m = NewRegistry()
296 } else {309 } else {
297- m = New(tt.commands)310+ m = New(CommandResults(tt.commands))
298 }311 }
299 for _, r := range tt.query {312 for _, r := range tt.query {
300 m.Insert(r)313 m.Insert(r)
@@ -319,11 +332,65 @@ func TestModelCompleteSelected(t *testing.T) {
319 }332 }
320 }333 }
321 334
335+func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
336+ created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
337+ results := []Result{
338+ NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
339+ NewActiveSessionResult("work", created, "work-id"),
340+ NewStoppedSessionResult("work", created),
341+ }
342+ m := New(results)
343+ results[1] = NewActiveSessionResult("changed", created, "changed-id")
344+
345+ for _, r := range "work" {
346+ m.Insert(r)
347+ }
348+ matches := m.Matches()
349+ require.Len(t, matches, 2)
350+ require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
351+ matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
352+ require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
353+
354+ selected, ok := m.Selected()
355+ require.True(t, ok)
356+ require.Equal(t, ResultKindActiveSession, selected.Kind())
357+ id, active := selected.SessionID()
358+ require.True(t, active)
359+ require.Equal(t, domain.SessionID("work-id"), id)
360+ require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
361+ require.Equal(t, "work", m.Query())
362+ _, argument := m.ArgumentCommand()
363+ require.False(t, argument)
364+
365+ frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
366+ require.Equal(t, "Switch to session work ", frameRow(frame, 1))
367+}
368+
369+func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
370+ m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))})
371+ m.Insert('w')
372+ m.Insert('k')
373+
374+ frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
375+
376+ require.Equal(t, "Resume session work ", frameRow(frame, 1))
377+ require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
378+ require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
379+ require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
380+}
381+
382+func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
383+ m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
384+ frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
385+ require.Len(t, m.Matches(), 1)
386+ require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
387+}
388+
322 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {389 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
323 jrs := cmd("JRS", "Jump", "Jump to recent session")390 jrs := cmd("JRS", "Jump", "Jump to recent session")
324 jrs.Arguments = command.ArgumentsRequired391 jrs.Arguments = command.ArgumentsRequired
325 jrs.ContextHint = command.ContextHintRecentSessions392 jrs.ContextHint = command.ContextHintRecentSessions
326- m := New([]command.Command{jrs})393+ m := New(CommandResults([]command.Command{jrs}))
327 for _, r := range "JRS 1" {394 for _, r := range "JRS 1" {
328 m.Insert(r)395 m.Insert(r)
329 }396 }
A internal/usecase/palette/result.go +109 -0
@@ -0,0 +1,109 @@
1+package palette
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/command"
8+)
9+
10+// ResultKind identifies the immutable target represented by a palette row.
11+type ResultKind uint8
12+
13+const (
14+ ResultKindCommand ResultKind = iota
15+ ResultKindActiveSession
16+ ResultKindStoppedSession
17+)
18+
19+// Result is an immutable palette target. Its kind is the sole discriminator
20+// for its private command or session payload.
21+type Result struct {
22+ kind ResultKind
23+ command command.Command
24+ session sessionPayload
25+}
26+
27+type sessionPayload struct {
28+ name string
29+ createdAt time.Time
30+ sessionID domain.SessionID
31+}
32+
33+// NewCommandResult creates a static command palette target.
34+func NewCommandResult(cmd command.Command) Result {
35+ return Result{kind: ResultKindCommand, command: cmd}
36+}
37+
38+// NewActiveSessionResult creates an immutable active named-session target.
39+func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result {
40+ return Result{
41+ kind: ResultKindActiveSession,
42+ session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID},
43+ }
44+}
45+
46+// NewStoppedSessionResult creates an immutable stopped named-session target.
47+func NewStoppedSessionResult(name string, createdAt time.Time) Result {
48+ return Result{
49+ kind: ResultKindStoppedSession,
50+ session: sessionPayload{name: name, createdAt: createdAt},
51+ }
52+}
53+
54+func (r Result) Kind() ResultKind { return r.kind }
55+
56+func (r Result) DisplayText() string {
57+ if r.kind == ResultKindCommand {
58+ return r.command.Code
59+ }
60+ return r.sessionDisplayPrefix() + r.session.name
61+}
62+
63+func (r Result) sessionDisplayPrefix() string {
64+ if r.kind == ResultKindStoppedSession {
65+ return stoppedSessionDisplayPrefix
66+ }
67+ return activeSessionDisplayPrefix
68+}
69+
70+func (r Result) SearchText() string {
71+ if r.kind == ResultKindCommand {
72+ return r.command.Code
73+ }
74+ return r.session.name
75+}
76+
77+// Command returns the command payload only for command results.
78+func (r Result) Command() (command.Command, bool) {
79+ return r.command, r.kind == ResultKindCommand
80+}
81+
82+// SessionName returns the session name only for session results.
83+func (r Result) SessionName() (string, bool) {
84+ return r.session.name, r.kind != ResultKindCommand
85+}
86+
87+// SessionCreatedAt returns the session creation time only for session results.
88+func (r Result) SessionCreatedAt() (time.Time, bool) {
89+ return r.session.createdAt, r.kind != ResultKindCommand
90+}
91+
92+// SessionID returns the lifecycle identity only for active session results.
93+func (r Result) SessionID() (domain.SessionID, bool) {
94+ return r.session.sessionID, r.kind == ResultKindActiveSession
95+}
96+
97+const (
98+ activeSessionDisplayPrefix = "Switch to session "
99+ stoppedSessionDisplayPrefix = "Resume session "
100+)
101+
102+// CommandResults converts static commands to immutable palette targets.
103+func CommandResults(commands []command.Command) []Result {
104+ results := make([]Result, len(commands))
105+ for i, cmd := range commands {
106+ results[i] = NewCommandResult(cmd)
107+ }
108+ return results
109+}
@@ -0,0 +1,109 @@
1+package palette
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/command"
8+)
9+
10+// ResultKind identifies the immutable target represented by a palette row.
11+type ResultKind uint8
12+
13+const (
14+ ResultKindCommand ResultKind = iota
15+ ResultKindActiveSession
16+ ResultKindStoppedSession
17+)
18+
19+// Result is an immutable palette target. Its kind is the sole discriminator
20+// for its private command or session payload.
21+type Result struct {
22+ kind ResultKind
23+ command command.Command
24+ session sessionPayload
25+}
26+
27+type sessionPayload struct {
28+ name string
29+ createdAt time.Time
30+ sessionID domain.SessionID
31+}
32+
33+// NewCommandResult creates a static command palette target.
34+func NewCommandResult(cmd command.Command) Result {
35+ return Result{kind: ResultKindCommand, command: cmd}
36+}
37+
38+// NewActiveSessionResult creates an immutable active named-session target.
39+func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result {
40+ return Result{
41+ kind: ResultKindActiveSession,
42+ session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID},
43+ }
44+}
45+
46+// NewStoppedSessionResult creates an immutable stopped named-session target.
47+func NewStoppedSessionResult(name string, createdAt time.Time) Result {
48+ return Result{
49+ kind: ResultKindStoppedSession,
50+ session: sessionPayload{name: name, createdAt: createdAt},
51+ }
52+}
53+
54+func (r Result) Kind() ResultKind { return r.kind }
55+
56+func (r Result) DisplayText() string {
57+ if r.kind == ResultKindCommand {
58+ return r.command.Code
59+ }
60+ return r.sessionDisplayPrefix() + r.session.name
61+}
62+
63+func (r Result) sessionDisplayPrefix() string {
64+ if r.kind == ResultKindStoppedSession {
65+ return stoppedSessionDisplayPrefix
66+ }
67+ return activeSessionDisplayPrefix
68+}
69+
70+func (r Result) SearchText() string {
71+ if r.kind == ResultKindCommand {
72+ return r.command.Code
73+ }
74+ return r.session.name
75+}
76+
77+// Command returns the command payload only for command results.
78+func (r Result) Command() (command.Command, bool) {
79+ return r.command, r.kind == ResultKindCommand
80+}
81+
82+// SessionName returns the session name only for session results.
83+func (r Result) SessionName() (string, bool) {
84+ return r.session.name, r.kind != ResultKindCommand
85+}
86+
87+// SessionCreatedAt returns the session creation time only for session results.
88+func (r Result) SessionCreatedAt() (time.Time, bool) {
89+ return r.session.createdAt, r.kind != ResultKindCommand
90+}
91+
92+// SessionID returns the lifecycle identity only for active session results.
93+func (r Result) SessionID() (domain.SessionID, bool) {
94+ return r.session.sessionID, r.kind == ResultKindActiveSession
95+}
96+
97+const (
98+ activeSessionDisplayPrefix = "Switch to session "
99+ stoppedSessionDisplayPrefix = "Resume session "
100+)
101+
102+// CommandResults converts static commands to immutable palette targets.
103+func CommandResults(commands []command.Command) []Result {
104+ results := make([]Result, len(commands))
105+ for i, cmd := range commands {
106+ results[i] = NewCommandResult(cmd)
107+ }
108+ return results
109+}
A internal/usecase/palette/result_test.go +48 -0
@@ -0,0 +1,48 @@
1+package palette
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/command"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
13+ created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
14+ commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"})
15+ active := NewActiveSessionResult("work", created, domain.SessionID("session-work"))
16+ stopped := NewStoppedSessionResult("archive", created)
17+
18+ require.Equal(t, ResultKindCommand, commandResult.Kind())
19+ require.Equal(t, "NT", commandResult.DisplayText())
20+ require.Equal(t, "NT", commandResult.SearchText())
21+ commandInfo, ok := commandResult.Command()
22+ require.True(t, ok)
23+ require.Equal(t, "NT", commandInfo.Code)
24+
25+ require.Equal(t, ResultKindActiveSession, active.Kind())
26+ name, ok := active.SessionName()
27+ require.True(t, ok)
28+ require.Equal(t, "work", name)
29+ createdAt, ok := active.SessionCreatedAt()
30+ require.True(t, ok)
31+ require.Equal(t, created, createdAt)
32+ require.Equal(t, "Switch to session work", active.DisplayText())
33+ require.Equal(t, "work", active.SearchText())
34+ id, ok := active.SessionID()
35+ require.True(t, ok)
36+ require.Equal(t, domain.SessionID("session-work"), id)
37+
38+ require.Equal(t, ResultKindStoppedSession, stopped.Kind())
39+ name, ok = stopped.SessionName()
40+ require.True(t, ok)
41+ require.Equal(t, "archive", name)
42+ createdAt, ok = stopped.SessionCreatedAt()
43+ require.True(t, ok)
44+ require.Equal(t, created, createdAt)
45+ require.Equal(t, "Resume session archive", stopped.DisplayText())
46+ _, ok = stopped.SessionID()
47+ require.False(t, ok)
48+}
@@ -0,0 +1,48 @@
1+package palette
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/command"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
13+ created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
14+ commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"})
15+ active := NewActiveSessionResult("work", created, domain.SessionID("session-work"))
16+ stopped := NewStoppedSessionResult("archive", created)
17+
18+ require.Equal(t, ResultKindCommand, commandResult.Kind())
19+ require.Equal(t, "NT", commandResult.DisplayText())
20+ require.Equal(t, "NT", commandResult.SearchText())
21+ commandInfo, ok := commandResult.Command()
22+ require.True(t, ok)
23+ require.Equal(t, "NT", commandInfo.Code)
24+
25+ require.Equal(t, ResultKindActiveSession, active.Kind())
26+ name, ok := active.SessionName()
27+ require.True(t, ok)
28+ require.Equal(t, "work", name)
29+ createdAt, ok := active.SessionCreatedAt()
30+ require.True(t, ok)
31+ require.Equal(t, created, createdAt)
32+ require.Equal(t, "Switch to session work", active.DisplayText())
33+ require.Equal(t, "work", active.SearchText())
34+ id, ok := active.SessionID()
35+ require.True(t, ok)
36+ require.Equal(t, domain.SessionID("session-work"), id)
37+
38+ require.Equal(t, ResultKindStoppedSession, stopped.Kind())
39+ name, ok = stopped.SessionName()
40+ require.True(t, ok)
41+ require.Equal(t, "archive", name)
42+ createdAt, ok = stopped.SessionCreatedAt()
43+ require.True(t, ok)
44+ require.Equal(t, created, createdAt)
45+ require.Equal(t, "Resume session archive", stopped.DisplayText())
46+ _, ok = stopped.SessionID()
47+ require.False(t, ok)
48+}
M internal/usecase/picker/model.go +21 -14
@@ -16,11 +16,13 @@ const (
1616 )
1717
1818 type SessionView struct {
19- ID domain.SessionID
20- Name string
21- Tabs []TabEntry
22- Active int
23- Stopped bool
19+ ID domain.SessionID
20+ Name string
21+ TargetName string
22+ Tabs []TabEntry
23+ Active int
24+ Stopped bool
25+ ExpectedCreatedAt *int64
2426 }
2527
2628 // TabEntry is one tab row; Name is drawn emphasized, Detail muted.
@@ -58,6 +60,10 @@ type Target struct {
5860 Name string
5961 TabIndex int
6062 Stopped bool
63+ // ExpectedCreatedAt optionally pins this target to a particular named
64+ // session lifecycle. Callers that obtain a snapshot outside the daemon use
65+ // it to reject a same-name replacement at commit time.
66+ ExpectedCreatedAt *int64
6167 }
6268
6369 // Preview is a bounded view of the selected pane's visible frame.
@@ -89,29 +95,30 @@ type row struct {
8995 detail string // " (paneTitle)" segment, muted; "" for headers
9096 attention bool // draw the attention marker right after the name, before detail; tab rows only
9197 session domain.SessionID
92- // targetName is the stopped-session lookup name threaded into Target;
98+ // targetName is the named-session lookup name threaded into Target;
9399 // distinct from dispName, which is what gets drawn.
94- targetName string
95- tabIndex int
96- stopped bool
100+ targetName string
101+ tabIndex int
102+ stopped bool
103+ expectedCreatedAt *int64
97104 }
98105
99106 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
100107 m := &Model{selected: -1}
101108 activeSelection := -1
102109 for _, session := range sessions {
103- targetName := ""
104- if session.Stopped {
110+ targetName := session.TargetName
111+ if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) {
105112 targetName = session.Name
106113 }
107- m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped})
114+ m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt})
108115 active := session.Active
109116 if active < 0 || active >= len(session.Tabs) {
110117 active = 0
111118 }
112119 for i, tab := range session.Tabs {
113120 idx := len(m.rows)
114- m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped})
121+ m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt})
115122 if session.ID == cur && i == curTab {
116123 m.selected = idx
117124 }
@@ -177,7 +184,7 @@ func (m *Model) Selected() (Target, bool) {
177184 if r.header {
178185 return Target{}, false
179186 }
180- return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true
187+ return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAt}, true
181188 }
182189
183190 func (m *Model) Clone() *Model {
@@ -16,11 +16,13 @@ const (
16 )16 )
17 17
18 type SessionView struct {18 type SessionView struct {
19- ID domain.SessionID19+ ID domain.SessionID
20- Name string20+ Name string
21- Tabs []TabEntry21+ TargetName string
22- Active int22+ Tabs []TabEntry
23- Stopped bool23+ Active int
24+ Stopped bool
25+ ExpectedCreatedAt *int64
24 }26 }
25 27
26 // TabEntry is one tab row; Name is drawn emphasized, Detail muted.28 // TabEntry is one tab row; Name is drawn emphasized, Detail muted.
@@ -58,6 +60,10 @@ type Target struct {
58 Name string60 Name string
59 TabIndex int61 TabIndex int
60 Stopped bool62 Stopped bool
63+ // ExpectedCreatedAt optionally pins this target to a particular named
64+ // session lifecycle. Callers that obtain a snapshot outside the daemon use
65+ // it to reject a same-name replacement at commit time.
66+ ExpectedCreatedAt *int64
61 }67 }
62 68
63 // Preview is a bounded view of the selected pane's visible frame.69 // Preview is a bounded view of the selected pane's visible frame.
@@ -89,29 +95,30 @@ type row struct {
89 detail string // " (paneTitle)" segment, muted; "" for headers95 detail string // " (paneTitle)" segment, muted; "" for headers
90 attention bool // draw the attention marker right after the name, before detail; tab rows only96 attention bool // draw the attention marker right after the name, before detail; tab rows only
91 session domain.SessionID97 session domain.SessionID
92- // targetName is the stopped-session lookup name threaded into Target;98+ // targetName is the named-session lookup name threaded into Target;
93 // distinct from dispName, which is what gets drawn.99 // distinct from dispName, which is what gets drawn.
94- targetName string100+ targetName string
95- tabIndex int101+ tabIndex int
96- stopped bool102+ stopped bool
103+ expectedCreatedAt *int64
97 }104 }
98 105
99 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {106 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
100 m := &Model{selected: -1}107 m := &Model{selected: -1}
101 activeSelection := -1108 activeSelection := -1
102 for _, session := range sessions {109 for _, session := range sessions {
103- targetName := ""110+ targetName := session.TargetName
104- if session.Stopped {111+ if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) {
105 targetName = session.Name112 targetName = session.Name
106 }113 }
107- m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped})114+ m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt})
108 active := session.Active115 active := session.Active
109 if active < 0 || active >= len(session.Tabs) {116 if active < 0 || active >= len(session.Tabs) {
110 active = 0117 active = 0
111 }118 }
112 for i, tab := range session.Tabs {119 for i, tab := range session.Tabs {
113 idx := len(m.rows)120 idx := len(m.rows)
114- m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped})121+ m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt})
115 if session.ID == cur && i == curTab {122 if session.ID == cur && i == curTab {
116 m.selected = idx123 m.selected = idx
117 }124 }
@@ -177,7 +184,7 @@ func (m *Model) Selected() (Target, bool) {
177 if r.header {184 if r.header {
178 return Target{}, false185 return Target{}, false
179 }186 }
180- return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true187+ return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAt}, true
181 }188 }
182 189
183 func (m *Model) Clone() *Model {190 func (m *Model) Clone() *Model {

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