detached b1b4f59

Merge pull request #112 from bnema/fix/command-palette-description-theme

fix(palette): theme selected descriptions
commit
b1b4f59c59752bcf95f1b22ee3f2c00f12789899
author
bnema · · 2026-07-18 15:18 UTC
committer
GitHub · · 2026-07-18 15:18 UTC
merge of
f40c90d + 90f4a2e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +126 -31
M internal/usecase/daemon/palette.go +1 -1
@@ -501,6 +501,6 @@ func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg do
501501 styleSet := resolveStyles(styles)
502502 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
503503 return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
504- return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.SurfaceInactive, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription}, Guidance: guidance})
504+ return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.SurfaceInactive, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription, SelectionDescription: styleSet.PickerSelectionMuted}, Guidance: guidance})
505505 })
506506 }
@@ -501,6 +501,6 @@ func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg do
501 styleSet := resolveStyles(styles)501 styleSet := resolveStyles(styles)
502 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)502 modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
503 return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {503 return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
504- return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.SurfaceInactive, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription}, Guidance: guidance})504+ return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.SurfaceInactive, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription, SelectionDescription: styleSet.PickerSelectionMuted}, Guidance: guidance})
505 })505 })
506 }506 }
M internal/usecase/daemon/palette_test.go +21 -6
@@ -73,14 +73,17 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
7373
7474 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
7575
76- description := state.overlays.palette.inner.At(4, 1).Style
77- require.Equal(t, state.styles.SurfaceInactive.Background, description.Background)
78- require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB)
79- require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB)
76+ inactive := state.overlays.palette.inner.At(4, 1).Style
77+ require.Equal(t, state.styles.SurfaceInactive.Background, inactive.Background)
78+ require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
79+ require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
8080 if tt.indexed {
81- require.Equal(t, 2, description.Foreground)
82- require.False(t, description.HasBackgroundRGB)
81+ require.Equal(t, 2, inactive.Foreground)
82+ require.False(t, inactive.HasBackgroundRGB)
8383 }
84+
85+ selected := state.overlays.palette.inner.At(4, 2).Style
86+ require.True(t, selected.Equal(state.styles.PickerSelectionMuted))
8487 })
8588 }
8689 }
@@ -721,6 +724,18 @@ func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
721724 }
722725 }
723726
727+func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
728+ model := palette.New(palette.CommandResults([]command.Command{{Code: "ONE", Desc: "first description"}}))
729+ size := domain.Size{Cols: 120, Rows: 40}
730+ base := renderer.NewFrame(size.Cols, size.Rows)
731+ styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
732+
733+ frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
734+ inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size)
735+
736+ require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
737+}
738+
724739 func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {
725740 d, sess, ac, sends, releases := newManualTabSession(t, 1)
726741 defer releases[0]()
@@ -73,14 +73,17 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
73 73
74 captureOverlayLayers(&state, snap, domain.PaletteConfig{})74 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
75 75
76- description := state.overlays.palette.inner.At(4, 1).Style76+ inactive := state.overlays.palette.inner.At(4, 1).Style
77- require.Equal(t, state.styles.SurfaceInactive.Background, description.Background)77+ require.Equal(t, state.styles.SurfaceInactive.Background, inactive.Background)
78- require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB)78+ require.Equal(t, state.styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
79- require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB)79+ require.Equal(t, state.styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
80 if tt.indexed {80 if tt.indexed {
81- require.Equal(t, 2, description.Foreground)81+ require.Equal(t, 2, inactive.Foreground)
82- require.False(t, description.HasBackgroundRGB)82+ require.False(t, inactive.HasBackgroundRGB)
83 }83 }
84+
85+ selected := state.overlays.palette.inner.At(4, 2).Style
86+ require.True(t, selected.Equal(state.styles.PickerSelectionMuted))
84 })87 })
85 }88 }
86 }89 }
@@ -721,6 +724,18 @@ func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
721 }724 }
722 }725 }
723 726
727+func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
728+ model := palette.New(palette.CommandResults([]command.Command{{Code: "ONE", Desc: "first description"}}))
729+ size := domain.Size{Cols: 120, Rows: 40}
730+ base := renderer.NewFrame(size.Cols, size.Rows)
731+ styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
732+
733+ frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
734+ inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size)
735+
736+ require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
737+}
738+
724 func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {739 func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {
725 d, sess, ac, sends, releases := newManualTabSession(t, 1)740 d, sess, ac, sends, releases := newManualTabSession(t, 1)
726 defer releases[0]()741 defer releases[0]()
M internal/usecase/daemon/render_pipeline.go +1 -1
@@ -226,7 +226,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
226226 guidance = snap.paletteHints.Feedback
227227 }
228228 o.paletteGuidance = snap.paletteFeedback
229- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.SurfaceInactive, Selection: styles.PickerSelection, Description: styles.PickerDescription}, Guidance: guidance, Feedback: snap.paletteFeedback})
229+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.SurfaceInactive, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})
230230 }
231231 if snap.promptActive && snap.promptModel != nil {
232232 o.prompt.modal = promptModalFor(snap.promptModel.Title())
@@ -226,7 +226,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
226 guidance = snap.paletteHints.Feedback226 guidance = snap.paletteHints.Feedback
227 }227 }
228 o.paletteGuidance = snap.paletteFeedback228 o.paletteGuidance = snap.paletteFeedback
229- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.SurfaceInactive, Selection: styles.PickerSelection, Description: styles.PickerDescription}, Guidance: guidance, Feedback: snap.paletteFeedback})229+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.SurfaceInactive, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})
230 }230 }
231 if snap.promptActive && snap.promptModel != nil {231 if snap.promptActive && snap.promptModel != nil {
232 o.prompt.modal = promptModalFor(snap.promptModel.Title())232 o.prompt.modal = promptModalFor(snap.promptModel.Title())
M internal/usecase/palette/model.go +19 -8
@@ -13,10 +13,11 @@ import (
1313 type RenderStyles struct {
1414 // Base fills unused modal interior cells; Row is the ordinary inactive
1515 // result surface. They are separate to retain the chrome hierarchy.
16- Base renderer.Style
17- Row renderer.Style
18- Selection renderer.Style
19- Description renderer.Style
16+ Base renderer.Style
17+ Row renderer.Style
18+ Selection renderer.Style
19+ Description renderer.Style
20+ SelectionDescription renderer.Style
2021 }
2122
2223 // RenderOptions contains presentation supplied by the daemon for one render.
@@ -57,8 +58,10 @@ func DefaultRenderStyles() RenderStyles {
5758 selection.Inverse = true
5859 description := renderer.DefaultStyle()
5960 description.Italic = true
61+ selectionDescription := selection
62+ selectionDescription.Italic = true
6063 base := renderer.DefaultStyle()
61- return RenderStyles{Base: base, Row: base, Selection: selection, Description: description}
64+ return RenderStyles{Base: base, Row: base, Selection: selection, Description: description, SelectionDescription: selectionDescription}
6265 }
6366
6467 func (m *Model) Insert(r rune) {
@@ -248,6 +251,10 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
248251 return frame
249252 }
250253 base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
254+ selectionDesc := styles.SelectionDescription
255+ if selectionDesc == (renderer.Style{}) {
256+ selectionDesc = mergePaletteDescStyle(selection, desc)
257+ }
251258 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
252259 if m == nil {
253260 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
@@ -283,7 +290,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
283290 }
284291 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
285292 if cmd, ok := match.Result.Command(); ok {
286- m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
293+ m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
287294 continue
288295 }
289296 m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
@@ -291,7 +298,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
291298 return frame
292299 }
293300
294-func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {
301+func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc, selectionDesc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {
295302 x, nextHighlight := 0, 0
296303 for _, r := range cmd.Code {
297304 cellStyle := style
@@ -316,7 +323,11 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
316323 } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
317324 description = guidance
318325 }
319- ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc))
326+ descriptionStyle := mergePaletteDescStyle(style, desc)
327+ if selected {
328+ descriptionStyle = selectionDesc
329+ }
330+ ui.DrawText(frame, x, y, frame.Width, description, descriptionStyle)
320331 }
321332
322333 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
@@ -13,10 +13,11 @@ import (
13 type RenderStyles struct {13 type RenderStyles struct {
14 // Base fills unused modal interior cells; Row is the ordinary inactive14 // Base fills unused modal interior cells; Row is the ordinary inactive
15 // result surface. They are separate to retain the chrome hierarchy.15 // result surface. They are separate to retain the chrome hierarchy.
16- Base renderer.Style16+ Base renderer.Style
17- Row renderer.Style17+ Row renderer.Style
18- Selection renderer.Style18+ Selection renderer.Style
19- Description renderer.Style19+ Description renderer.Style
20+ SelectionDescription renderer.Style
20 }21 }
21 22
22 // RenderOptions contains presentation supplied by the daemon for one render.23 // RenderOptions contains presentation supplied by the daemon for one render.
@@ -57,8 +58,10 @@ func DefaultRenderStyles() RenderStyles {
57 selection.Inverse = true58 selection.Inverse = true
58 description := renderer.DefaultStyle()59 description := renderer.DefaultStyle()
59 description.Italic = true60 description.Italic = true
61+ selectionDescription := selection
62+ selectionDescription.Italic = true
60 base := renderer.DefaultStyle()63 base := renderer.DefaultStyle()
61- return RenderStyles{Base: base, Row: base, Selection: selection, Description: description}64+ return RenderStyles{Base: base, Row: base, Selection: selection, Description: description, SelectionDescription: selectionDescription}
62 }65 }
63 66
64 func (m *Model) Insert(r rune) {67 func (m *Model) Insert(r rune) {
@@ -248,6 +251,10 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
248 return frame251 return frame
249 }252 }
250 base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description253 base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
254+ selectionDesc := styles.SelectionDescription
255+ if selectionDesc == (renderer.Style{}) {
256+ selectionDesc = mergePaletteDescStyle(selection, desc)
257+ }
251 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})258 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
252 if m == nil {259 if m == nil {
253 ui.DrawInputLine(frame, 0, "> ", "", base, selection)260 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
@@ -283,7 +290,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
283 }290 }
284 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})291 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
285 if cmd, ok := match.Result.Command(); ok {292 if cmd, ok := match.Result.Command(); ok {
286- m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)293+ m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
287 continue294 continue
288 }295 }
289 m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)296 m.renderSession(frame, y+start, style, selection, match, opts.Feedback, idx == m.selected)
@@ -291,7 +298,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
291 return frame298 return frame
292 }299 }
293 300
294-func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {301+func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc, selectionDesc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {
295 x, nextHighlight := 0, 0302 x, nextHighlight := 0, 0
296 for _, r := range cmd.Code {303 for _, r := range cmd.Code {
297 cellStyle := style304 cellStyle := style
@@ -316,7 +323,11 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
316 } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {323 } else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
317 description = guidance324 description = guidance
318 }325 }
319- ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc))326+ descriptionStyle := mergePaletteDescStyle(style, desc)
327+ if selected {
328+ descriptionStyle = selectionDesc
329+ }
330+ ui.DrawText(frame, x, y, frame.Width, description, descriptionStyle)
320 }331 }
321 332
322 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {333 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
M internal/usecase/palette/model_test.go +84 -15
@@ -203,10 +203,11 @@ func frameRow(frame renderer.Frame, y int) string {
203203
204204 func TestRenderUsesConfiguredStyles(t *testing.T) {
205205 tests := []struct {
206- name string
207- styles func() RenderStyles
208- x int
209- assert func(t *testing.T, style renderer.Style)
206+ name string
207+ styles func() RenderStyles
208+ x, y int
209+ unfiltered bool
210+ assert func(t *testing.T, style renderer.Style)
210211 }{
211212 {
212213 name: "selection style for fuzzy highlights",
@@ -219,6 +220,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
219220 return styles
220221 },
221222 x: 0,
223+ y: 1,
222224 assert: func(t *testing.T, style renderer.Style) {
223225 require.True(t, style.Bold)
224226 require.False(t, style.Inverse)
@@ -237,7 +239,9 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
237239 styles.Description = muted
238240 return styles
239241 },
240- x: 4,
242+ x: 4,
243+ y: 2,
244+ unfiltered: true,
241245 assert: func(t *testing.T, style renderer.Style) {
242246 require.True(t, style.Italic)
243247 require.True(t, style.HasForegroundRGB)
@@ -250,9 +254,11 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
250254 styles: func() RenderStyles {
251255 styles := DefaultRenderStyles()
252256 styles.Description.Italic = false
257+ styles.SelectionDescription.Italic = false
253258 return styles
254259 },
255260 x: 4,
261+ y: 1,
256262 assert: func(t *testing.T, style renderer.Style) {
257263 require.False(t, style.Italic)
258264 },
@@ -261,16 +267,75 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
261267
262268 for _, tt := range tests {
263269 t.Run(tt.name, func(t *testing.T) {
264- m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
265- m.Insert('c')
266- m.Insert('y')
270+ m := New(CommandResults([]command.Command{
271+ cmd("CPY", "Copy", "Enter copy mode"),
272+ cmd("CNT", "New", "Create tab"),
273+ }))
274+ if !tt.unfiltered {
275+ m.Insert('c')
276+ m.Insert('y')
277+ }
267278 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
268279
269- tt.assert(t, frame.At(tt.x, 1).Style)
280+ tt.assert(t, frame.At(tt.x, tt.y).Style)
270281 })
271282 }
272283 }
273284
285+func TestPaletteDescriptionUsesSelectedRowForeground(t *testing.T) {
286+ selection := renderer.DefaultStyle()
287+ selection.HasForegroundRGB = true
288+ selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
289+ selection.HasBackgroundRGB = true
290+ selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
291+ description := renderer.DefaultStyle()
292+ description.Italic = true
293+ description.HasForegroundRGB = true
294+ description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
295+ selectionDescription := selection
296+ selectionDescription.Italic = true
297+ selectionDescription.ForegroundRGB = renderer.RGB{R: 220, G: 221, B: 222}
298+
299+ model := New(CommandResults([]command.Command{
300+ cmd("ONE", "One", "first description"),
301+ cmd("TWO", "Two", "second description"),
302+ }))
303+ frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
304+ Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
305+ Selection: selection, Description: description, SelectionDescription: selectionDescription,
306+ }})
307+
308+ selected := frame.At(4, 1).Style
309+ require.True(t, selected.Equal(selectionDescription))
310+
311+ inactive := frame.At(4, 2).Style
312+ require.Equal(t, description.ForegroundRGB, inactive.ForegroundRGB)
313+ require.True(t, inactive.Italic)
314+}
315+
316+func TestPaletteDescriptionWithoutSelectionStylePreservesDescriptionForeground(t *testing.T) {
317+ selection := renderer.DefaultStyle()
318+ selection.HasForegroundRGB = true
319+ selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
320+ selection.HasBackgroundRGB = true
321+ selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
322+ description := renderer.DefaultStyle()
323+ description.Italic = true
324+ description.HasForegroundRGB = true
325+ description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
326+
327+ model := New(CommandResults([]command.Command{cmd("ONE", "One", "first description")}))
328+ frame := model.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: RenderStyles{
329+ Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
330+ Selection: selection, Description: description,
331+ }})
332+
333+ selected := frame.At(4, 1).Style
334+ require.Equal(t, description.ForegroundRGB, selected.ForegroundRGB)
335+ require.Equal(t, selection.BackgroundRGB, selected.BackgroundRGB)
336+ require.True(t, selected.Italic)
337+}
338+
274339 func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) {
275340 paletteColors := [16]renderer.RGB{}
276341 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
@@ -311,15 +376,19 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T)
311376 frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
312377 Base: styles.PickerBase, Row: styles.SurfaceInactive,
313378 Selection: styles.PickerSelection, Description: styles.PickerDescription,
379+ SelectionDescription: styles.PickerSelectionMuted,
314380 }})
315- description := frame.At(4, 1).Style
316- require.Equal(t, styles.SurfaceInactive.Background, description.Background)
317- require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB)
318- require.Equal(t, styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB)
381+ inactive := frame.At(4, 1).Style
382+ require.Equal(t, styles.SurfaceInactive.Background, inactive.Background)
383+ require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
384+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
319385 if tt.name == "indexed only" {
320- require.Equal(t, 2, description.Foreground)
321- require.False(t, description.HasBackgroundRGB)
386+ require.Equal(t, 2, inactive.Foreground)
387+ require.False(t, inactive.HasBackgroundRGB)
322388 }
389+
390+ selected := frame.At(4, 2).Style
391+ require.True(t, selected.Equal(styles.PickerSelectionMuted))
323392 })
324393 }
325394 }
@@ -203,10 +203,11 @@ func frameRow(frame renderer.Frame, y int) string {
203 203
204 func TestRenderUsesConfiguredStyles(t *testing.T) {204 func TestRenderUsesConfiguredStyles(t *testing.T) {
205 tests := []struct {205 tests := []struct {
206- name string206+ name string
207- styles func() RenderStyles207+ styles func() RenderStyles
208- x int208+ x, y int
209- assert func(t *testing.T, style renderer.Style)209+ unfiltered bool
210+ assert func(t *testing.T, style renderer.Style)
210 }{211 }{
211 {212 {
212 name: "selection style for fuzzy highlights",213 name: "selection style for fuzzy highlights",
@@ -219,6 +220,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
219 return styles220 return styles
220 },221 },
221 x: 0,222 x: 0,
223+ y: 1,
222 assert: func(t *testing.T, style renderer.Style) {224 assert: func(t *testing.T, style renderer.Style) {
223 require.True(t, style.Bold)225 require.True(t, style.Bold)
224 require.False(t, style.Inverse)226 require.False(t, style.Inverse)
@@ -237,7 +239,9 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
237 styles.Description = muted239 styles.Description = muted
238 return styles240 return styles
239 },241 },
240- x: 4,242+ x: 4,
243+ y: 2,
244+ unfiltered: true,
241 assert: func(t *testing.T, style renderer.Style) {245 assert: func(t *testing.T, style renderer.Style) {
242 require.True(t, style.Italic)246 require.True(t, style.Italic)
243 require.True(t, style.HasForegroundRGB)247 require.True(t, style.HasForegroundRGB)
@@ -250,9 +254,11 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
250 styles: func() RenderStyles {254 styles: func() RenderStyles {
251 styles := DefaultRenderStyles()255 styles := DefaultRenderStyles()
252 styles.Description.Italic = false256 styles.Description.Italic = false
257+ styles.SelectionDescription.Italic = false
253 return styles258 return styles
254 },259 },
255 x: 4,260 x: 4,
261+ y: 1,
256 assert: func(t *testing.T, style renderer.Style) {262 assert: func(t *testing.T, style renderer.Style) {
257 require.False(t, style.Italic)263 require.False(t, style.Italic)
258 },264 },
@@ -261,16 +267,75 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
261 267
262 for _, tt := range tests {268 for _, tt := range tests {
263 t.Run(tt.name, func(t *testing.T) {269 t.Run(tt.name, func(t *testing.T) {
264- m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))270+ m := New(CommandResults([]command.Command{
265- m.Insert('c')271+ cmd("CPY", "Copy", "Enter copy mode"),
266- m.Insert('y')272+ cmd("CNT", "New", "Create tab"),
273+ }))
274+ if !tt.unfiltered {
275+ m.Insert('c')
276+ m.Insert('y')
277+ }
267 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})278 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
268 279
269- tt.assert(t, frame.At(tt.x, 1).Style)280+ tt.assert(t, frame.At(tt.x, tt.y).Style)
270 })281 })
271 }282 }
272 }283 }
273 284
285+func TestPaletteDescriptionUsesSelectedRowForeground(t *testing.T) {
286+ selection := renderer.DefaultStyle()
287+ selection.HasForegroundRGB = true
288+ selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
289+ selection.HasBackgroundRGB = true
290+ selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
291+ description := renderer.DefaultStyle()
292+ description.Italic = true
293+ description.HasForegroundRGB = true
294+ description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
295+ selectionDescription := selection
296+ selectionDescription.Italic = true
297+ selectionDescription.ForegroundRGB = renderer.RGB{R: 220, G: 221, B: 222}
298+
299+ model := New(CommandResults([]command.Command{
300+ cmd("ONE", "One", "first description"),
301+ cmd("TWO", "Two", "second description"),
302+ }))
303+ frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
304+ Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
305+ Selection: selection, Description: description, SelectionDescription: selectionDescription,
306+ }})
307+
308+ selected := frame.At(4, 1).Style
309+ require.True(t, selected.Equal(selectionDescription))
310+
311+ inactive := frame.At(4, 2).Style
312+ require.Equal(t, description.ForegroundRGB, inactive.ForegroundRGB)
313+ require.True(t, inactive.Italic)
314+}
315+
316+func TestPaletteDescriptionWithoutSelectionStylePreservesDescriptionForeground(t *testing.T) {
317+ selection := renderer.DefaultStyle()
318+ selection.HasForegroundRGB = true
319+ selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
320+ selection.HasBackgroundRGB = true
321+ selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
322+ description := renderer.DefaultStyle()
323+ description.Italic = true
324+ description.HasForegroundRGB = true
325+ description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
326+
327+ model := New(CommandResults([]command.Command{cmd("ONE", "One", "first description")}))
328+ frame := model.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: RenderStyles{
329+ Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
330+ Selection: selection, Description: description,
331+ }})
332+
333+ selected := frame.At(4, 1).Style
334+ require.Equal(t, description.ForegroundRGB, selected.ForegroundRGB)
335+ require.Equal(t, selection.BackgroundRGB, selected.BackgroundRGB)
336+ require.True(t, selected.Italic)
337+}
338+
274 func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) {339 func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) {
275 paletteColors := [16]renderer.RGB{}340 paletteColors := [16]renderer.RGB{}
276 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}341 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
@@ -311,15 +376,19 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T)
311 frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{376 frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
312 Base: styles.PickerBase, Row: styles.SurfaceInactive,377 Base: styles.PickerBase, Row: styles.SurfaceInactive,
313 Selection: styles.PickerSelection, Description: styles.PickerDescription,378 Selection: styles.PickerSelection, Description: styles.PickerDescription,
379+ SelectionDescription: styles.PickerSelectionMuted,
314 }})380 }})
315- description := frame.At(4, 1).Style381+ inactive := frame.At(4, 1).Style
316- require.Equal(t, styles.SurfaceInactive.Background, description.Background)382+ require.Equal(t, styles.SurfaceInactive.Background, inactive.Background)
317- require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, description.HasBackgroundRGB)383+ require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
318- require.Equal(t, styles.SurfaceInactive.BackgroundRGB, description.BackgroundRGB)384+ require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
319 if tt.name == "indexed only" {385 if tt.name == "indexed only" {
320- require.Equal(t, 2, description.Foreground)386+ require.Equal(t, 2, inactive.Foreground)
321- require.False(t, description.HasBackgroundRGB)387+ require.False(t, inactive.HasBackgroundRGB)
322 }388 }
389+
390+ selected := frame.At(4, 2).Style
391+ require.True(t, selected.Equal(styles.PickerSelectionMuted))
323 })392 })
324 }393 }
325 }394 }

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close