Skip to content

Commit 73991b0

Browse files
authored
Merge pull request #1005 from krissetto/pasted-attachments
Attachment improvements - Pasted text attachments
2 parents 1c9a9c7 + 17885db commit 73991b0

File tree

9 files changed

+1129
-116
lines changed

9 files changed

+1129
-116
lines changed

pkg/tui/components/completion/completion.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type Item struct {
2222
Description string
2323
Value string
2424
Execute func() tea.Cmd
25+
Pinned bool // Pinned items always appear at the top, in original order
2526
}
2627

2728
type OpenMsg struct {
@@ -83,13 +84,17 @@ type Manager interface {
8384

8485
GetLayers() []*lipgloss.Layer
8586
Open() bool
87+
// SetEditorBottom sets the height from the bottom of the screen where the editor ends.
88+
// This is used to position the completion popup above the editor.
89+
SetEditorBottom(height int)
8690
}
8791

8892
// manager represents an item completion component that manages completion state and UI
8993
type manager struct {
9094
keyMap completionKeyMap
9195
width int
9296
height int
97+
editorBottom int // height from screen bottom where editor ends (for popup positioning)
9398
items []Item
9499
filteredItems []Item
95100
query string
@@ -182,6 +187,10 @@ func (c *manager) SetSize(width, height int) tea.Cmd {
182187
return nil
183188
}
184189

190+
func (c *manager) SetEditorBottom(height int) {
191+
c.editorBottom = height
192+
}
193+
185194
func (c *manager) View() string {
186195
if !c.visible {
187196
return ""
@@ -237,11 +246,15 @@ func (c *manager) GetLayers() []*lipgloss.Layer {
237246
view := c.View()
238247
viewHeight := lipgloss.Height(view)
239248

240-
editorHeight := 4
249+
// Use actual editor height if set, otherwise fall back to reasonable default
250+
editorHeight := c.editorBottom
251+
if editorHeight == 0 {
252+
editorHeight = 4
253+
}
241254
yPos := max(c.height-viewHeight-editorHeight-1, 0)
242255

243256
return []*lipgloss.Layer{
244-
lipgloss.NewLayer(view).SetContent(view).X(1).Y(yPos),
257+
lipgloss.NewLayer(view).SetContent(view).X(styles.AppPaddingLeft).Y(yPos),
245258
}
246259
}
247260

@@ -256,6 +269,7 @@ func (c *manager) filterItems(query string) {
256269
}
257270

258271
pattern := []rune(strings.ToLower(query))
272+
var pinnedItems []Item
259273
var matches []matchResult
260274

261275
for _, item := range c.items {
@@ -271,18 +285,25 @@ func (c *manager) filterItems(query string) {
271285
)
272286

273287
if result.Start >= 0 {
274-
matches = append(matches, matchResult{
275-
item: item,
276-
score: result.Score,
277-
})
288+
if item.Pinned {
289+
// Pinned items keep their original order at the top
290+
pinnedItems = append(pinnedItems, item)
291+
} else {
292+
matches = append(matches, matchResult{
293+
item: item,
294+
score: result.Score,
295+
})
296+
}
278297
}
279298
}
280299

281300
sort.Slice(matches, func(i, j int) bool {
282301
return matches[i].score > matches[j].score
283302
})
284303

285-
c.filteredItems = make([]Item, 0, len(matches))
304+
// Build result: pinned items first, then sorted matches
305+
c.filteredItems = make([]Item, 0, len(pinnedItems)+len(matches))
306+
c.filteredItems = append(c.filteredItems, pinnedItems...)
286307
for _, match := range matches {
287308
c.filteredItems = append(c.filteredItems, match.item)
288309
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package editor
2+
3+
import (
4+
"strings"
5+
6+
"charm.land/lipgloss/v2"
7+
"github.com/mattn/go-runewidth"
8+
9+
"github.com/docker/cagent/pkg/tui/styles"
10+
)
11+
12+
const (
13+
bannerSeparatorText = " • "
14+
// BorderLeft + PaddingLeft from banner style
15+
bannerContentOffset = 2
16+
)
17+
18+
type attachmentBanner struct {
19+
items []bannerItem
20+
height int // cached height after last render
21+
regions []bannerRegion
22+
}
23+
24+
type bannerItem struct {
25+
label string
26+
placeholder string
27+
}
28+
29+
type bannerRegion struct {
30+
start int
31+
end int
32+
item bannerItem
33+
}
34+
35+
func newAttachmentBanner() *attachmentBanner {
36+
return &attachmentBanner{}
37+
}
38+
39+
func (b *attachmentBanner) SetItems(items []bannerItem) {
40+
b.items = items
41+
b.updateHeight()
42+
}
43+
44+
// Height returns the number of lines this banner will take when rendered.
45+
func (b *attachmentBanner) Height() int {
46+
return b.height
47+
}
48+
49+
// updateHeight recalculates the banner height based on current state.
50+
func (b *attachmentBanner) updateHeight() {
51+
if len(b.items) == 0 {
52+
b.height = 0
53+
return
54+
}
55+
// Banner takes 1 line when visible
56+
b.height = 1
57+
}
58+
59+
func (b *attachmentBanner) View() string {
60+
if len(b.items) == 0 {
61+
return ""
62+
}
63+
64+
// Build pill-style badges for each attachment
65+
var pills []string
66+
for _, item := range b.items {
67+
name, size := parseLabel(item.label)
68+
69+
// Create a nice pill: icon + name + size
70+
pill := styles.AttachmentIconStyle.Render("📎 ") +
71+
styles.AttachmentBadgeStyle.Render(name)
72+
if size != "" {
73+
pill += " " + styles.AttachmentSizeStyle.Render(size)
74+
}
75+
pills = append(pills, pill)
76+
}
77+
78+
separator := styles.MutedStyle.Render(bannerSeparatorText)
79+
content := strings.Join(pills, separator)
80+
81+
b.buildRegions(pills, separator)
82+
83+
// Wrap in banner style with subtle left border
84+
banner := lipgloss.NewStyle().
85+
BorderLeft(true).
86+
BorderStyle(lipgloss.ThickBorder()).
87+
BorderForeground(styles.Info).
88+
PaddingLeft(1).
89+
PaddingRight(1).
90+
Foreground(styles.TextSecondary).
91+
Render(content)
92+
93+
return styles.AttachmentBannerStyle.Render(banner)
94+
}
95+
96+
func (b *attachmentBanner) buildRegions(pills []string, separator string) {
97+
b.regions = b.regions[:0]
98+
if len(pills) == 0 {
99+
return
100+
}
101+
102+
pos := 0
103+
sepWidth := runewidth.StringWidth(stripANSI(separator))
104+
105+
for i, pill := range pills {
106+
if i > 0 {
107+
pos += sepWidth
108+
}
109+
width := runewidth.StringWidth(stripANSI(pill))
110+
b.regions = append(b.regions, bannerRegion{
111+
start: pos,
112+
end: pos + width,
113+
item: b.items[i],
114+
})
115+
pos += width
116+
}
117+
}
118+
119+
func (b *attachmentBanner) HitTest(x int) (bannerItem, bool) {
120+
if len(b.regions) == 0 {
121+
return bannerItem{}, false
122+
}
123+
124+
rel := x - bannerContentOffset
125+
if rel < 0 {
126+
return bannerItem{}, false
127+
}
128+
129+
for _, region := range b.regions {
130+
if rel >= region.start && rel < region.end {
131+
return region.item, true
132+
}
133+
}
134+
return bannerItem{}, false
135+
}
136+
137+
// parseLabel splits a label like "paste-1 (21.1 KB)" into name and size parts.
138+
func parseLabel(label string) (name, size string) {
139+
// Find the last opening parenthesis for size
140+
idx := strings.LastIndex(label, " (")
141+
if idx > 0 && strings.HasSuffix(label, ")") {
142+
return label[:idx], label[idx+1:]
143+
}
144+
return label, ""
145+
}

0 commit comments

Comments
 (0)