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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | 9 | "github.com/bnema/vev/internal/ports" | |
| 10 | 10 | "github.com/bnema/vev/internal/usecase/keys" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | type captureKeyHandler struct { | |
| @@ -97,8 +98,12 @@ func TestApplyConfigRepaintsActivePaletteWithoutReplacingModel(t *testing.T) { | |||
| 97 | 98 | require.Equal(t, query, ac.overlays.palette.Query()) | |
| 98 | 99 | selectedAfter, ok := ac.overlays.palette.Selected() | |
| 99 | 100 | 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) | |
| 102 | 107 | } | |
| 103 | 108 | ||
| 104 | 109 | 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 { | |||
| 26 | 26 | paletteRecent []recentSession // immutable for this palette interaction | |
| 27 | 27 | paletteGeneration uint64 | |
| 28 | 28 | paletteHints palette.ContextualHints | |
| 29 | + | paletteFeedback string | |
| 29 | 30 | palettePending []byte | |
| 30 | 31 | ||
| 31 | 32 | promptMu sync.Mutex | |
| @@ -171,9 +172,10 @@ type overlayRenderSnapshot struct { | |||
| 171 | 172 | paletteModel *palette.Model | |
| 172 | 173 | // paletteHints is a copy captured under paletteMu. Rendering must use this | |
| 173 | 174 | // 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 | |
| 177 | 179 | ||
| 178 | 180 | promptActive bool | |
| 179 | 181 | promptModel *promptui.Model | |
| @@ -221,6 +223,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 221 | 223 | hints := rt.paletteHints | |
| 222 | 224 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | |
| 223 | 225 | snap.paletteHints = &hints | |
| 226 | + | snap.paletteFeedback = rt.paletteFeedback | |
| 224 | 227 | snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...) | |
| 225 | 228 | snap.paletteLocked = true | |
| 226 | 229 | } else { | |
| @@ -26,6 +26,7 @@ type overlayRuntime struct { | |||||
| 26 | paletteRecent []recentSession // immutable for this palette interaction | 26 | paletteRecent []recentSession // immutable for this palette interaction | ||
| 27 | paletteGeneration uint64 | 27 | paletteGeneration uint64 | ||
| 28 | paletteHints palette.ContextualHints | 28 | paletteHints palette.ContextualHints | ||
| 29 | + | paletteFeedback string | |||
| 29 | palettePending []byte | 30 | palettePending []byte | ||
| 30 | 31 | ||||
| 31 | promptMu sync.Mutex | 32 | promptMu sync.Mutex | ||
| @@ -171,9 +172,10 @@ type overlayRenderSnapshot struct { | |||||
| 171 | paletteModel *palette.Model | 172 | paletteModel *palette.Model | ||
| 172 | // paletteHints is a copy captured under paletteMu. Rendering must use this | 173 | // 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.ContextualHints | 175 | + | paletteHints *palette.ContextualHints |
| 175 | - | paletteRecent []recentSession | 176 | + | paletteFeedback string |
| 176 | - | paletteLocked bool | 177 | + | paletteRecent []recentSession |
| 178 | + | paletteLocked bool | |||
| 177 | 179 | ||||
| 178 | promptActive bool | 180 | promptActive bool | ||
| 179 | promptModel *promptui.Model | 181 | promptModel *promptui.Model | ||
| @@ -221,6 +223,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 221 | hints := rt.paletteHints | 223 | hints := rt.paletteHints | ||
| 222 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | 224 | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | ||
| 223 | snap.paletteHints = &hints | 225 | 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 = true | 228 | snap.paletteLocked = true | ||
| 226 | } else { | 229 | } else { | ||
M internal/usecase/daemon/palette.go +99 -40
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sort" | |
| 5 | + | "time" | |
| 6 | + | ||
| 4 | 7 | "github.com/bnema/vev/internal/domain" | |
| 5 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| 6 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -35,16 +38,59 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 35 | 38 | // holding an overlay lock while inspecting live sessions. | |
| 36 | 39 | recent := d.recentSessions(sess) | |
| 37 | 40 | commands := d.paletteCommands() | |
| 41 | + | results := d.paletteResults(sess, commands) | |
| 38 | 42 | ac.overlays.paletteMu.Lock() | |
| 39 | 43 | ac.overlays.paletteGeneration++ | |
| 40 | - | ac.overlays.palette = palette.New(commands) | |
| 44 | + | ac.overlays.palette = palette.New(results) | |
| 41 | 45 | ac.overlays.paletteRecent = recent | |
| 42 | 46 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 47 | + | ac.overlays.paletteFeedback = "" | |
| 43 | 48 | ac.overlays.palettePending = nil | |
| 44 | 49 | ac.overlays.paletteMu.Unlock() | |
| 45 | 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 | 94 | func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints { | |
| 49 | 95 | names := make([]string, len(recent)) | |
| 50 | 96 | for i, entry := range recent { | |
| @@ -104,6 +150,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 104 | 150 | return | |
| 105 | 151 | } | |
| 106 | 152 | var cmd command.Command | |
| 153 | + | var sessionTarget palette.SessionResult | |
| 154 | + | var hasSessionTarget bool | |
| 107 | 155 | var args []string | |
| 108 | 156 | var recent []recentSession | |
| 109 | 157 | var generation uint64 | |
| @@ -117,48 +165,49 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 117 | 165 | return | |
| 118 | 166 | } | |
| 119 | 167 | 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 }, | |
| 132 | 174 | 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 { | |
| 136 | 177 | changed = true | |
| 137 | 178 | return | |
| 138 | 179 | } | |
| 139 | 180 | 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...) | |
| 149 | 194 | } | |
| 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 | |
| 152 | 201 | } | |
| 153 | 202 | generation = ac.overlays.paletteGeneration | |
| 154 | 203 | execute = true | |
| 155 | 204 | }, | |
| 156 | 205 | }) | |
| 157 | 206 | if changed { | |
| 207 | + | ac.overlays.paletteFeedback = "" | |
| 158 | 208 | active, ok := ac.overlays.palette.ArgumentCommand() | |
| 159 | 209 | 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)) | |
| 162 | 211 | } else { | |
| 163 | 212 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 164 | 213 | } | |
| @@ -178,8 +227,25 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 178 | 227 | return | |
| 179 | 228 | } | |
| 180 | 229 | ||
| 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 | + | ||
| 183 | 249 | if cmd.Slug == "jump-recent-session" { | |
| 184 | 250 | rank, err := command.ParsePositiveDecimal(args) | |
| 185 | 251 | if err != nil { | |
| @@ -187,16 +253,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 187 | 253 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 188 | 254 | 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 | 256 | exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent} | |
| 193 | 257 | if err := exec.JumpRecentSession(rank); err != nil { | |
| 194 | 258 | ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable") | |
| 195 | 259 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 196 | 260 | return | |
| 197 | 261 | } | |
| 198 | - | // Publication remains generation-safe; do not let stale work close a | |
| 199 | - | // newer palette interaction. | |
| 200 | 262 | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 201 | 263 | d.recordPaletteUse(cmd.Code) | |
| 202 | 264 | d.invalidateRender(ac.currentSession(), ac, true, "palette.go") | |
| @@ -226,6 +288,7 @@ func (ac *attachedClient) clearPaletteLocked() { | |||
| 226 | 288 | ac.overlays.palette = nil | |
| 227 | 289 | ac.overlays.paletteRecent = nil | |
| 228 | 290 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 291 | + | ac.overlays.paletteFeedback = "" | |
| 229 | 292 | ac.overlays.palettePending = nil | |
| 230 | 293 | } | |
| 231 | 294 | ||
| @@ -235,11 +298,7 @@ func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback s | |||
| 235 | 298 | if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery { | |
| 236 | 299 | return | |
| 237 | 300 | } | |
| 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 | |
| 243 | 302 | } | |
| 244 | 303 | ||
| 245 | 304 | func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool { | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package daemon | 1 | 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 = recent | 45 | ac.overlays.paletteRecent = recent | ||
| 42 | ac.overlays.paletteHints = palette.ContextualHints{} | 46 | ac.overlays.paletteHints = palette.ContextualHints{} | ||
| 47 | + | ac.overlays.paletteFeedback = "" | |||
| 43 | ac.overlays.palettePending = nil | 48 | 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 | return | 150 | return | ||
| 105 | } | 151 | } | ||
| 106 | var cmd command.Command | 152 | var cmd command.Command | ||
| 153 | + | var sessionTarget palette.SessionResult | |||
| 154 | + | var hasSessionTarget bool | |||
| 107 | var args []string | 155 | var args []string | ||
| 108 | var recent []recentSession | 156 | var recent []recentSession | ||
| 109 | var generation uint64 | 157 | var generation uint64 | ||
| @@ -117,48 +165,49 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 117 | return | 165 | 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 = true | 170 | + | 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 bool | 175 | + | selected, ok := ac.overlays.palette.Selected() |
| 134 | - | cmd, selected = ac.overlays.palette.Selected() | 176 | + | if !ok { |
| 135 | - | if !selected { | |||
| 136 | changed = true | 177 | changed = true | ||
| 137 | return | 178 | 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 so | 181 | + | 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 selected | 183 | + | 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 = true | 188 | + | if !valid { |
| 148 | - | return | 189 | + | changed = true |
| 190 | + | return | |||
| 191 | + | } | |||
| 192 | + | args = action.Args | |||
| 193 | + | recent = append([]recentSession(nil), ac.overlays.paletteRecent...) | |||
| 149 | } | 194 | } | ||
| 150 | - | args = action.Args | 195 | + | 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.paletteGeneration | 202 | generation = ac.overlays.paletteGeneration | ||
| 154 | execute = true | 203 | 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 | return | 227 | return | ||
| 179 | } | 228 | } | ||
| 180 | 229 | ||||
| 181 | - | // Revalidate live target without paletteMu. A captured ID is never replaced | 230 | + | 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 | return | 254 | 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 | return | 260 | 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 = nil | 288 | ac.overlays.palette = nil | ||
| 227 | ac.overlays.paletteRecent = nil | 289 | ac.overlays.paletteRecent = nil | ||
| 228 | ac.overlays.paletteHints = palette.ContextualHints{} | 290 | ac.overlays.paletteHints = palette.ContextualHints{} | ||
| 291 | + | ac.overlays.paletteFeedback = "" | |||
| 229 | ac.overlays.palettePending = nil | 292 | 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 | return | 299 | return | ||
| 237 | } | 300 | } | ||
| 238 | - | // Rendering consumes the immutable contextual hint snapshot; preserve ranks | 301 | + | 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) { | |||
| 48 | 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 | 143 | func TestPaletteJRSActivatesOnlyExactContextualHint(t *testing.T) { | |
| 52 | 144 | p, release := newBlockingPTY(t) | |
| 53 | 145 | defer release() | |
| @@ -172,7 +264,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||
| 172 | 264 | require.True(t, ac.overlays.paletteActive()) | |
| 173 | 265 | ac.overlays.paletteMu.Lock() | |
| 174 | 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 | 268 | ac.overlays.paletteMu.Unlock() | |
| 177 | 269 | } | |
| 178 | 270 | ||
| @@ -188,7 +280,7 @@ func TestPaletteJRSOutOfRangeKeepsPaletteOpenWithoutClamping(t *testing.T) { | |||
| 188 | 280 | require.True(t, ac.overlays.paletteActive()) | |
| 189 | 281 | ac.overlays.paletteMu.Lock() | |
| 190 | 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 | 284 | ac.overlays.paletteMu.Unlock() | |
| 193 | 285 | awaitFrame(t, sends, ports.MsgOutput) | |
| 194 | 286 | } | |
| @@ -205,7 +297,7 @@ func TestPaletteJRSMalformedRankFeedback(t *testing.T) { | |||
| 205 | 297 | require.True(t, ac.overlays.paletteActive()) | |
| 206 | 298 | ac.overlays.paletteMu.Lock() | |
| 207 | 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 | 301 | ac.overlays.paletteMu.Unlock() | |
| 210 | 302 | awaitFrame(t, sends, ports.MsgOutput) | |
| 211 | 303 | } | |
| @@ -223,7 +315,7 @@ func TestPaletteFailureDoesNotOverwriteNewerInteraction(t *testing.T) { | |||
| 223 | 315 | ac.paletteFailure(staleGeneration, "JRS 1", "stale failure") | |
| 224 | 316 | ||
| 225 | 317 | ac.overlays.paletteMu.Lock() | |
| 226 | - | require.Empty(t, ac.overlays.paletteHints.Feedback) | |
| 318 | + | require.Empty(t, ac.overlays.paletteFeedback) | |
| 227 | 319 | ac.overlays.paletteMu.Unlock() | |
| 228 | 320 | awaitFrame(t, sends, ports.MsgOutput) | |
| 229 | 321 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -302,9 +394,11 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | |||
| 302 | 394 | ||
| 303 | 395 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 304 | 396 | awaitFrame(t, sends, ports.MsgOutput) | |
| 305 | - | cmd, ok := ac.overlays.palette.Selected() | |
| 397 | + | result, ok := ac.overlays.palette.Selected() | |
| 306 | 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 | 404 | func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |
| @@ -413,15 +507,19 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) { | |||
| 413 | 507 | ||
| 414 | 508 | d.handlePaletteInput(ac, []byte{0x0e}) | |
| 415 | 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 | 513 | require.True(t, ok) | |
| 418 | - | require.Equal(t, "CNS", cmd.Code) | |
| 514 | + | require.Equal(t, "CNS", cmd.Command().Code) | |
| 419 | 515 | ||
| 420 | 516 | d.handlePaletteInput(ac, []byte{0x10}) | |
| 421 | 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 | 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 | 525 | 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 | |||
| 206 | 206 | if snap.paletteHints != nil { | |
| 207 | 207 | guidance = snap.paletteHints.Feedback | |
| 208 | 208 | } | |
| 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}) | |
| 211 | 211 | } | |
| 212 | 212 | if snap.promptActive && snap.promptModel != nil { | |
| 213 | 213 | 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.Feedback | 207 | guidance = snap.paletteHints.Feedback | ||
| 208 | } | 208 | } | ||
| 209 | - | o.paletteGuidance = 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}) | 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 { | |||
| 20 | 20 | type RenderOptions struct { | |
| 21 | 21 | Styles RenderStyles | |
| 22 | 22 | 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 | 28 | type Model struct { | |
| @@ -275,15 +278,15 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 275 | 278 | } | |
| 276 | 279 | ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 277 | 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 | 282 | 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 | 286 | 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 | 290 | x, highlight := 0, map[int]bool{} | |
| 288 | 291 | for _, position := range positions { | |
| 289 | 292 | highlight[position] = true | |
| @@ -305,14 +308,20 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des | |||
| 305 | 308 | x++ | |
| 306 | 309 | } | |
| 307 | 310 | 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 | 314 | description = guidance | |
| 310 | 315 | } | |
| 311 | 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 | 320 | text := []rune(match.Result.DisplayText()) | |
| 321 | + | if feedback != "" && selected { | |
| 322 | + | text = append(text, ' ') | |
| 323 | + | text = append(text, []rune(feedback)...) | |
| 324 | + | } | |
| 316 | 325 | highlight := map[int]bool{} | |
| 317 | 326 | prefix := len([]rune("Switch to session ")) | |
| 318 | 327 | for _, position := range match.Positions { | |
| @@ -20,6 +20,9 @@ type RenderStyles struct { | |||||
| 20 | type RenderOptions struct { | 20 | type RenderOptions struct { | ||
| 21 | Styles RenderStyles | 21 | Styles RenderStyles | ||
| 22 | Guidance string | 22 | 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 | continue | 282 | 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 frame | 286 | 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] = true | 292 | 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.Desc | 310 | 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 = guidance | 314 | 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 | |||
| 366 | 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 | 376 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | |
| 370 | 377 | jrs := cmd("JRS", "Jump", "Jump to recent session") | |
| 371 | 378 | 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.ArgumentsRequired | 378 | jrs.Arguments = command.ArgumentsRequired | ||