@@ -29,23 +29,30 @@ type completionItemCmp struct {
29 focus bool
29 focus bool
30 matchIndexes []int
30 matchIndexes []int
31 bgColor color.Color
31 bgColor color.Color
34−type completionOptions func(*completionItemCmp)
35+type CompletionOption func(*completionItemCmp)
36−func WithBackgroundColor(c color.Color) completionOptions {
37+func WithBackgroundColor(c color.Color) CompletionOption {
37 return func(cmp *completionItemCmp) {
38 return func(cmp *completionItemCmp) {
38 cmp.bgColor = c
39 cmp.bgColor = c
42−func WithMatchIndexes(indexes ...int) completionOptions {
43+func WithMatchIndexes(indexes ...int) CompletionOption {
43 return func(cmp *completionItemCmp) {
44 return func(cmp *completionItemCmp) {
44 cmp.matchIndexes = indexes
45 cmp.matchIndexes = indexes
49+func WithShortcut(shortcut string) CompletionOption {
50+ return func(cmp *completionItemCmp) {
51+ cmp.shortcut = shortcut
48−func NewCompletionItem(text string, value any, opts ...completionOptions) CompletionItem {
55+func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
49 c := &completionItemCmp{
56 c := &completionItemCmp{
50 text: text,
57 text: text,
51 value: value,
58 value: value,
@@ -71,7 +78,14 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
71func (c *completionItemCmp) View() tea.View {
78func (c *completionItemCmp) View() tea.View {
72 t := styles.CurrentTheme()
79 t := styles.CurrentTheme()
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
84+ if c.shortcut != "" {
85+ innerWidth -= lipgloss.Width(c.shortcut)
88+ titleStyle := t.S().Text.Width(innerWidth)
75 titleMatchStyle := t.S().Text.Underline(true)
89 titleMatchStyle := t.S().Text.Underline(true)
76 if c.bgColor != nil {
90 if c.bgColor != nil {
77 titleStyle = titleStyle.Background(c.bgColor)
91 titleStyle = titleStyle.Background(c.bgColor)
@@ -79,36 +93,49 @@ func (c *completionItemCmp) View() tea.View {
81 if c.focus {
95 if c.focus {
82− titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width)
96+ titleStyle = t.S().TextSelected.Width(innerWidth)
83 titleMatchStyle = t.S().TextSelected.Underline(true)
97 titleMatchStyle = t.S().TextSelected.Underline(true)
98+ itemStyle = itemStyle.Background(t.Primary)
86 var truncatedTitle string
101 var truncatedTitle string
87− var adjustedMatchIndexes []int
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 {
91 // Smart truncation: ensure the last matching part is visible
104 // 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)
94 // No matches, use regular truncation
107 // No matches, use regular truncation
95− truncatedTitle = ansi.Truncate(c.text, availableWidth, "…")
108+ truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
96− adjustedMatchIndexes = c.matchIndexes
99 text := titleStyle.Render(truncatedTitle)
111 text := titleStyle.Render(truncatedTitle)
100− if len(adjustedMatchIndexes) > 0 {
112+ if len(c.matchIndexes) > 0 {
101 var ranges []lipgloss.Range
113 var ranges []lipgloss.Range
102− for _, rng := range matchedRanges(adjustedMatchIndexes) {
114+ for _, rng := range matchedRanges(c.matchIndexes) {
103 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
115 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
104 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
116 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
105 // so we need to adjust it here:
117 // so we need to adjust it here:
106− start, stop := bytePosToVisibleCharPos(text, rng)
118+ start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
107 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
119 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
109 text = lipgloss.StyleRanges(text, ranges...)
121 text = lipgloss.StyleRanges(text, ranges...)
123+ parts := []string{text}
124+ if c.shortcut != "" {
125+ // Add the shortcut at the end
126+ shortcutStyle := t.S().Muted
128+ shortcutStyle = t.S().TextSelected
130+ parts = append(parts, shortcutStyle.Render(c.shortcut))
132+ item := itemStyle.Render(
133+ lipgloss.JoinHorizontal(
111− return tea.NewView(text)
138+ return tea.NewView(item)
114// Blur implements CommandItem.
141// Blur implements CommandItem.
@@ -141,9 +168,6 @@ func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
142func (c *completionItemCmp) MatchIndexes(indexes []int) {
169func (c *completionItemCmp) MatchIndexes(indexes []int) {
143 c.matchIndexes = indexes
170 c.matchIndexes = indexes
144− for i := range c.matchIndexes {
145− c.matchIndexes[i] += 1 // Adjust for the padding we add in View
149func (c *completionItemCmp) FilterValue() string {
173func (c *completionItemCmp) FilterValue() string {
@@ -155,18 +179,18 @@ func (c *completionItemCmp) Value() any {
157// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
181// 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 {
159 if width <= 0 {
183 if width <= 0 {
160− return "", []int{}
184+ return ""
163 textLen := ansi.StringWidth(text)
187 textLen := ansi.StringWidth(text)
164 if textLen <= width {
188 if textLen <= width {
165− return text, matchIndexes
189+ return text
168 if len(matchIndexes) == 0 {
192 if len(matchIndexes) == 0 {
169− return ansi.Truncate(text, width, "…"), []int{}
193+ return ansi.Truncate(text, width, "…")
172 // Find the last match position
196 // Find the last match position
@@ -187,7 +211,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [
188 // If the last match is within the available width, truncate from the end
212 // If the last match is within the available width, truncate from the end
189 if lastMatchVisualPos < availableWidth {
213 if lastMatchVisualPos < availableWidth {
190− return ansi.Truncate(text, width, "…"), matchIndexes
214+ return ansi.Truncate(text, width, "…")
193 // Calculate the start position to ensure the last match is visible
217 // Calculate the start position to ensure the last match is visible
@@ -209,20 +233,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [
209 // Truncate to fit width with ellipsis
233 // Truncate to fit width with ellipsis
210 truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
234 truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
211 truncatedText = "…" + truncatedText
235 truncatedText = "…" + truncatedText
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)
225− return truncatedText, adjustedIndexes
236+ return truncatedText
228func matchedRanges(in []int) [][2]int {
239func matchedRanges(in []int) [][2]int {