feat(status): show contextual recent session ranks
- commit
- 9818e1d3c7bde743e45cbb82c38f4231a4496f14
- author
- brice · · 2026-07-11 12:26 UTC
- parent
- 38162d7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +185 -40
M internal/usecase/daemon/overlay_runtime.go +10 -0
| @@ -168,6 +168,10 @@ type overlayRenderSnapshot struct { | |||
| 168 | 168 | ||
| 169 | 169 | paletteActive bool | |
| 170 | 170 | paletteModel *palette.Model | |
| 171 | + | // paletteHints is a copy captured under paletteMu. Rendering must use this | |
| 172 | + | // immutable interaction snapshot rather than consult live session state. | |
| 173 | + | paletteHints *palette.ContextualHints | |
| 174 | + | paletteRecent []recentSession | |
| 171 | 175 | paletteLocked bool | |
| 172 | 176 | ||
| 173 | 177 | promptActive bool | |
| @@ -212,6 +216,12 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 212 | 216 | rt.paletteMu.Lock() | |
| 213 | 217 | snap.paletteModel = rt.palette | |
| 214 | 218 | snap.paletteActive = snap.paletteModel != nil | |
| 219 | + | if snap.paletteActive { | |
| 220 | + | hints := rt.paletteHints | |
| 221 | + | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | |
| 222 | + | snap.paletteHints = &hints | |
| 223 | + | snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...) | |
| 224 | + | } | |
| 215 | 225 | if snap.paletteActive { | |
| 216 | 226 | snap.paletteLocked = true | |
| 217 | 227 | } else { | |
| @@ -168,6 +168,10 @@ type overlayRenderSnapshot struct { | |||||
| 168 | 168 | ||||
| 169 | paletteActive bool | 169 | paletteActive bool | ||
| 170 | paletteModel *palette.Model | 170 | paletteModel *palette.Model | ||
| 171 | + | // paletteHints is a copy captured under paletteMu. Rendering must use this | |||
| 172 | + | // immutable interaction snapshot rather than consult live session state. | |||
| 173 | + | paletteHints *palette.ContextualHints | |||
| 174 | + | paletteRecent []recentSession | |||
| 171 | paletteLocked bool | 175 | paletteLocked bool | ||
| 172 | 176 | ||||
| 173 | promptActive bool | 177 | promptActive bool | ||
| @@ -212,6 +216,12 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 212 | rt.paletteMu.Lock() | 216 | rt.paletteMu.Lock() | ||
| 213 | snap.paletteModel = rt.palette | 217 | snap.paletteModel = rt.palette | ||
| 214 | snap.paletteActive = snap.paletteModel != nil | 218 | snap.paletteActive = snap.paletteModel != nil | ||
| 219 | + | if snap.paletteActive { | |||
| 220 | + | hints := rt.paletteHints | |||
| 221 | + | hints.Recent = append([]palette.RecentSessionHint(nil), hints.Recent...) | |||
| 222 | + | snap.paletteHints = &hints | |||
| 223 | + | snap.paletteRecent = append([]recentSession(nil), rt.paletteRecent...) | |||
| 224 | + | } | |||
| 215 | if snap.paletteActive { | 225 | if snap.paletteActive { | ||
| 216 | snap.paletteLocked = true | 226 | snap.paletteLocked = true | ||
| 217 | } else { | 227 | } else { | ||
M internal/usecase/daemon/palette.go +7 -9
| @@ -121,13 +121,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 121 | 121 | rune: func(r rune) { | |
| 122 | 122 | ac.overlays.palette.Insert(r) | |
| 123 | 123 | ac.overlays.paletteFeedback = "" | |
| 124 | - | ac.overlays.palette.SetFeedback("") | |
| 125 | 124 | changed = true | |
| 126 | 125 | }, | |
| 127 | 126 | backspace: func() { | |
| 128 | 127 | ac.overlays.palette.Backspace() | |
| 129 | 128 | ac.overlays.paletteFeedback = "" | |
| 130 | - | ac.overlays.palette.SetFeedback("") | |
| 131 | 129 | changed = true | |
| 132 | 130 | }, | |
| 133 | 131 | up: func() { ac.overlays.palette.Up(); changed = true }, | |
| @@ -138,7 +136,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 138 | 136 | cmd, selected = ac.overlays.palette.Selected() | |
| 139 | 137 | if !selected { | |
| 140 | 138 | ac.overlays.paletteFeedback = "invalid command arguments" | |
| 141 | - | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) | |
| 142 | 139 | changed = true | |
| 143 | 140 | return | |
| 144 | 141 | } | |
| @@ -151,7 +148,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 151 | 148 | action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery) | |
| 152 | 149 | if !valid { | |
| 153 | 150 | ac.overlays.paletteFeedback = "invalid command arguments" | |
| 154 | - | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) | |
| 155 | 151 | changed = true | |
| 156 | 152 | return | |
| 157 | 153 | } | |
| @@ -167,10 +163,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 167 | 163 | if ok && active.Code == "JRS" { | |
| 168 | 164 | hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active)) | |
| 169 | 165 | ac.overlays.paletteHints = hints | |
| 170 | - | ac.overlays.palette.SetFeedback(hints.Feedback) | |
| 171 | 166 | } else { | |
| 172 | 167 | ac.overlays.paletteHints = palette.ContextualHints{} | |
| 173 | - | ac.overlays.palette.SetFeedback("") | |
| 174 | 168 | } | |
| 175 | 169 | } | |
| 176 | 170 | if cancel { | |
| @@ -247,7 +241,11 @@ func (d *Daemon) paletteFailure(ac *attachedClient, generation uint64, rawQuery, | |||
| 247 | 241 | return | |
| 248 | 242 | } | |
| 249 | 243 | ac.overlays.paletteFeedback = feedback | |
| 250 | - | ac.overlays.palette.SetFeedback(feedback) | |
| 244 | + | // Rendering consumes the immutable contextual hint snapshot, not Model's | |
| 245 | + | // feedback state. Preserve ranks while making stale-target feedback visible. | |
| 246 | + | if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions { | |
| 247 | + | ac.overlays.paletteHints.Feedback = feedback | |
| 248 | + | } | |
| 251 | 249 | } | |
| 252 | 250 | func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool { | |
| 253 | 251 | ac.overlays.paletteMu.Lock() | |
| @@ -410,10 +408,10 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||
| 410 | 408 | return nil | |
| 411 | 409 | } | |
| 412 | 410 | ||
| 413 | - | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 411 | + | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 414 | 412 | styleSet := resolveThemeStyles(styles) | |
| 415 | 413 | modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg) | |
| 416 | 414 | return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | |
| 417 | - | return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}) | |
| 415 | + | return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}, Guidance: guidance}) | |
| 418 | 416 | }) | |
| 419 | 417 | } | |
| @@ -121,13 +121,11 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 121 | rune: func(r rune) { | 121 | rune: func(r rune) { | ||
| 122 | ac.overlays.palette.Insert(r) | 122 | ac.overlays.palette.Insert(r) | ||
| 123 | ac.overlays.paletteFeedback = "" | 123 | ac.overlays.paletteFeedback = "" | ||
| 124 | - | ac.overlays.palette.SetFeedback("") | |||
| 125 | changed = true | 124 | changed = true | ||
| 126 | }, | 125 | }, | ||
| 127 | backspace: func() { | 126 | backspace: func() { | ||
| 128 | ac.overlays.palette.Backspace() | 127 | ac.overlays.palette.Backspace() | ||
| 129 | ac.overlays.paletteFeedback = "" | 128 | ac.overlays.paletteFeedback = "" | ||
| 130 | - | ac.overlays.palette.SetFeedback("") | |||
| 131 | changed = true | 129 | changed = true | ||
| 132 | }, | 130 | }, | ||
| 133 | up: func() { ac.overlays.palette.Up(); changed = true }, | 131 | up: func() { ac.overlays.palette.Up(); changed = true }, | ||
| @@ -138,7 +136,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 138 | cmd, selected = ac.overlays.palette.Selected() | 136 | cmd, selected = ac.overlays.palette.Selected() | ||
| 139 | if !selected { | 137 | if !selected { | ||
| 140 | ac.overlays.paletteFeedback = "invalid command arguments" | 138 | ac.overlays.paletteFeedback = "invalid command arguments" | ||
| 141 | - | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) | |||
| 142 | changed = true | 139 | changed = true | ||
| 143 | return | 140 | return | ||
| 144 | } | 141 | } | ||
| @@ -151,7 +148,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 151 | action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery) | 148 | action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery) | ||
| 152 | if !valid { | 149 | if !valid { | ||
| 153 | ac.overlays.paletteFeedback = "invalid command arguments" | 150 | ac.overlays.paletteFeedback = "invalid command arguments" | ||
| 154 | - | ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback) | |||
| 155 | changed = true | 151 | changed = true | ||
| 156 | return | 152 | return | ||
| 157 | } | 153 | } | ||
| @@ -167,10 +163,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 167 | if ok && active.Code == "JRS" { | 163 | if ok && active.Code == "JRS" { | ||
| 168 | hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active)) | 164 | hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active)) | ||
| 169 | ac.overlays.paletteHints = hints | 165 | ac.overlays.paletteHints = hints | ||
| 170 | - | ac.overlays.palette.SetFeedback(hints.Feedback) | |||
| 171 | } else { | 166 | } else { | ||
| 172 | ac.overlays.paletteHints = palette.ContextualHints{} | 167 | ac.overlays.paletteHints = palette.ContextualHints{} | ||
| 173 | - | ac.overlays.palette.SetFeedback("") | |||
| 174 | } | 168 | } | ||
| 175 | } | 169 | } | ||
| 176 | if cancel { | 170 | if cancel { | ||
| @@ -247,7 +241,11 @@ func (d *Daemon) paletteFailure(ac *attachedClient, generation uint64, rawQuery, | |||||
| 247 | return | 241 | return | ||
| 248 | } | 242 | } | ||
| 249 | ac.overlays.paletteFeedback = feedback | 243 | ac.overlays.paletteFeedback = feedback | ||
| 250 | - | ac.overlays.palette.SetFeedback(feedback) | 244 | + | // Rendering consumes the immutable contextual hint snapshot, not Model's |
| 245 | + | // feedback state. Preserve ranks while making stale-target feedback visible. | |||
| 246 | + | if ac.overlays.paletteHints.Kind == command.ContextHintRecentSessions { | |||
| 247 | + | ac.overlays.paletteHints.Feedback = feedback | |||
| 248 | + | } | |||
| 251 | } | 249 | } | ||
| 252 | func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool { | 250 | func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool { | ||
| 253 | ac.overlays.paletteMu.Lock() | 251 | ac.overlays.paletteMu.Lock() | ||
| @@ -410,10 +408,10 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||||
| 410 | return nil | 408 | return nil | ||
| 411 | } | 409 | } | ||
| 412 | 410 | ||||
| 413 | - | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | 411 | + | func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { |
| 414 | styleSet := resolveThemeStyles(styles) | 412 | styleSet := resolveThemeStyles(styles) | ||
| 415 | modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg) | 413 | modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg) | ||
| 416 | return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | 414 | return composeModalClientFrame(base, modal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame { | ||
| 417 | - | return model.Render(size, palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}) | 415 | + | return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Selection: styleSet.selection, Description: styleSet.paletteDesc}, Guidance: guidance}) |
| 418 | }) | 416 | }) | ||
| 419 | } | 417 | } | ||
M internal/usecase/daemon/palette_test.go +1 -1
| @@ -408,7 +408,7 @@ func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) { | |||
| 408 | 408 | {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}}, | |
| 409 | 409 | } { | |
| 410 | 410 | t.Run(tt.name, func(t *testing.T) { | |
| 411 | - | frame, _ := composePaletteClientFrame(model, base, tt.cfg) | |
| 411 | + | frame, _ := composePaletteClientFrame(model, base, tt.cfg, "") | |
| 412 | 412 | require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune) | |
| 413 | 413 | }) | |
| 414 | 414 | } | |
| @@ -408,7 +408,7 @@ func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) { | |||||
| 408 | {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}}, | 408 | {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}}, | ||
| 409 | } { | 409 | } { | ||
| 410 | t.Run(tt.name, func(t *testing.T) { | 410 | t.Run(tt.name, func(t *testing.T) { | ||
| 411 | - | frame, _ := composePaletteClientFrame(model, base, tt.cfg) | 411 | + | frame, _ := composePaletteClientFrame(model, base, tt.cfg, "") |
| 412 | require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune) | 412 | require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune) | ||
| 413 | }) | 413 | }) | ||
| 414 | } | 414 | } | ||
M internal/usecase/daemon/render.go +8 -1
| @@ -369,6 +369,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 369 | 369 | } | |
| 370 | 370 | d.refreshSessionFocusedTitles(sess) | |
| 371 | 371 | bars := d.barStateForClient(sess, ac, overlays.copyFeedback) | |
| 372 | + | // Contextual ranks are completely captured under paletteMu. Do not consult | |
| 373 | + | // the domain while composing: an interaction's MRU ordering is immutable. | |
| 374 | + | bars.rankedRecent = rankedRecentForHints(overlays.paletteHints, overlays.paletteRecent) | |
| 372 | 375 | bars.theme = ac.getTheme() | |
| 373 | 376 | _, attentionVisible := pulseStyle(bars.attentionFrame) | |
| 374 | 377 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| @@ -454,7 +457,11 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 454 | 457 | frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles) | |
| 455 | 458 | } | |
| 456 | 459 | if overlays.paletteActive { | |
| 457 | - | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, styles) | |
| 460 | + | guidance := "" | |
| 461 | + | if overlays.paletteHints != nil { | |
| 462 | + | guidance = overlays.paletteHints.Feedback | |
| 463 | + | } | |
| 464 | + | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles) | |
| 458 | 465 | } | |
| 459 | 466 | if overlays.promptActive { | |
| 460 | 467 | frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles) | |
| @@ -369,6 +369,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 369 | } | 369 | } | ||
| 370 | d.refreshSessionFocusedTitles(sess) | 370 | d.refreshSessionFocusedTitles(sess) | ||
| 371 | bars := d.barStateForClient(sess, ac, overlays.copyFeedback) | 371 | bars := d.barStateForClient(sess, ac, overlays.copyFeedback) | ||
| 372 | + | // Contextual ranks are completely captured under paletteMu. Do not consult | |||
| 373 | + | // the domain while composing: an interaction's MRU ordering is immutable. | |||
| 374 | + | bars.rankedRecent = rankedRecentForHints(overlays.paletteHints, overlays.paletteRecent) | |||
| 372 | bars.theme = ac.getTheme() | 375 | bars.theme = ac.getTheme() | ||
| 373 | _, attentionVisible := pulseStyle(bars.attentionFrame) | 376 | _, attentionVisible := pulseStyle(bars.attentionFrame) | ||
| 374 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | 377 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | ||
| @@ -454,7 +457,11 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 454 | frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles) | 457 | frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles) | ||
| 455 | } | 458 | } | ||
| 456 | if overlays.paletteActive { | 459 | if overlays.paletteActive { | ||
| 457 | - | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, styles) | 460 | + | guidance := "" |
| 461 | + | if overlays.paletteHints != nil { | |||
| 462 | + | guidance = overlays.paletteHints.Feedback | |||
| 463 | + | } | |||
| 464 | + | frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles) | |||
| 458 | } | 465 | } | ||
| 459 | if overlays.promptActive { | 466 | if overlays.promptActive { | ||
| 460 | frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles) | 467 | frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles) | ||
M internal/usecase/daemon/status.go +89 -4
| @@ -29,6 +29,8 @@ import ( | |||
| 29 | 29 | "strconv" | |
| 30 | 30 | "time" | |
| 31 | 31 | ||
| 32 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 33 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 32 | 34 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 33 | 35 | "github.com/bnema/vev/internal/usecase/ui" | |
| 34 | 36 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -82,14 +84,48 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) | |||
| 82 | 84 | x := 0 | |
| 83 | 85 | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | |
| 84 | 86 | writeStatusText(row, &x, " "+state.status.session+" ", styles.accent) | |
| 85 | - | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 86 | - | for i, sess := range fittedMRU { | |
| 87 | - | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | |
| 88 | - | drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame) | |
| 87 | + | if state.rankedRecent != nil { | |
| 88 | + | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { | |
| 89 | + | style := styles.statusBar // contextual ranks deliberately do not fade. | |
| 90 | + | if sess.selected { | |
| 91 | + | style = styles.accent | |
| 92 | + | } | |
| 93 | + | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | |
| 94 | + | } | |
| 95 | + | } else { | |
| 96 | + | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 97 | + | for i, sess := range fittedMRU { | |
| 98 | + | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | |
| 99 | + | drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame) | |
| 100 | + | } | |
| 89 | 101 | } | |
| 90 | 102 | drawRightPlainText(row, rightText, x, styles.statusBar) | |
| 91 | 103 | } | |
| 92 | 104 | ||
| 105 | + | type rankedRecent struct { | |
| 106 | + | rank int | |
| 107 | + | name string | |
| 108 | + | ephemeral bool | |
| 109 | + | attention bool | |
| 110 | + | selected bool | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) { | |
| 114 | + | prefixStyle := style | |
| 115 | + | prefixStyle.Bold = true | |
| 116 | + | writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle) | |
| 117 | + | name := sess.name | |
| 118 | + | if sess.ephemeral { | |
| 119 | + | name += "*" | |
| 120 | + | } | |
| 121 | + | writeStatusText(row, x, name, style) | |
| 122 | + | if sess.attention { | |
| 123 | + | writeStatusText(row, x, " ", style) | |
| 124 | + | writeBell(row, x, attentionFrame) | |
| 125 | + | } | |
| 126 | + | writeStatusText(row, x, " ", style) | |
| 127 | + | } | |
| 128 | + | ||
| 93 | 129 | func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) { | |
| 94 | 130 | if ephemeral { | |
| 95 | 131 | name += "*" | |
| @@ -141,6 +177,7 @@ type barState struct { | |||
| 141 | 177 | bottomRight string | |
| 142 | 178 | copyFeedback string | |
| 143 | 179 | mru []recentSession | |
| 180 | + | rankedRecent []rankedRecent | |
| 144 | 181 | attentionFrame int | |
| 145 | 182 | // theme is the client's terminal theme, if reported. Its zero value | |
| 146 | 183 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | |
| @@ -184,6 +221,24 @@ func tabDisplayName(tb *tab, index int) string { | |||
| 184 | 221 | return strconv.Itoa(index + 1) | |
| 185 | 222 | } | |
| 186 | 223 | ||
| 224 | + | func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession) []rankedRecent { | |
| 225 | + | if hints == nil || hints.Kind != command.ContextHintRecentSessions { | |
| 226 | + | return nil | |
| 227 | + | } | |
| 228 | + | entries := make([]rankedRecent, 0, len(hints.Recent)) | |
| 229 | + | for i, hint := range hints.Recent { | |
| 230 | + | entry := rankedRecent{rank: hint.Rank, name: hint.Name, selected: hint.Rank == hints.SelectedRank} | |
| 231 | + | // recent was copied with the hint under paletteMu, so this only enriches | |
| 232 | + | // the render snapshot and never performs a live domain lookup. | |
| 233 | + | if i < len(recent) { | |
| 234 | + | entry.ephemeral = recent[i].ephemeral | |
| 235 | + | entry.attention = recent[i].attention | |
| 236 | + | } | |
| 237 | + | entries = append(entries, entry) | |
| 238 | + | } | |
| 239 | + | return entries | |
| 240 | + | } | |
| 241 | + | ||
| 187 | 242 | func (d *Daemon) barStateForClient(cur *session, _ *attachedClient, copyFeedback string) barState { | |
| 188 | 243 | return d.barStateFor(cur, copyFeedback) | |
| 189 | 244 | } | |
| @@ -272,6 +327,36 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||
| 272 | 327 | return labels | |
| 273 | 328 | } | |
| 274 | 329 | ||
| 330 | + | func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent { | |
| 331 | + | reserve := 1 | |
| 332 | + | if feedback != "" { | |
| 333 | + | reserve = statusTextWidth(feedback) + 2 | |
| 334 | + | } | |
| 335 | + | budget := rowLen - leftUsed - reserve | |
| 336 | + | if budget <= 0 { | |
| 337 | + | return nil | |
| 338 | + | } | |
| 339 | + | cost := func(e rankedRecent) int { | |
| 340 | + | name := e.name | |
| 341 | + | if e.ephemeral { | |
| 342 | + | name += "*" | |
| 343 | + | } | |
| 344 | + | width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(name) | |
| 345 | + | if e.attention { | |
| 346 | + | width += 1 + renderer.RuneWidth(attentionGlyph) | |
| 347 | + | } | |
| 348 | + | return width | |
| 349 | + | } | |
| 350 | + | used := 0 | |
| 351 | + | for i, entry := range entries { | |
| 352 | + | if used+cost(entry) > budget { | |
| 353 | + | return entries[:i] // whole entries only; ranks remain their original MRU ranks. | |
| 354 | + | } | |
| 355 | + | used += cost(entry) | |
| 356 | + | } | |
| 357 | + | return entries | |
| 358 | + | } | |
| 359 | + | ||
| 275 | 360 | func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession { | |
| 276 | 361 | // With no feedback, keep one blank trailing cell; with feedback, reserve | |
| 277 | 362 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | |
| @@ -29,6 +29,8 @@ import ( | |||||
| 29 | "strconv" | 29 | "strconv" | ||
| 30 | "time" | 30 | "time" | ||
| 31 | 31 | ||||
| 32 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 33 | + | "github.com/bnema/vev/internal/usecase/palette" | |||
| 32 | themeui "github.com/bnema/vev/internal/usecase/theme" | 34 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 33 | "github.com/bnema/vev/internal/usecase/ui" | 35 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 34 | "github.com/bnema/vev/pkg/renderer" | 36 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -82,14 +84,48 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) | |||||
| 82 | x := 0 | 84 | x := 0 | ||
| 83 | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | 85 | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | ||
| 84 | writeStatusText(row, &x, " "+state.status.session+" ", styles.accent) | 86 | writeStatusText(row, &x, " "+state.status.session+" ", styles.accent) | ||
| 85 | - | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | 87 | + | if state.rankedRecent != nil { |
| 86 | - | for i, sess := range fittedMRU { | 88 | + | for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) { |
| 87 | - | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | 89 | + | style := styles.statusBar // contextual ranks deliberately do not fade. |
| 88 | - | drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame) | 90 | + | if sess.selected { |
| 91 | + | style = styles.accent | |||
| 92 | + | } | |||
| 93 | + | drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame) | |||
| 94 | + | } | |||
| 95 | + | } else { | |||
| 96 | + | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |||
| 97 | + | for i, sess := range fittedMRU { | |||
| 98 | + | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | |||
| 99 | + | drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame) | |||
| 100 | + | } | |||
| 89 | } | 101 | } | ||
| 90 | drawRightPlainText(row, rightText, x, styles.statusBar) | 102 | drawRightPlainText(row, rightText, x, styles.statusBar) | ||
| 91 | } | 103 | } | ||
| 92 | 104 | ||||
| 105 | + | type rankedRecent struct { | |||
| 106 | + | rank int | |||
| 107 | + | name string | |||
| 108 | + | ephemeral bool | |||
| 109 | + | attention bool | |||
| 110 | + | selected bool | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) { | |||
| 114 | + | prefixStyle := style | |||
| 115 | + | prefixStyle.Bold = true | |||
| 116 | + | writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle) | |||
| 117 | + | name := sess.name | |||
| 118 | + | if sess.ephemeral { | |||
| 119 | + | name += "*" | |||
| 120 | + | } | |||
| 121 | + | writeStatusText(row, x, name, style) | |||
| 122 | + | if sess.attention { | |||
| 123 | + | writeStatusText(row, x, " ", style) | |||
| 124 | + | writeBell(row, x, attentionFrame) | |||
| 125 | + | } | |||
| 126 | + | writeStatusText(row, x, " ", style) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 93 | func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) { | 129 | func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) { | ||
| 94 | if ephemeral { | 130 | if ephemeral { | ||
| 95 | name += "*" | 131 | name += "*" | ||
| @@ -141,6 +177,7 @@ type barState struct { | |||||
| 141 | bottomRight string | 177 | bottomRight string | ||
| 142 | copyFeedback string | 178 | copyFeedback string | ||
| 143 | mru []recentSession | 179 | mru []recentSession | ||
| 180 | + | rankedRecent []rankedRecent | |||
| 144 | attentionFrame int | 181 | attentionFrame int | ||
| 145 | // theme is the client's terminal theme, if reported. Its zero value | 182 | // theme is the client's terminal theme, if reported. Its zero value | ||
| 146 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | 183 | // (Theme{}, Known: false) is a valid "no theme" default that resolves to | ||
| @@ -184,6 +221,24 @@ func tabDisplayName(tb *tab, index int) string { | |||||
| 184 | return strconv.Itoa(index + 1) | 221 | return strconv.Itoa(index + 1) | ||
| 185 | } | 222 | } | ||
| 186 | 223 | ||||
| 224 | + | func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession) []rankedRecent { | |||
| 225 | + | if hints == nil || hints.Kind != command.ContextHintRecentSessions { | |||
| 226 | + | return nil | |||
| 227 | + | } | |||
| 228 | + | entries := make([]rankedRecent, 0, len(hints.Recent)) | |||
| 229 | + | for i, hint := range hints.Recent { | |||
| 230 | + | entry := rankedRecent{rank: hint.Rank, name: hint.Name, selected: hint.Rank == hints.SelectedRank} | |||
| 231 | + | // recent was copied with the hint under paletteMu, so this only enriches | |||
| 232 | + | // the render snapshot and never performs a live domain lookup. | |||
| 233 | + | if i < len(recent) { | |||
| 234 | + | entry.ephemeral = recent[i].ephemeral | |||
| 235 | + | entry.attention = recent[i].attention | |||
| 236 | + | } | |||
| 237 | + | entries = append(entries, entry) | |||
| 238 | + | } | |||
| 239 | + | return entries | |||
| 240 | + | } | |||
| 241 | + | ||||
| 187 | func (d *Daemon) barStateForClient(cur *session, _ *attachedClient, copyFeedback string) barState { | 242 | func (d *Daemon) barStateForClient(cur *session, _ *attachedClient, copyFeedback string) barState { | ||
| 188 | return d.barStateFor(cur, copyFeedback) | 243 | return d.barStateFor(cur, copyFeedback) | ||
| 189 | } | 244 | } | ||
| @@ -272,6 +327,36 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||||
| 272 | return labels | 327 | return labels | ||
| 273 | } | 328 | } | ||
| 274 | 329 | ||||
| 330 | + | func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent { | |||
| 331 | + | reserve := 1 | |||
| 332 | + | if feedback != "" { | |||
| 333 | + | reserve = statusTextWidth(feedback) + 2 | |||
| 334 | + | } | |||
| 335 | + | budget := rowLen - leftUsed - reserve | |||
| 336 | + | if budget <= 0 { | |||
| 337 | + | return nil | |||
| 338 | + | } | |||
| 339 | + | cost := func(e rankedRecent) int { | |||
| 340 | + | name := e.name | |||
| 341 | + | if e.ephemeral { | |||
| 342 | + | name += "*" | |||
| 343 | + | } | |||
| 344 | + | width := 2 + statusTextWidth(strconv.Itoa(e.rank)+":") + statusTextWidth(name) | |||
| 345 | + | if e.attention { | |||
| 346 | + | width += 1 + renderer.RuneWidth(attentionGlyph) | |||
| 347 | + | } | |||
| 348 | + | return width | |||
| 349 | + | } | |||
| 350 | + | used := 0 | |||
| 351 | + | for i, entry := range entries { | |||
| 352 | + | if used+cost(entry) > budget { | |||
| 353 | + | return entries[:i] // whole entries only; ranks remain their original MRU ranks. | |||
| 354 | + | } | |||
| 355 | + | used += cost(entry) | |||
| 356 | + | } | |||
| 357 | + | return entries | |||
| 358 | + | } | |||
| 359 | + | ||||
| 275 | func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession { | 360 | func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession { | ||
| 276 | // With no feedback, keep one blank trailing cell; with feedback, reserve | 361 | // With no feedback, keep one blank trailing cell; with feedback, reserve | ||
| 277 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | 362 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | ||
M internal/usecase/daemon/status_test.go +34 -0
| @@ -860,6 +860,40 @@ func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | |||
| 860 | 860 | require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB) | |
| 861 | 861 | } | |
| 862 | 862 | ||
| 863 | + | func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) { | |
| 864 | + | styles := resolveThemeStyles(nil) | |
| 865 | + | row := make([]renderer.Cell, 18) | |
| 866 | + | state := barState{ | |
| 867 | + | status: statusSnapshot{session: "cur"}, | |
| 868 | + | rankedRecent: []rankedRecent{ | |
| 869 | + | {rank: 1, name: "wide界"}, | |
| 870 | + | {rank: 2, name: "two", selected: true}, | |
| 871 | + | {rank: 3, name: "three"}, | |
| 872 | + | }, | |
| 873 | + | } | |
| 874 | + | ||
| 875 | + | drawStatusBarState(row, state, styles) | |
| 876 | + | text := rowText(row) | |
| 877 | + | require.Contains(t, text, "1:wide界") | |
| 878 | + | require.NotContains(t, text, "2:two") // whole entries fit; no partial rank/name. | |
| 879 | + | require.NotContains(t, text, "3:three") | |
| 880 | + | // A rank that does fit is accent-colored and its prefix is bold. | |
| 881 | + | row = make([]renderer.Cell, 28) | |
| 882 | + | drawStatusBarState(row, state, styles) | |
| 883 | + | require.Contains(t, rowText(row), "2:two") | |
| 884 | + | found := false | |
| 885 | + | for _, cell := range row { | |
| 886 | + | if cell.Rune == '2' { | |
| 887 | + | found = true | |
| 888 | + | require.True(t, cell.Style.Bold) | |
| 889 | + | require.Equal(t, styles.accent.Foreground, cell.Style.Foreground) | |
| 890 | + | require.Equal(t, styles.accent.Background, cell.Style.Background) | |
| 891 | + | break | |
| 892 | + | } | |
| 893 | + | } | |
| 894 | + | require.True(t, found) | |
| 895 | + | } | |
| 896 | + | ||
| 863 | 897 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | |
| 864 | 898 | theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | |
| 865 | 899 | styles := newThemeStyles(theme) | |
| @@ -860,6 +860,40 @@ func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) { | |||||
| 860 | require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB) | 860 | require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB) | ||
| 861 | } | 861 | } | ||
| 862 | 862 | ||||
| 863 | + | func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testing.T) { | |||
| 864 | + | styles := resolveThemeStyles(nil) | |||
| 865 | + | row := make([]renderer.Cell, 18) | |||
| 866 | + | state := barState{ | |||
| 867 | + | status: statusSnapshot{session: "cur"}, | |||
| 868 | + | rankedRecent: []rankedRecent{ | |||
| 869 | + | {rank: 1, name: "wide界"}, | |||
| 870 | + | {rank: 2, name: "two", selected: true}, | |||
| 871 | + | {rank: 3, name: "three"}, | |||
| 872 | + | }, | |||
| 873 | + | } | |||
| 874 | + | ||||
| 875 | + | drawStatusBarState(row, state, styles) | |||
| 876 | + | text := rowText(row) | |||
| 877 | + | require.Contains(t, text, "1:wide界") | |||
| 878 | + | require.NotContains(t, text, "2:two") // whole entries fit; no partial rank/name. | |||
| 879 | + | require.NotContains(t, text, "3:three") | |||
| 880 | + | // A rank that does fit is accent-colored and its prefix is bold. | |||
| 881 | + | row = make([]renderer.Cell, 28) | |||
| 882 | + | drawStatusBarState(row, state, styles) | |||
| 883 | + | require.Contains(t, rowText(row), "2:two") | |||
| 884 | + | found := false | |||
| 885 | + | for _, cell := range row { | |||
| 886 | + | if cell.Rune == '2' { | |||
| 887 | + | found = true | |||
| 888 | + | require.True(t, cell.Style.Bold) | |||
| 889 | + | require.Equal(t, styles.accent.Foreground, cell.Style.Foreground) | |||
| 890 | + | require.Equal(t, styles.accent.Background, cell.Style.Background) | |||
| 891 | + | break | |||
| 892 | + | } | |||
| 893 | + | } | |||
| 894 | + | require.True(t, found) | |||
| 895 | + | } | |||
| 896 | + | ||||
| 863 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | 897 | func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) { | ||
| 864 | theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | 898 | theme := themeui.Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 20, G: 20, B: 20}, HasFG: true, HasBG: true, TrueColor: true, Known: true} | ||
| 865 | styles := newThemeStyles(theme) | 899 | styles := newThemeStyles(theme) | ||
M internal/usecase/palette/contextual_hints.go +5 -3
| @@ -14,9 +14,10 @@ type RecentSessionHint struct { | |||
| 14 | 14 | ||
| 15 | 15 | // ContextualHints is immutable contextual palette guidance. | |
| 16 | 16 | type ContextualHints struct { | |
| 17 | - | Kind command.ContextHint | |
| 18 | - | Recent []RecentSessionHint | |
| 19 | - | Feedback string | |
| 17 | + | Kind command.ContextHint | |
| 18 | + | Recent []RecentSessionHint | |
| 19 | + | SelectedRank int | |
| 20 | + | Feedback string | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | // BuildRecentSessionHints produces guidance without consulting live daemon state. | |
| @@ -38,6 +39,7 @@ func BuildRecentSessionHints(names []string, args []string) ContextualHints { | |||
| 38 | 39 | } else if rank > len(names) { | |
| 39 | 40 | h.Feedback = fmt.Sprintf("rank %d is unavailable", rank) | |
| 40 | 41 | } else { | |
| 42 | + | h.SelectedRank = rank | |
| 41 | 43 | h.Feedback = fmt.Sprintf("jump to recent session %d", rank) | |
| 42 | 44 | } | |
| 43 | 45 | return h | |
| @@ -14,9 +14,10 @@ type RecentSessionHint struct { | |||||
| 14 | 14 | ||||
| 15 | // ContextualHints is immutable contextual palette guidance. | 15 | // ContextualHints is immutable contextual palette guidance. | ||
| 16 | type ContextualHints struct { | 16 | type ContextualHints struct { | ||
| 17 | - | Kind command.ContextHint | 17 | + | Kind command.ContextHint |
| 18 | - | Recent []RecentSessionHint | 18 | + | Recent []RecentSessionHint |
| 19 | - | Feedback string | 19 | + | SelectedRank int |
| 20 | + | Feedback string | |||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 22 | // BuildRecentSessionHints produces guidance without consulting live daemon state. | 23 | // BuildRecentSessionHints produces guidance without consulting live daemon state. | ||
| @@ -38,6 +39,7 @@ func BuildRecentSessionHints(names []string, args []string) ContextualHints { | |||||
| 38 | } else if rank > len(names) { | 39 | } else if rank > len(names) { | ||
| 39 | h.Feedback = fmt.Sprintf("rank %d is unavailable", rank) | 40 | h.Feedback = fmt.Sprintf("rank %d is unavailable", rank) | ||
| 40 | } else { | 41 | } else { | ||
| 42 | + | h.SelectedRank = rank | |||
| 41 | h.Feedback = fmt.Sprintf("jump to recent session %d", rank) | 43 | h.Feedback = fmt.Sprintf("jump to recent session %d", rank) | ||
| 42 | } | 44 | } | ||
| 43 | return h | 45 | return h | ||
M internal/usecase/palette/model.go +17 -13
| @@ -12,13 +12,20 @@ type RenderStyles struct { | |||
| 12 | 12 | Description renderer.Style | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | + | // RenderOptions contains presentation supplied by the daemon for one render. | |
| 16 | + | // Guidance is substituted only into the exact contextual command row; it never | |
| 17 | + | // adds rows or changes fuzzy matching geometry. | |
| 18 | + | type RenderOptions struct { | |
| 19 | + | Styles RenderStyles | |
| 20 | + | Guidance string | |
| 21 | + | } | |
| 22 | + | ||
| 15 | 23 | type Model struct { | |
| 16 | 24 | commands []command.Command | |
| 17 | 25 | input ui.TextInput | |
| 18 | 26 | matches []Match | |
| 19 | 27 | selected int | |
| 20 | 28 | scroll int | |
| 21 | - | feedback string | |
| 22 | 29 | } | |
| 23 | 30 | ||
| 24 | 31 | func New(commands []command.Command) *Model { | |
| @@ -54,12 +61,6 @@ func (m *Model) Backspace() { | |||
| 54 | 61 | } | |
| 55 | 62 | } | |
| 56 | 63 | ||
| 57 | - | // SetFeedback sets interaction-local guidance shown below the query. | |
| 58 | - | func (m *Model) SetFeedback(feedback string) { | |
| 59 | - | if m != nil { | |
| 60 | - | m.feedback = feedback | |
| 61 | - | } | |
| 62 | - | } | |
| 63 | 64 | func (m *Model) Query() string { | |
| 64 | 65 | if m == nil { | |
| 65 | 66 | return "" | |
| @@ -142,7 +143,8 @@ func (m *Model) clamp() { | |||
| 142 | 143 | } | |
| 143 | 144 | } | |
| 144 | 145 | ||
| 145 | - | func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |
| 146 | + | func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |
| 147 | + | styles := opts.Styles | |
| 146 | 148 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 147 | 149 | if frame.Width == 0 || frame.Height == 0 { | |
| 148 | 150 | return frame | |
| @@ -157,10 +159,6 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||
| 157 | 159 | } | |
| 158 | 160 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | |
| 159 | 161 | start := 1 | |
| 160 | - | if m.feedback != "" && frame.Height > 1 { | |
| 161 | - | ui.DrawText(frame, 0, 1, frame.Width, m.feedback, desc) | |
| 162 | - | start++ | |
| 163 | - | } | |
| 164 | 162 | visible := frame.Height - start | |
| 165 | 163 | if visible <= 0 { | |
| 166 | 164 | return frame | |
| @@ -212,7 +210,13 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||
| 212 | 210 | frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style}) | |
| 213 | 211 | x++ | |
| 214 | 212 | } | |
| 215 | - | ui.DrawText(frame, x, y+start, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) | |
| 213 | + | description := match.Command.Desc | |
| 214 | + | if opts.Guidance != "" && match.Command.ContextHint != command.ContextHintNone && m.Query() != "" { | |
| 215 | + | if active, ok := m.ArgumentCommand(); ok && active.Code == match.Command.Code { | |
| 216 | + | description = opts.Guidance | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ui.DrawText(frame, x, y+start, frame.Width, description, mergePaletteDescStyle(style, desc)) | |
| 216 | 220 | } | |
| 217 | 221 | return frame | |
| 218 | 222 | } | |
| @@ -12,13 +12,20 @@ type RenderStyles struct { | |||||
| 12 | Description renderer.Style | 12 | Description renderer.Style | ||
| 13 | } | 13 | } | ||
| 14 | 14 | ||||
| 15 | + | // RenderOptions contains presentation supplied by the daemon for one render. | |||
| 16 | + | // Guidance is substituted only into the exact contextual command row; it never | |||
| 17 | + | // adds rows or changes fuzzy matching geometry. | |||
| 18 | + | type RenderOptions struct { | |||
| 19 | + | Styles RenderStyles | |||
| 20 | + | Guidance string | |||
| 21 | + | } | |||
| 22 | + | ||||
| 15 | type Model struct { | 23 | type Model struct { | ||
| 16 | commands []command.Command | 24 | commands []command.Command | ||
| 17 | input ui.TextInput | 25 | input ui.TextInput | ||
| 18 | matches []Match | 26 | matches []Match | ||
| 19 | selected int | 27 | selected int | ||
| 20 | scroll int | 28 | scroll int | ||
| 21 | - | feedback string | |||
| 22 | } | 29 | } | ||
| 23 | 30 | ||||
| 24 | func New(commands []command.Command) *Model { | 31 | func New(commands []command.Command) *Model { | ||
| @@ -54,12 +61,6 @@ func (m *Model) Backspace() { | |||||
| 54 | } | 61 | } | ||
| 55 | } | 62 | } | ||
| 56 | 63 | ||||
| 57 | - | // SetFeedback sets interaction-local guidance shown below the query. | |||
| 58 | - | func (m *Model) SetFeedback(feedback string) { | |||
| 59 | - | if m != nil { | |||
| 60 | - | m.feedback = feedback | |||
| 61 | - | } | |||
| 62 | - | } | |||
| 63 | func (m *Model) Query() string { | 64 | func (m *Model) Query() string { | ||
| 64 | if m == nil { | 65 | if m == nil { | ||
| 65 | return "" | 66 | return "" | ||
| @@ -142,7 +143,8 @@ func (m *Model) clamp() { | |||||
| 142 | } | 143 | } | ||
| 143 | } | 144 | } | ||
| 144 | 145 | ||||
| 145 | - | func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | 146 | + | func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { |
| 147 | + | styles := opts.Styles | |||
| 146 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | 148 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | ||
| 147 | if frame.Width == 0 || frame.Height == 0 { | 149 | if frame.Width == 0 || frame.Height == 0 { | ||
| 148 | return frame | 150 | return frame | ||
| @@ -157,10 +159,6 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||||
| 157 | } | 159 | } | ||
| 158 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | 160 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | ||
| 159 | start := 1 | 161 | start := 1 | ||
| 160 | - | if m.feedback != "" && frame.Height > 1 { | |||
| 161 | - | ui.DrawText(frame, 0, 1, frame.Width, m.feedback, desc) | |||
| 162 | - | start++ | |||
| 163 | - | } | |||
| 164 | visible := frame.Height - start | 162 | visible := frame.Height - start | ||
| 165 | if visible <= 0 { | 163 | if visible <= 0 { | ||
| 166 | return frame | 164 | return frame | ||
| @@ -212,7 +210,13 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame { | |||||
| 212 | frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style}) | 210 | frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style}) | ||
| 213 | x++ | 211 | x++ | ||
| 214 | } | 212 | } | ||
| 215 | - | ui.DrawText(frame, x, y+start, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc)) | 213 | + | description := match.Command.Desc |
| 214 | + | if opts.Guidance != "" && match.Command.ContextHint != command.ContextHintNone && m.Query() != "" { | |||
| 215 | + | if active, ok := m.ArgumentCommand(); ok && active.Code == match.Command.Code { | |||
| 216 | + | description = opts.Guidance | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ui.DrawText(frame, x, y+start, frame.Width, description, mergePaletteDescStyle(style, desc)) | |||
| 216 | } | 220 | } | ||
| 217 | return frame | 221 | return frame | ||
| 218 | } | 222 | } | ||
M internal/usecase/palette/model_test.go +14 -9
| @@ -74,7 +74,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | |||
| 74 | 74 | }) | |
| 75 | 75 | m.Insert('c') | |
| 76 | 76 | m.Insert('y') | |
| 77 | - | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, DefaultRenderStyles()) | |
| 77 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()}) | |
| 78 | 78 | ||
| 79 | 79 | require.Equal(t, '>', frame.At(0, 0).Rune) | |
| 80 | 80 | require.Equal(t, ' ', frame.At(1, 0).Rune) | |
| @@ -99,7 +99,7 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) { | |||
| 99 | 99 | ||
| 100 | 100 | for _, cols := range []int{0, 1, 3, 4, 7} { | |
| 101 | 101 | t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) { | |
| 102 | - | frame := m.Render(domain.Size{Cols: cols, Rows: 2}, DefaultRenderStyles()) | |
| 102 | + | frame := m.Render(domain.Size{Cols: cols, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |
| 103 | 103 | require.Equal(t, cols, frame.Width) | |
| 104 | 104 | if cols > 0 { | |
| 105 | 105 | require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1))) | |
| @@ -179,19 +179,24 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||
| 179 | 179 | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | |
| 180 | 180 | m.Insert('c') | |
| 181 | 181 | m.Insert('y') | |
| 182 | - | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, tt.styles()) | |
| 182 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()}) | |
| 183 | 183 | ||
| 184 | 184 | tt.assert(t, frame.At(tt.x, 1).Style) | |
| 185 | 185 | }) | |
| 186 | 186 | } | |
| 187 | 187 | } | |
| 188 | 188 | ||
| 189 | - | func TestRenderPreservesFeedbackAboveCommandRows(t *testing.T) { | |
| 190 | - | m := New([]command.Command{cmd("JRS", "Jump", "Jump to recent session")}) | |
| 191 | - | m.SetFeedback("rank 1") | |
| 189 | + | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | |
| 190 | + | jrs := cmd("JRS", "Jump", "Jump to recent session") | |
| 191 | + | jrs.Arguments = command.ArgumentsRequired | |
| 192 | + | jrs.ContextHint = command.ContextHintRecentSessions | |
| 193 | + | m := New([]command.Command{jrs}) | |
| 194 | + | for _, r := range "JRS 1" { | |
| 195 | + | m.Insert(r) | |
| 196 | + | } | |
| 192 | 197 | ||
| 193 | - | frame := m.Render(domain.Size{Cols: 28, Rows: 4}, DefaultRenderStyles()) | |
| 194 | - | if got := frameRow(frame, 1); got[:6] != "rank 1" { | |
| 195 | - | t.Fatalf("feedback row = %q, want prefix %q", got, "rank 1") | |
| 198 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"}) | |
| 199 | + | if got := frameRow(frame, 1); got != "JRS jump to recent session 1" { | |
| 200 | + | t.Fatalf("command row = %q, want contextual guidance without a feedback row", got) | |
| 196 | 201 | } | |
| 197 | 202 | } | |
| @@ -74,7 +74,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | |||||
| 74 | }) | 74 | }) | ||
| 75 | m.Insert('c') | 75 | m.Insert('c') | ||
| 76 | m.Insert('y') | 76 | m.Insert('y') | ||
| 77 | - | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, DefaultRenderStyles()) | 77 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()}) |
| 78 | 78 | ||||
| 79 | require.Equal(t, '>', frame.At(0, 0).Rune) | 79 | require.Equal(t, '>', frame.At(0, 0).Rune) | ||
| 80 | require.Equal(t, ' ', frame.At(1, 0).Rune) | 80 | require.Equal(t, ' ', frame.At(1, 0).Rune) | ||
| @@ -99,7 +99,7 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) { | |||||
| 99 | 99 | ||||
| 100 | for _, cols := range []int{0, 1, 3, 4, 7} { | 100 | for _, cols := range []int{0, 1, 3, 4, 7} { | ||
| 101 | t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) { | 101 | t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) { | ||
| 102 | - | frame := m.Render(domain.Size{Cols: cols, Rows: 2}, DefaultRenderStyles()) | 102 | + | frame := m.Render(domain.Size{Cols: cols, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) |
| 103 | require.Equal(t, cols, frame.Width) | 103 | require.Equal(t, cols, frame.Width) | ||
| 104 | if cols > 0 { | 104 | if cols > 0 { | ||
| 105 | require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1))) | 105 | require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1))) | ||
| @@ -179,19 +179,24 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||||
| 179 | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | 179 | m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}) | ||
| 180 | m.Insert('c') | 180 | m.Insert('c') | ||
| 181 | m.Insert('y') | 181 | m.Insert('y') | ||
| 182 | - | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, tt.styles()) | 182 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()}) |
| 183 | 183 | ||||
| 184 | tt.assert(t, frame.At(tt.x, 1).Style) | 184 | tt.assert(t, frame.At(tt.x, 1).Style) | ||
| 185 | }) | 185 | }) | ||
| 186 | } | 186 | } | ||
| 187 | } | 187 | } | ||
| 188 | 188 | ||||
| 189 | - | func TestRenderPreservesFeedbackAboveCommandRows(t *testing.T) { | 189 | + | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { |
| 190 | - | m := New([]command.Command{cmd("JRS", "Jump", "Jump to recent session")}) | 190 | + | jrs := cmd("JRS", "Jump", "Jump to recent session") |
| 191 | - | m.SetFeedback("rank 1") | 191 | + | jrs.Arguments = command.ArgumentsRequired |
| 192 | + | jrs.ContextHint = command.ContextHintRecentSessions | |||
| 193 | + | m := New([]command.Command{jrs}) | |||
| 194 | + | for _, r := range "JRS 1" { | |||
| 195 | + | m.Insert(r) | |||
| 196 | + | } | |||
| 192 | 197 | ||||
| 193 | - | frame := m.Render(domain.Size{Cols: 28, Rows: 4}, DefaultRenderStyles()) | 198 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"}) |
| 194 | - | if got := frameRow(frame, 1); got[:6] != "rank 1" { | 199 | + | if got := frameRow(frame, 1); got != "JRS jump to recent session 1" { |
| 195 | - | t.Fatalf("feedback row = %q, want prefix %q", got, "rank 1") | 200 | + | t.Fatalf("command row = %q, want contextual guidance without a feedback row", got) |
| 196 | } | 201 | } | ||
| 197 | } | 202 | } | ||