detached 6909543

feat(daemon): suggest named sessions in palette

commit
6909543ddb8914602516d434594c2a439d943867
author
brice · · 2026-07-15 06:30 UTC
committer
brice · · 2026-07-15 07:47 UTC
parent
0400d88
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +243 -62
M internal/usecase/daemon/config_test.go +7 -2
@@ -8,6 +8,7 @@ import (
88 "github.com/bnema/vev/internal/domain"
99 "github.com/bnema/vev/internal/ports"
1010 "github.com/bnema/vev/internal/usecase/keys"
11+ "github.com/bnema/vev/internal/usecase/palette"
1112 )
1213
1314 type captureKeyHandler struct {
@@ -97,8 +98,12 @@ func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
9798 require.Equal(t, query, ac.overlays.palette.Query())
9899 selectedAfter, ok := ac.overlays.palette.Selected()
99100 require.True(t, ok)
100- require.Equal(t, selected.Slug, selectedAfter.Slug)
101- require.Equal(t, selected.Code, selectedAfter.Code)
101+ selectedCommand, ok := selected.(palette.CommandResult)
102+ require.True(t, ok)
103+ selectedAfterCommand, ok := selectedAfter.(palette.CommandResult)
104+ require.True(t, ok)
105+ require.Equal(t, selectedCommand.Command().Slug, selectedAfterCommand.Command().Slug)
106+ require.Equal(t, selectedCommand.Command().Code, selectedAfterCommand.Command().Code)
102107 }
103108
104109 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
@@ -8,6 +8,7 @@ import (
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/keys"10 "github.com/bnema/vev/internal/usecase/keys"
11+ "github.com/bnema/vev/internal/usecase/palette"
11 )12 )
12 13
13 type captureKeyHandler struct {14 type captureKeyHandler struct {
@@ -97,8 +98,12 @@ func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) {
97 require.Equal(t, query, ac.overlays.palette.Query())98 require.Equal(t, query, ac.overlays.palette.Query())
98 selectedAfter, ok := ac.overlays.palette.Selected()99 selectedAfter, ok := ac.overlays.palette.Selected()
99 require.True(t, ok)100 require.True(t, ok)
100- require.Equal(t, selected.Slug, selectedAfter.Slug)101+ selectedCommand, ok := selected.(palette.CommandResult)
101- require.Equal(t, selected.Code, selectedAfter.Code)102+ require.True(t, ok)
103+ selectedAfterCommand, ok := selectedAfter.(palette.CommandResult)
104+ require.True(t, ok)
105+ require.Equal(t, selectedCommand.Command().Slug, selectedAfterCommand.Command().Slug)
106+ require.Equal(t, selectedCommand.Command().Code, selectedAfterCommand.Command().Code)
102 }107 }
103 108
104 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {109 func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
M internal/usecase/daemon/overlay_runtime.go +6 -3
@@ -26,6 +26,7 @@ type overlayRuntime struct {
2626 paletteRecent []recentSession // immutable for this palette interaction
2727 paletteGeneration uint64
2828 paletteHints palette.ContextualHints
29+ paletteFeedback string
2930 palettePending []byte
3031
3132 promptMu sync.Mutex
@@ -171,9 +172,10 @@ type overlayRenderSnapshot struct {
171172 paletteModel *palette.Model
172173 // paletteHints is a copy captured under paletteMu. Rendering must use this
173174 // immutable interaction snapshot rather than consult live session state.
174- paletteHints *palette.ContextualHints
175- paletteRecent []recentSession
176- paletteLocked bool
175+ paletteHints *palette.ContextualHints
176+ paletteFeedback string
177+ paletteRecent []recentSession
178+ paletteLocked bool
177179
178180 promptActive bool
179181 promptModel *promptui.Model
@@ -221,6 +223,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
221223 hints := rt.paletteHints
222224 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
223225 snap.paletteHints = &hints
226+ snap.paletteFeedback = rt.paletteFeedback
224227 snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)
225228 snap.paletteLocked = true
226229 } else {
@@ -26,6 +26,7 @@ type overlayRuntime struct {
26 paletteRecent []recentSession // immutable for this palette interaction26 paletteRecent []recentSession // immutable for this palette interaction
27 paletteGeneration uint6427 paletteGeneration uint64
28 paletteHints palette.ContextualHints28 paletteHints palette.ContextualHints
29+ paletteFeedback string
29 palettePending []byte30 palettePending []byte
30 31
31 promptMu sync.Mutex32 promptMu sync.Mutex
@@ -171,9 +172,10 @@ type overlayRenderSnapshot struct {
171 paletteModel *palette.Model172 paletteModel *palette.Model
172 // paletteHints is a copy captured under paletteMu. Rendering must use this173 // paletteHints is a copy captured under paletteMu. Rendering must use this
173 // immutable interaction snapshot rather than consult live session state.174 // immutable interaction snapshot rather than consult live session state.
174- paletteHints *palette.ContextualHints175+ paletteHints *palette.ContextualHints
175- paletteRecent []recentSession176+ paletteFeedback string
176- paletteLocked bool177+ paletteRecent []recentSession
178+ paletteLocked bool
177 179
178 promptActive bool180 promptActive bool
179 promptModel *promptui.Model181 promptModel *promptui.Model
@@ -221,6 +223,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
221 hints := rt.paletteHints223 hints := rt.paletteHints
222 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)224 hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...)
223 snap.paletteHints = &hints225 snap.paletteHints = &hints
226+ snap.paletteFeedback = rt.paletteFeedback
224 snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)227 snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...)
225 snap.paletteLocked = true228 snap.paletteLocked = true
226 } else {229 } else {
M internal/usecase/daemon/palette.go +99 -40
@@ -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,59 @@ 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.SessionResult, 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 { return active[i].Name() < active[j].Name() })
84+ sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
85+ for _, candidate := range active {
86+ results = append(results, candidate)
87+ }
88+ for _, candidate := range stopped {
89+ results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
90+ }
91+ return results
92+}
93+
4894 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
4995 names := make([]string, len(recent))
5096 for i, entry := range recent {
@@ -104,6 +150,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
104150 return
105151 }
106152 var cmd command.Command
153+ var sessionTarget palette.SessionResult
154+ var hasSessionTarget bool
107155 var args []string
108156 var recent []recentSession
109157 var generation uint64
@@ -117,48 +165,49 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
117165 return
118166 }
119167 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
120- rune: func(r rune) {
121- ac.overlays.palette.Insert(r)
122- changed = true
123- },
124- backspace: func() {
125- ac.overlays.palette.Backspace()
126- changed = true
127- },
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 },
168+ rune: func(r rune) { ac.overlays.palette.Insert(r); changed = true },
169+ backspace: func() { ac.overlays.palette.Backspace(); changed = true },
170+ tab: func() { changed = ac.overlays.palette.CompleteSelected() || changed },
171+ up: func() { ac.overlays.palette.Up(); changed = true },
172+ down: func() { ac.overlays.palette.Down(); changed = true },
173+ cancel: func() { cancel = true },
132174 enter: func() {
133- var selected bool
134- cmd, selected = ac.overlays.palette.Selected()
135- if !selected {
175+ selected, ok := ac.overlays.palette.Selected()
176+ if !ok {
136177 changed = true
137178 return
138179 }
139180 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
181+ switch result := selected.(type) {
182+ case palette.CommandResult:
183+ cmd = result.Command()
184+ // JRS is contextual: fuzzy selection cannot turn an unrelated
185+ // query into a jump.
186+ if cmd.Slug == "jump-recent-session" {
187+ action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)
188+ if !valid {
189+ changed = true
190+ return
191+ }
192+ args = action.Args
193+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
149194 }
150- args = action.Args
151- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
195+ case palette.SessionResult:
196+ sessionTarget = result
197+ hasSessionTarget = true
198+ default:
199+ changed = true
200+ return
152201 }
153202 generation = ac.overlays.paletteGeneration
154203 execute = true
155204 },
156205 })
157206 if changed {
207+ ac.overlays.paletteFeedback = ""
158208 active, ok := ac.overlays.palette.ArgumentCommand()
159209 if ok && active.Slug == "jump-recent-session" {
160- hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))
161- ac.overlays.paletteHints = hints
210+ ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))
162211 } else {
163212 ac.overlays.paletteHints = palette.ContextualHints{}
164213 }
@@ -178,8 +227,25 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
178227 return
179228 }
180229
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.
230+ if hasSessionTarget {
231+ createdAt := sessionTarget.CreatedAt().UnixNano()
232+ target := picker.Target{Name: sessionTarget.Name(), TabIndex: -1, ExpectedCreatedAt: &createdAt}
233+ if id, active := sessionTarget.SessionID(); active {
234+ target.Session = id
235+ } else {
236+ target.Stopped = true
237+ }
238+ if !d.switchToTarget(sess, ac, target) {
239+ ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
240+ d.invalidateRender(sess, ac, true, "palette.go")
241+ return
242+ }
243+ if ac.closeExecutedPalette(generation, rawQuery) {
244+ d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
245+ }
246+ return
247+ }
248+
183249 if cmd.Slug == "jump-recent-session" {
184250 rank, err := command.ParsePositiveDecimal(args)
185251 if err != nil {
@@ -187,16 +253,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
187253 d.invalidateRender(sess, ac, true, "palette.go")
188254 return
189255 }
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.
192256 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
193257 if err := exec.JumpRecentSession(rank); err != nil {
194258 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
195259 d.invalidateRender(sess, ac, true, "palette.go")
196260 return
197261 }
198- // Publication remains generation-safe; do not let stale work close a
199- // newer palette interaction.
200262 if ac.closeExecutedPalette(generation, rawQuery) {
201263 d.recordPaletteUse(cmd.Code)
202264 d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
@@ -226,6 +288,7 @@ func (ac *attachedClient) clearPaletteLocked() {
226288 ac.overlays.palette = nil
227289 ac.overlays.paletteRecent = nil
228290 ac.overlays.paletteHints = palette.ContextualHints{}
291+ ac.overlays.paletteFeedback = ""
229292 ac.overlays.palettePending = nil
230293 }
231294
@@ -235,11 +298,7 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s
235298 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
236299 return
237300 }
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- }
301+ ac.overlays.paletteFeedback = feedback
243302 }
244303
245304 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,59 @@ 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.SessionResult, 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 { return active[i].Name() < active[j].Name() })
84+ sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
85+ for _, candidate := range active {
86+ results = append(results, candidate)
87+ }
88+ for _, candidate := range stopped {
89+ results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
90+ }
91+ return results
92+}
93+
48 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {94 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
49 names := make([]string, len(recent))95 names := make([]string, len(recent))
50 for i, entry := range recent {96 for i, entry := range recent {
@@ -104,6 +150,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
104 return150 return
105 }151 }
106 var cmd command.Command152 var cmd command.Command
153+ var sessionTarget palette.SessionResult
154+ var hasSessionTarget bool
107 var args []string155 var args []string
108 var recent []recentSession156 var recent []recentSession
109 var generation uint64157 var generation uint64
@@ -117,48 +165,49 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
117 return165 return
118 }166 }
119 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{167 routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
120- rune: func(r rune) {168+ rune: func(r rune) { ac.overlays.palette.Insert(r); changed = true },
121- ac.overlays.palette.Insert(r)169+ backspace: func() { ac.overlays.palette.Backspace(); changed = true },
122- changed = true170+ tab: func() { changed = ac.overlays.palette.CompleteSelected() || changed },
123- },171+ up: func() { ac.overlays.palette.Up(); changed = true },
124- backspace: func() {172+ down: func() { ac.overlays.palette.Down(); changed = true },
125- ac.overlays.palette.Backspace()173+ cancel: func() { cancel = true },
126- changed = true
127- },
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() {174 enter: func() {
133- var selected bool175+ selected, ok := ac.overlays.palette.Selected()
134- cmd, selected = ac.overlays.palette.Selected()176+ if !ok {
135- if !selected {
136 changed = true177 changed = true
137 return178 return
138 }179 }
139 rawQuery = ac.overlays.palette.Query()180 rawQuery = ac.overlays.palette.Query()
140- // JRS is contextual: it is executable only from its exact token so181+ switch result := selected.(type) {
141- // fuzzy selection can never turn an unrelated query into a jump.182+ case palette.CommandResult:
142- // Static commands retain normal palette behavior and run the selected183+ cmd = result.Command()
143- // fuzzy match.184+ // JRS is contextual: fuzzy selection cannot turn an unrelated
144- if cmd.Slug == "jump-recent-session" {185+ // query into a jump.
145- action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)186+ if cmd.Slug == "jump-recent-session" {
146- if !valid {187+ action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)
147- changed = true188+ if !valid {
148- return189+ changed = true
190+ return
191+ }
192+ args = action.Args
193+ recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
149 }194 }
150- args = action.Args195+ case palette.SessionResult:
151- recent = append([]recentSession(nil), ac.overlays.paletteRecent...)196+ sessionTarget = result
197+ hasSessionTarget = true
198+ default:
199+ changed = true
200+ return
152 }201 }
153 generation = ac.overlays.paletteGeneration202 generation = ac.overlays.paletteGeneration
154 execute = true203 execute = true
155 },204 },
156 })205 })
157 if changed {206 if changed {
207+ ac.overlays.paletteFeedback = ""
158 active, ok := ac.overlays.palette.ArgumentCommand()208 active, ok := ac.overlays.palette.ArgumentCommand()
159 if ok && active.Slug == "jump-recent-session" {209 if ok && active.Slug == "jump-recent-session" {
160- hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))210+ ac.overlays.paletteHints = recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))
161- ac.overlays.paletteHints = hints
162 } else {211 } else {
163 ac.overlays.paletteHints = palette.ContextualHints{}212 ac.overlays.paletteHints = palette.ContextualHints{}
164 }213 }
@@ -178,8 +227,25 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
178 return227 return
179 }228 }
180 229
181- // Revalidate live target without paletteMu. A captured ID is never replaced230+ if hasSessionTarget {
182- // by a newly-ranked session, so MRU changes cannot shift a requested rank.231+ createdAt := sessionTarget.CreatedAt().UnixNano()
232+ target := picker.Target{Name: sessionTarget.Name(), TabIndex: -1, ExpectedCreatedAt: &createdAt}
233+ if id, active := sessionTarget.SessionID(); active {
234+ target.Session = id
235+ } else {
236+ target.Stopped = true
237+ }
238+ if !d.switchToTarget(sess, ac, target) {
239+ ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
240+ d.invalidateRender(sess, ac, true, "palette.go")
241+ return
242+ }
243+ if ac.closeExecutedPalette(generation, rawQuery) {
244+ d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
245+ }
246+ return
247+ }
248+
183 if cmd.Slug == "jump-recent-session" {249 if cmd.Slug == "jump-recent-session" {
184 rank, err := command.ParsePositiveDecimal(args)250 rank, err := command.ParsePositiveDecimal(args)
185 if err != nil {251 if err != nil {
@@ -187,16 +253,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
187 d.invalidateRender(sess, ac, true, "palette.go")253 d.invalidateRender(sess, ac, true, "palette.go")
188 return254 return
189 }255 }
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}256 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
193 if err := exec.JumpRecentSession(rank); err != nil {257 if err := exec.JumpRecentSession(rank); err != nil {
194 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")258 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
195 d.invalidateRender(sess, ac, true, "palette.go")259 d.invalidateRender(sess, ac, true, "palette.go")
196 return260 return
197 }261 }
198- // Publication remains generation-safe; do not let stale work close a
199- // newer palette interaction.
200 if ac.closeExecutedPalette(generation, rawQuery) {262 if ac.closeExecutedPalette(generation, rawQuery) {
201 d.recordPaletteUse(cmd.Code)263 d.recordPaletteUse(cmd.Code)
202 d.invalidateRender(ac.currentSession(), ac, true, "palette.go")264 d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
@@ -226,6 +288,7 @@ func (ac *attachedClient) clearPaletteLocked() {
226 ac.overlays.palette = nil288 ac.overlays.palette = nil
227 ac.overlays.paletteRecent = nil289 ac.overlays.paletteRecent = nil
228 ac.overlays.paletteHints = palette.ContextualHints{}290 ac.overlays.paletteHints = palette.ContextualHints{}
291+ ac.overlays.paletteFeedback = ""
229 ac.overlays.palettePending = nil292 ac.overlays.palettePending = nil
230 }293 }
231 294
@@ -235,11 +298,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 {298 if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
236 return299 return
237 }300 }
238- // Rendering consumes the immutable contextual hint snapshot; preserve ranks301+ 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 }302 }
244 303
245 func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {304 func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {
M internal/usecase/daemon/palette_test.go +108 -10
@@ -48,6 +48,98 @@ 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 result, ok := match.Result.(palette.SessionResult); ok {
67+ got[result.Name()] = 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.(palette.SessionResult)
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 TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(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\r"))
114+
115+ require.Same(t, target, ac.currentSession())
116+ require.Same(t, ac, target.client, "canonical handoff reuses the attached client")
117+ require.False(t, ac.overlays.paletteActive())
118+ require.Empty(t, d.paletteRecent, "session selections are not command recency")
119+ awaitFrame(t, sends, ports.MsgOutput)
120+ awaitFrame(t, sends, ports.MsgOutput)
121+}
122+
123+func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
124+ p1, release1 := newBlockingPTY(t)
125+ p2, release2 := newBlockingPTY(t)
126+ defer release1()
127+ defer release2()
128+ d, current, ac, sends := newManualSessionWithPTYs(t, p1)
129+ d.ptys = newFactory(t, p2)
130+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42}
131+
132+ d.handleInput(current, ac, []byte("\x1b "))
133+ awaitFrame(t, sends, ports.MsgOutput)
134+ d.handleInput(current, ac, []byte("stopped\r"))
135+
136+ require.Equal(t, "stopped", ac.currentSession().name)
137+ require.Equal(t, int64(42), ac.currentSession().createdAt)
138+ require.False(t, ac.overlays.paletteActive())
139+ awaitFrame(t, sends, ports.MsgOutput)
140+ awaitFrame(t, sends, ports.MsgOutput)
141+}
142+
51143 func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
52144 p, release := newBlockingPTY(t)
53145 defer release()
@@ -172,7 +264,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
172264 require.True(t, ac.overlays.paletteActive())
173265 ac.overlays.paletteMu.Lock()
174266 require.Equal(t, "JRS 1", ac.overlays.palette.Query())
175- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
267+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
176268 ac.overlays.paletteMu.Unlock()
177269 }
178270
@@ -188,7 +280,7 @@ func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
188280 require.True(t, ac.overlays.paletteActive())
189281 ac.overlays.paletteMu.Lock()
190282 require.Equal(t, "JRS 3", ac.overlays.palette.Query())
191- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)
283+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
192284 ac.overlays.paletteMu.Unlock()
193285 awaitFrame(t, sends, ports.MsgOutput)
194286 }
@@ -205,7 +297,7 @@ func TestPaletteJRSMalformedRankFeedback(t *testing.T) {
205297 require.True(t, ac.overlays.paletteActive())
206298 ac.overlays.paletteMu.Lock()
207299 require.Equal(t, "JRS 0", ac.overlays.palette.Query())
208- require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteHints.Feedback)
300+ require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteFeedback)
209301 ac.overlays.paletteMu.Unlock()
210302 awaitFrame(t, sends, ports.MsgOutput)
211303 }
@@ -223,7 +315,7 @@ func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
223315 ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")
224316
225317 ac.overlays.paletteMu.Lock()
226- require.Empty(t, ac.overlays.paletteHints.Feedback)
318+ require.Empty(t, ac.overlays.paletteFeedback)
227319 ac.overlays.paletteMu.Unlock()
228320 awaitFrame(t, sends, ports.MsgOutput)
229321 awaitFrame(t, sends, ports.MsgOutput)
@@ -302,9 +394,11 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
302394
303395 d.handleInput(sess, ac, []byte("\x1b "))
304396 awaitFrame(t, sends, ports.MsgOutput)
305- cmd, ok := ac.overlays.palette.Selected()
397+ result, ok := ac.overlays.palette.Selected()
306398 require.True(t, ok)
307- require.Equal(t, "NXT", cmd.Code)
399+ cmd, ok := result.(palette.CommandResult)
400+ require.True(t, ok)
401+ require.Equal(t, "NXT", cmd.Command().Code)
308402 }
309403
310404 func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
@@ -413,15 +507,19 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
413507
414508 d.handlePaletteInput(ac, []byte{0x0e})
415509 awaitFrame(t, sends, ports.MsgOutput)
416- cmd, ok := ac.overlays.palette.Selected()
510+ result, ok := ac.overlays.palette.Selected()
511+ require.True(t, ok)
512+ cmd, ok := result.(palette.CommandResult)
417513 require.True(t, ok)
418- require.Equal(t, "CNS", cmd.Code)
514+ require.Equal(t, "CNS", cmd.Command().Code)
419515
420516 d.handlePaletteInput(ac, []byte{0x10})
421517 awaitFrame(t, sends, ports.MsgOutput)
422- cmd, ok = ac.overlays.palette.Selected()
518+ result, ok = ac.overlays.palette.Selected()
519+ require.True(t, ok)
520+ cmd, ok = result.(palette.CommandResult)
423521 require.True(t, ok)
424- require.Equal(t, "CNT", cmd.Code)
522+ require.Equal(t, "CNT", cmd.Command().Code)
425523 }
426524
427525 func TestPaletteModalGeometry(t *testing.T) {
@@ -48,6 +48,98 @@ 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 result, ok := match.Result.(palette.SessionResult); ok {
67+ got[result.Name()] = 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.(palette.SessionResult)
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 TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(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\r"))
114+
115+ require.Same(t, target, ac.currentSession())
116+ require.Same(t, ac, target.client, "canonical handoff reuses the attached client")
117+ require.False(t, ac.overlays.paletteActive())
118+ require.Empty(t, d.paletteRecent, "session selections are not command recency")
119+ awaitFrame(t, sends, ports.MsgOutput)
120+ awaitFrame(t, sends, ports.MsgOutput)
121+}
122+
123+func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
124+ p1, release1 := newBlockingPTY(t)
125+ p2, release2 := newBlockingPTY(t)
126+ defer release1()
127+ defer release2()
128+ d, current, ac, sends := newManualSessionWithPTYs(t, p1)
129+ d.ptys = newFactory(t, p2)
130+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 42}
131+
132+ d.handleInput(current, ac, []byte("\x1b "))
133+ awaitFrame(t, sends, ports.MsgOutput)
134+ d.handleInput(current, ac, []byte("stopped\r"))
135+
136+ require.Equal(t, "stopped", ac.currentSession().name)
137+ require.Equal(t, int64(42), ac.currentSession().createdAt)
138+ require.False(t, ac.overlays.paletteActive())
139+ awaitFrame(t, sends, ports.MsgOutput)
140+ awaitFrame(t, sends, ports.MsgOutput)
141+}
142+
51 func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {143 func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) {
52 p, release := newBlockingPTY(t)144 p, release := newBlockingPTY(t)
53 defer release()145 defer release()
@@ -172,7 +264,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
172 require.True(t, ac.overlays.paletteActive())264 require.True(t, ac.overlays.paletteActive())
173 ac.overlays.paletteMu.Lock()265 ac.overlays.paletteMu.Lock()
174 require.Equal(t, "JRS 1", ac.overlays.palette.Query())266 require.Equal(t, "JRS 1", ac.overlays.palette.Query())
175- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)267+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
176 ac.overlays.paletteMu.Unlock()268 ac.overlays.paletteMu.Unlock()
177 }269 }
178 270
@@ -188,7 +280,7 @@ func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) {
188 require.True(t, ac.overlays.paletteActive())280 require.True(t, ac.overlays.paletteActive())
189 ac.overlays.paletteMu.Lock()281 ac.overlays.paletteMu.Lock()
190 require.Equal(t, "JRS 3", ac.overlays.palette.Query())282 require.Equal(t, "JRS 3", ac.overlays.palette.Query())
191- require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteHints.Feedback)283+ require.Equal(t, "requested recent session is unavailable", ac.overlays.paletteFeedback)
192 ac.overlays.paletteMu.Unlock()284 ac.overlays.paletteMu.Unlock()
193 awaitFrame(t, sends, ports.MsgOutput)285 awaitFrame(t, sends, ports.MsgOutput)
194 }286 }
@@ -205,7 +297,7 @@ func TestPaletteJRSMalformedRankFeedback(t *testing.T) {
205 require.True(t, ac.overlays.paletteActive())297 require.True(t, ac.overlays.paletteActive())
206 ac.overlays.paletteMu.Lock()298 ac.overlays.paletteMu.Lock()
207 require.Equal(t, "JRS 0", ac.overlays.palette.Query())299 require.Equal(t, "JRS 0", ac.overlays.palette.Query())
208- require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteHints.Feedback)300+ require.Equal(t, "rank must be one positive decimal", ac.overlays.paletteFeedback)
209 ac.overlays.paletteMu.Unlock()301 ac.overlays.paletteMu.Unlock()
210 awaitFrame(t, sends, ports.MsgOutput)302 awaitFrame(t, sends, ports.MsgOutput)
211 }303 }
@@ -223,7 +315,7 @@ func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) {
223 ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")315 ac.paletteFailure(staleGeneration, "JRS 1", "stale failure")
224 316
225 ac.overlays.paletteMu.Lock()317 ac.overlays.paletteMu.Lock()
226- require.Empty(t, ac.overlays.paletteHints.Feedback)318+ require.Empty(t, ac.overlays.paletteFeedback)
227 ac.overlays.paletteMu.Unlock()319 ac.overlays.paletteMu.Unlock()
228 awaitFrame(t, sends, ports.MsgOutput)320 awaitFrame(t, sends, ports.MsgOutput)
229 awaitFrame(t, sends, ports.MsgOutput)321 awaitFrame(t, sends, ports.MsgOutput)
@@ -302,9 +394,11 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
302 394
303 d.handleInput(sess, ac, []byte("\x1b "))395 d.handleInput(sess, ac, []byte("\x1b "))
304 awaitFrame(t, sends, ports.MsgOutput)396 awaitFrame(t, sends, ports.MsgOutput)
305- cmd, ok := ac.overlays.palette.Selected()397+ result, ok := ac.overlays.palette.Selected()
306 require.True(t, ok)398 require.True(t, ok)
307- require.Equal(t, "NXT", cmd.Code)399+ cmd, ok := result.(palette.CommandResult)
400+ require.True(t, ok)
401+ require.Equal(t, "NXT", cmd.Command().Code)
308 }402 }
309 403
310 func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {404 func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
@@ -413,15 +507,19 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
413 507
414 d.handlePaletteInput(ac, []byte{0x0e})508 d.handlePaletteInput(ac, []byte{0x0e})
415 awaitFrame(t, sends, ports.MsgOutput)509 awaitFrame(t, sends, ports.MsgOutput)
416- cmd, ok := ac.overlays.palette.Selected()510+ result, ok := ac.overlays.palette.Selected()
511+ require.True(t, ok)
512+ cmd, ok := result.(palette.CommandResult)
417 require.True(t, ok)513 require.True(t, ok)
418- require.Equal(t, "CNS", cmd.Code)514+ require.Equal(t, "CNS", cmd.Command().Code)
419 515
420 d.handlePaletteInput(ac, []byte{0x10})516 d.handlePaletteInput(ac, []byte{0x10})
421 awaitFrame(t, sends, ports.MsgOutput)517 awaitFrame(t, sends, ports.MsgOutput)
422- cmd, ok = ac.overlays.palette.Selected()518+ result, ok = ac.overlays.palette.Selected()
519+ require.True(t, ok)
520+ cmd, ok = result.(palette.CommandResult)
423 require.True(t, ok)521 require.True(t, ok)
424- require.Equal(t, "CNT", cmd.Code)522+ require.Equal(t, "CNT", cmd.Command().Code)
425 }523 }
426 524
427 func TestPaletteModalGeometry(t *testing.T) {525 func TestPaletteModalGeometry(t *testing.T) {
M internal/usecase/daemon/render_pipeline.go +2 -2
@@ -206,8 +206,8 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
206206 if snap.paletteHints != nil {
207207 guidance = snap.paletteHints.Feedback
208208 }
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})
209+ 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, Feedback: snap.paletteFeedback})
211211 }
212212 if snap.promptActive && snap.promptModel != nil {
213213 o.prompt.modal = promptModalFor(snap.promptModel.Title())
@@ -206,8 +206,8 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
206 if snap.paletteHints != nil {206 if snap.paletteHints != nil {
207 guidance = snap.paletteHints.Feedback207 guidance = snap.paletteHints.Feedback
208 }208 }
209- o.paletteGuidance = guidance209+ 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})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, Feedback: snap.paletteFeedback})
211 }211 }
212 if snap.promptActive && snap.promptModel != nil {212 if snap.promptActive && snap.promptModel != nil {
213 o.prompt.modal = promptModalFor(snap.promptModel.Title())213 o.prompt.modal = promptModalFor(snap.promptModel.Title())
M internal/usecase/palette/model.go +14 -5
@@ -20,6 +20,9 @@ type RenderStyles struct {
2020 type RenderOptions struct {
2121 Styles RenderStyles
2222 Guidance string
23+ // Feedback is interaction-scoped daemon feedback. It replaces the selected
24+ // row's contextual detail without adding a searchable result or row.
25+ Feedback string
2326 }
2427
2528 type Model struct {
@@ -275,15 +278,15 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
275278 }
276279 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
277280 if cmd, ok := resultCommand(match.Result); ok {
278- m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance)
281+ m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
279282 continue
280283 }
281- m.renderSession(frame, y+start, style, selection, match)
284+ m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
282285 }
283286 return frame
284287 }
285288
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 string) {
289+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) {
287290 x, highlight := 0, map[int]bool{}
288291 for _, position := range positions {
289292 highlight[position] = true
@@ -305,14 +308,20 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
305308 x++
306309 }
307310 description := cmd.Desc
308- if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
311+ if feedback != "" && selected {
312+ description = feedback
313+ } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
309314 description = guidance
310315 }
311316 ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc))
312317 }
313318
314-func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match) {
319+func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
315320 text := []rune(match.Result.DisplayText())
321+ if feedback != "" && selected {
322+ text = append(text, ' ')
323+ text = append(text, []rune(feedback)...)
324+ }
316325 highlight := map[int]bool{}
317326 prefix := len([]rune("Switch to session "))
318327 for _, position := range match.Positions {
@@ -20,6 +20,9 @@ type RenderStyles struct {
20 type RenderOptions struct {20 type RenderOptions struct {
21 Styles RenderStyles21 Styles RenderStyles
22 Guidance string22 Guidance string
23+ // Feedback is interaction-scoped daemon feedback. It replaces the selected
24+ // row's contextual detail without adding a searchable result or row.
25+ Feedback string
23 }26 }
24 27
25 type Model struct {28 type Model struct {
@@ -275,15 +278,15 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
275 }278 }
276 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})279 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
277 if cmd, ok := resultCommand(match.Result); ok {280 if cmd, ok := resultCommand(match.Result); ok {
278- m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance)281+ m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
279 continue282 continue
280 }283 }
281- m.renderSession(frame, y+start, style, selection, match)284+ m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
282 }285 }
283 return frame286 return frame
284 }287 }
285 288
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 string) {289+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, highlight := 0, map[int]bool{}290 x, highlight := 0, map[int]bool{}
288 for _, position := range positions {291 for _, position := range positions {
289 highlight[position] = true292 highlight[position] = true
@@ -305,14 +308,20 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
305 x++308 x++
306 }309 }
307 description := cmd.Desc310 description := cmd.Desc
308- if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {311+ if feedback != "" && selected {
312+ description = feedback
313+ } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
309 description = guidance314 description = guidance
310 }315 }
311 ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc))316 ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc))
312 }317 }
313 318
314-func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match) {319+func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
315 text := []rune(match.Result.DisplayText())320 text := []rune(match.Result.DisplayText())
321+ if feedback != "" && selected {
322+ text = append(text, ' ')
323+ text = append(text, []rune(feedback)...)
324+ }
316 highlight := map[int]bool{}325 highlight := map[int]bool{}
317 prefix := len([]rune("Switch to session "))326 prefix := len([]rune("Switch to session "))
318 for _, position := range match.Positions {327 for _, position := range match.Positions {
M internal/usecase/palette/model_test.go +7 -0
@@ -366,6 +366,13 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
366366 require.Equal(t, "Switch to session work ", frameRow(frame, 1))
367367 }
368368
369+func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
370+ m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
371+ frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
372+ require.Len(t, m.Matches(), 1)
373+ require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
374+}
375+
369376 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
370377 jrs := cmd("JRS", "Jump", "Jump to recent session")
371378 jrs.Arguments = command.ArgumentsRequired
@@ -366,6 +366,13 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
366 require.Equal(t, "Switch to session work ", frameRow(frame, 1))366 require.Equal(t, "Switch to session work ", frameRow(frame, 1))
367 }367 }
368 368
369+func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
370+ m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
371+ frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
372+ require.Len(t, m.Matches(), 1)
373+ require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
374+}
375+
369 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {376 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
370 jrs := cmd("JRS", "Jump", "Jump to recent session")377 jrs := cmd("JRS", "Jump", "Jump to recent session")
371 jrs.Arguments = command.ArgumentsRequired378 jrs.Arguments = command.ArgumentsRequired

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