@@ -29,23 +29,30 @@ type completionItemCmp struct {
2929 focus bool
3030 matchIndexes []int
3131 bgColor color.Color
32+ shortcut string
3233}
3334
34−type completionOptions func(*completionItemCmp)
35+type CompletionOption func(*completionItemCmp)
3536
36−func WithBackgroundColor(c color.Color) completionOptions {
37+func WithBackgroundColor(c color.Color) CompletionOption {
3738 return func(cmp *completionItemCmp) {
3839 cmp.bgColor = c
3940 }
4041}
4142
42−func WithMatchIndexes(indexes ...int) completionOptions {
43+func WithMatchIndexes(indexes ...int) CompletionOption {
4344 return func(cmp *completionItemCmp) {
4445 cmp.matchIndexes = indexes
4546 }
4647}
4748
48−func NewCompletionItem(text string, value any, opts ...completionOptions) CompletionItem {
49+func WithShortcut(shortcut string) CompletionOption {
50+ return func(cmp *completionItemCmp) {
51+ cmp.shortcut = shortcut
52+ }
53+}
54+
55+func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
4956 c := &completionItemCmp{
5057 text: text,
5158 value: value,
@@ -71,7 +78,14 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
7178func (c *completionItemCmp) View() tea.View {
7279 t := styles.CurrentTheme()
7380
74− titleStyle := t.S().Text.Padding(0, 1).Width(c.width)
81+ itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
82+ innerWidth := c.width - 2 // Account for padding
83+
84+ if c.shortcut != "" {
85+ innerWidth -= lipgloss.Width(c.shortcut)
86+ }
87+
88+ titleStyle := t.S().Text.Width(innerWidth)
7589 titleMatchStyle := t.S().Text.Underline(true)
7690 if c.bgColor != nil {
7791 titleStyle = titleStyle.Background(c.bgColor)
@@ -79,36 +93,49 @@ func (c *completionItemCmp) View() tea.View {
7993 }
8094
8195 if c.focus {
82− titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width)
96+ titleStyle = t.S().TextSelected.Width(innerWidth)
8397 titleMatchStyle = t.S().TextSelected.Underline(true)
98+ itemStyle = itemStyle.Background(t.Primary)
8499 }
85100
86101 var truncatedTitle string
87− var adjustedMatchIndexes []int
88102
89− availableWidth := c.width - 2 // Account for padding
90− if len(c.matchIndexes) > 0 && len(c.text) > availableWidth {
103+ if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
91104 // Smart truncation: ensure the last matching part is visible
92− truncatedTitle, adjustedMatchIndexes = c.smartTruncate(c.text, availableWidth, c.matchIndexes)
105+ truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
93106 } else {
94107 // No matches, use regular truncation
95− truncatedTitle = ansi.Truncate(c.text, availableWidth, "…")
96− adjustedMatchIndexes = c.matchIndexes
108+ truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
97109 }
98110
99111 text := titleStyle.Render(truncatedTitle)
100− if len(adjustedMatchIndexes) > 0 {
112+ if len(c.matchIndexes) > 0 {
101113 var ranges []lipgloss.Range
102− for _, rng := range matchedRanges(adjustedMatchIndexes) {
114+ for _, rng := range matchedRanges(c.matchIndexes) {
103115 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
104116 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
105117 // so we need to adjust it here:
106− start, stop := bytePosToVisibleCharPos(text, rng)
118+ start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
107119 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
108120 }
109121 text = lipgloss.StyleRanges(text, ranges...)
110122 }
111− return tea.NewView(text)
123+ parts := []string{text}
124+ if c.shortcut != "" {
125+ // Add the shortcut at the end
126+ shortcutStyle := t.S().Muted
127+ if c.focus {
128+ shortcutStyle = t.S().TextSelected
129+ }
130+ parts = append(parts, shortcutStyle.Render(c.shortcut))
131+ }
132+ item := itemStyle.Render(
133+ lipgloss.JoinHorizontal(
134+ lipgloss.Left,
135+ parts...,
136+ ),
137+ )
138+ return tea.NewView(item)
112139}
113140
114141// Blur implements CommandItem.
@@ -141,9 +168,6 @@ func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
141168
142169func (c *completionItemCmp) MatchIndexes(indexes []int) {
143170 c.matchIndexes = indexes
144− for i := range c.matchIndexes {
145− c.matchIndexes[i] += 1 // Adjust for the padding we add in View
146− }
147171}
148172
149173func (c *completionItemCmp) FilterValue() string {
@@ -155,18 +179,18 @@ func (c *completionItemCmp) Value() any {
155179}
156180
157181// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
158−func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) (string, []int) {
182+func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
159183 if width <= 0 {
160− return "", []int{}
184+ return ""
161185 }
162186
163187 textLen := ansi.StringWidth(text)
164188 if textLen <= width {
165− return text, matchIndexes
189+ return text
166190 }
167191
168192 if len(matchIndexes) == 0 {
169− return ansi.Truncate(text, width, "…"), []int{}
193+ return ansi.Truncate(text, width, "…")
170194 }
171195
172196 // Find the last match position
@@ -187,7 +211,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [
187211
188212 // If the last match is within the available width, truncate from the end
189213 if lastMatchVisualPos < availableWidth {
190− return ansi.Truncate(text, width, "…"), matchIndexes
214+ return ansi.Truncate(text, width, "…")
191215 }
192216
193217 // Calculate the start position to ensure the last match is visible
@@ -209,20 +233,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [
209233 // Truncate to fit width with ellipsis
210234 truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
211235 truncatedText = "…" + truncatedText
212−
213− // Adjust match indexes for the new truncated string
214− adjustedIndexes := []int{}
215− for _, idx := range matchIndexes {
216− if idx >= startBytePos {
217− newIdx := idx - startBytePos + 1 //
218− // Check if this match is still within the truncated string
219− if newIdx < len(truncatedText) {
220− adjustedIndexes = append(adjustedIndexes, newIdx)
221− }
222− }
223− }
224−
225− return truncatedText, adjustedIndexes
236+ return truncatedText
226237}
227238
228239func matchedRanges(in []int) [][2]int {