detached 9818e1d

feat(status): show contextual recent session ranks

commit
9818e1d3c7bde743e45cbb82c38f4231a4496f14
author
brice · · 2026-07-11 12:26 UTC
parent
38162d7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
168168
169169 paletteActive bool
170170 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
171175 paletteLocked bool
172176
173177 promptActive bool
@@ -212,6 +216,12 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
212216 rt.paletteMu.Lock()
213217 snap.paletteModel = rt.palette
214218 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+ }
215225 if snap.paletteActive {
216226 snap.paletteLocked = true
217227 } else {
@@ -168,6 +168,10 @@ type overlayRenderSnapshot struct {
168 168
169 paletteActive bool169 paletteActive bool
170 paletteModel *palette.Model170 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 bool175 paletteLocked bool
172 176
173 promptActive bool177 promptActive bool
@@ -212,6 +216,12 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
212 rt.paletteMu.Lock()216 rt.paletteMu.Lock()
213 snap.paletteModel = rt.palette217 snap.paletteModel = rt.palette
214 snap.paletteActive = snap.paletteModel != nil218 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 = true226 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) {
121121 rune: func(r rune) {
122122 ac.overlays.palette.Insert(r)
123123 ac.overlays.paletteFeedback = ""
124- ac.overlays.palette.SetFeedback("")
125124 changed = true
126125 },
127126 backspace: func() {
128127 ac.overlays.palette.Backspace()
129128 ac.overlays.paletteFeedback = ""
130- ac.overlays.palette.SetFeedback("")
131129 changed = true
132130 },
133131 up: func() { ac.overlays.palette.Up(); changed = true },
@@ -138,7 +136,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
138136 cmd, selected = ac.overlays.palette.Selected()
139137 if !selected {
140138 ac.overlays.paletteFeedback = "invalid command arguments"
141- ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback)
142139 changed = true
143140 return
144141 }
@@ -151,7 +148,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
151148 action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)
152149 if !valid {
153150 ac.overlays.paletteFeedback = "invalid command arguments"
154- ac.overlays.palette.SetFeedback(ac.overlays.paletteFeedback)
155151 changed = true
156152 return
157153 }
@@ -167,10 +163,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
167163 if ok && active.Code == "JRS" {
168164 hints := recentSessionHints(ac.overlays.paletteRecent, paletteArgs(ac.overlays.palette.Query(), active))
169165 ac.overlays.paletteHints = hints
170- ac.overlays.palette.SetFeedback(hints.Feedback)
171166 } else {
172167 ac.overlays.paletteHints = palette.ContextualHints{}
173- ac.overlays.palette.SetFeedback("")
174168 }
175169 }
176170 if cancel {
@@ -247,7 +241,11 @@ func (d *Daemon) paletteFailure(ac *attachedClient, generation uint64, rawQuery,
247241 return
248242 }
249243 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+ }
251249 }
252250 func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, rawQuery string) bool {
253251 ac.overlays.paletteMu.Lock()
@@ -410,10 +408,10 @@ func (e paletteExec) JumpRecentSession(rank int) error {
410408 return nil
411409 }
412410
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) {
414412 styleSet := resolveThemeStyles(styles)
415413 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
416414 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})
418416 })
419417 }
@@ -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 = true124 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 = true129 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 = true139 changed = true
143 return140 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 = true151 changed = true
156 return152 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 = hints165 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 return241 return
248 }242 }
249 ac.overlays.paletteFeedback = feedback243 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 nil408 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) {
408408 {name: "bottom right", cfg: domain.PaletteConfig{Anchor: domain.AnchorBottomRight, AnchorSet: true}, at: domain.Rect{X: 55, Y: 28}},
409409 } {
410410 t.Run(tt.name, func(t *testing.T) {
411- frame, _ := composePaletteClientFrame(model, base, tt.cfg)
411+ frame, _ := composePaletteClientFrame(model, base, tt.cfg, "")
412412 require.Equal(t, '┌', frame.At(tt.at.X, tt.at.Y).Rune)
413413 })
414414 }
@@ -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
369369 }
370370 d.refreshSessionFocusedTitles(sess)
371371 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)
372375 bars.theme = ac.getTheme()
373376 _, attentionVisible := pulseStyle(bars.attentionFrame)
374377 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
@@ -454,7 +457,11 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
454457 frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
455458 }
456459 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)
458465 }
459466 if overlays.promptActive {
460467 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 (
2929 "strconv"
3030 "time"
3131
32+ "github.com/bnema/vev/internal/usecase/command"
33+ "github.com/bnema/vev/internal/usecase/palette"
3234 themeui "github.com/bnema/vev/internal/usecase/theme"
3335 "github.com/bnema/vev/internal/usecase/ui"
3436 "github.com/bnema/vev/pkg/renderer"
@@ -82,14 +84,48 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
8284 x := 0
8385 rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
8486 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+ }
89101 }
90102 drawRightPlainText(row, rightText, x, styles.statusBar)
91103 }
92104
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+
93129 func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) {
94130 if ephemeral {
95131 name += "*"
@@ -141,6 +177,7 @@ type barState struct {
141177 bottomRight string
142178 copyFeedback string
143179 mru []recentSession
180+ rankedRecent []rankedRecent
144181 attentionFrame int
145182 // theme is the client's terminal theme, if reported. Its zero value
146183 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -184,6 +221,24 @@ func tabDisplayName(tb *tab, index int) string {
184221 return strconv.Itoa(index + 1)
185222 }
186223
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+
187242 func (d *Daemon) barStateForClient(cur *session, _ *attachedClient, copyFeedback string) barState {
188243 return d.barStateFor(cur, copyFeedback)
189244 }
@@ -272,6 +327,36 @@ func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string {
272327 return labels
273328 }
274329
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+
275360 func fitMRU(entries []recentSession, rowLen, leftUsed int, feedback string) []recentSession {
276361 // With no feedback, keep one blank trailing cell; with feedback, reserve
277362 // 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 := 084 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 string177 bottomRight string
142 copyFeedback string178 copyFeedback string
143 mru []recentSession179 mru []recentSession
180+ rankedRecent []rankedRecent
144 attentionFrame int181 attentionFrame int
145 // theme is the client's terminal theme, if reported. Its zero value182 // theme is the client's terminal theme, if reported. Its zero value
146 // (Theme{}, Known: false) is a valid "no theme" default that resolves to183 // (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 labels327 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, reserve361 // 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) {
860860 require.NotEqual(t, styles.statusBar.BackgroundRGB, styles.accent.BackgroundRGB)
861861 }
862862
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+
863897 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
864898 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}
865899 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 {
1414
1515 // ContextualHints is immutable contextual palette guidance.
1616 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
2021 }
2122
2223 // BuildRecentSessionHints produces guidance without consulting live daemon state.
@@ -38,6 +39,7 @@ func BuildRecentSessionHints(names []string, args []string) ContextualHints {
3839 } else if rank > len(names) {
3940 h.Feedback = fmt.Sprintf("rank %d is unavailable", rank)
4041 } else {
42+ h.SelectedRank = rank
4143 h.Feedback = fmt.Sprintf("jump to recent session %d", rank)
4244 }
4345 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.ContextHint17+ Kind command.ContextHint
18- Recent []RecentSessionHint18+ Recent []RecentSessionHint
19- Feedback string19+ 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 h45 return h
M internal/usecase/palette/model.go +17 -13
@@ -12,13 +12,20 @@ type RenderStyles struct {
1212 Description renderer.Style
1313 }
1414
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+
1523 type Model struct {
1624 commands []command.Command
1725 input ui.TextInput
1826 matches []Match
1927 selected int
2028 scroll int
21- feedback string
2229 }
2330
2431 func New(commands []command.Command) *Model {
@@ -54,12 +61,6 @@ func (m *Model) Backspace() {
5461 }
5562 }
5663
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-}
6364 func (m *Model) Query() string {
6465 if m == nil {
6566 return ""
@@ -142,7 +143,8 @@ func (m *Model) clamp() {
142143 }
143144 }
144145
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
146148 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
147149 if frame.Width == 0 || frame.Height == 0 {
148150 return frame
@@ -157,10 +159,6 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
157159 }
158160 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
159161 start := 1
160- if m.feedback != "" && frame.Height > 1 {
161- ui.DrawText(frame, 0, 1, frame.Width, m.feedback, desc)
162- start++
163- }
164162 visible := frame.Height - start
165163 if visible <= 0 {
166164 return frame
@@ -212,7 +210,13 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
212210 frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style})
213211 x++
214212 }
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))
216220 }
217221 return frame
218222 }
@@ -12,13 +12,20 @@ type RenderStyles struct {
12 Description renderer.Style12 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.Command24 commands []command.Command
17 input ui.TextInput25 input ui.TextInput
18 matches []Match26 matches []Match
19 selected int27 selected int
20 scroll int28 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 frame150 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 := 1161 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 - start162 visible := frame.Height - start
165 if visible <= 0 {163 if visible <= 0 {
166 return frame164 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 frame221 return frame
218 }222 }
M internal/usecase/palette/model_test.go +14 -9
@@ -74,7 +74,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
7474 })
7575 m.Insert('c')
7676 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()})
7878
7979 require.Equal(t, '>', frame.At(0, 0).Rune)
8080 require.Equal(t, ' ', frame.At(1, 0).Rune)
@@ -99,7 +99,7 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
9999
100100 for _, cols := range []int{0, 1, 3, 4, 7} {
101101 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()})
103103 require.Equal(t, cols, frame.Width)
104104 if cols > 0 {
105105 require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
@@ -179,19 +179,24 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
179179 m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
180180 m.Insert('c')
181181 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()})
183183
184184 tt.assert(t, frame.At(tt.x, 1).Style)
185185 })
186186 }
187187 }
188188
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+ }
192197
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)
196201 }
197202 }
@@ -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close