summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-07-10 05:51:47 -0500
committeradamdottv <[email protected]>2025-07-10 05:53:00 -0500
commit85dbfeb3147cefa597938a315f0848a0d978640b (patch)
tree050bc9dd4a8a1a4e84431ccd637bdfc383ba433e /packages/tui/internal/components
parent085c0e4e2b8518d740f75372367a44d19b22f90e (diff)
downloadopencode-85dbfeb3147cefa597938a315f0848a0d978640b.tar.gz
opencode-85dbfeb3147cefa597938a315f0848a0d978640b.zip
feat(tui): @symbol attachments
Diffstat (limited to 'packages/tui/internal/components')
-rw-r--r--packages/tui/internal/components/chat/editor.go41
-rw-r--r--packages/tui/internal/components/dialog/complete.go174
2 files changed, 143 insertions, 72 deletions
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 4f6249722..444f5bef1 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -140,9 +140,9 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg:
- switch msg.ProviderID {
+ switch msg.Item.GetProviderID() {
case "commands":
- commandName := strings.TrimPrefix(msg.CompletionValue, "/")
+ commandName := strings.TrimPrefix(msg.Item.GetValue(), "/")
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
@@ -152,7 +152,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
- m.textarea.InsertString(msg.CompletionValue + " ")
+ m.textarea.InsertString(msg.Item.GetValue() + " ")
return m, nil
}
@@ -163,7 +163,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Now, insert the attachment at the position where the '@' was.
// The cursor is now at `atIndex` after the replacement.
- filePath := msg.CompletionValue
+ filePath := msg.Item.GetValue()
extension := filepath.Ext(filePath)
mediaType := ""
switch extension {
@@ -186,15 +186,32 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
- default:
- existingValue := m.textarea.Value()
- lastSpaceIndex := strings.LastIndex(existingValue, " ")
- if lastSpaceIndex == -1 {
- m.textarea.SetValue(msg.CompletionValue + " ")
- } else {
- modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
- m.textarea.SetValue(modifiedValue + " ")
+ case "symbols":
+ atIndex := m.textarea.LastRuneIndex('@')
+ if atIndex == -1 {
+ // Should not happen, but as a fallback, just insert.
+ m.textarea.InsertString(msg.Item.GetValue() + " ")
+ return m, nil
}
+
+ cursorCol := m.textarea.CursorColumn()
+ m.textarea.ReplaceRange(atIndex, cursorCol, "")
+
+ symbol := msg.Item.GetRaw().(opencode.Symbol)
+ parts := strings.Split(symbol.Name, ".")
+ lastPart := parts[len(parts)-1]
+ attachment := &textarea.Attachment{
+ ID: uuid.NewString(),
+ Display: "@" + lastPart,
+ URL: msg.Item.GetValue(),
+ Filename: lastPart,
+ MediaType: "text/plain",
+ }
+ m.textarea.InsertAttachment(attachment)
+ m.textarea.InsertString(" ")
+ return m, nil
+ default:
+ slog.Debug("Unknown provider", "provider", msg.Item.GetProviderID())
return m, nil
}
}
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
index 8173c8af1..0e8019a23 100644
--- a/packages/tui/internal/components/dialog/complete.go
+++ b/packages/tui/internal/components/dialog/complete.go
@@ -2,12 +2,15 @@ package dialog
import (
"log/slog"
+ "sort"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/lithammer/fuzzysearch/fuzzy"
+ "github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -15,32 +18,35 @@ import (
)
type CompletionItem struct {
- Title string
- Value string
+ Title string
+ Value string
+ ProviderID string
+ Raw any
}
type CompletionItemI interface {
list.ListItem
GetValue() string
DisplayValue() string
+ GetProviderID() string
+ GetRaw() any
}
func (ci *CompletionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Foreground(t.Text())
+ truncatedStr := truncate.String(string(ci.DisplayValue()), uint(width-4))
+
itemStyle := baseStyle.
Background(t.BackgroundElement()).
- Width(width).
Padding(0, 1)
if selected {
itemStyle = itemStyle.Foreground(t.Primary())
}
- title := itemStyle.Render(
- ci.DisplayValue(),
- )
+ title := itemStyle.Render(truncatedStr)
return title
}
@@ -52,6 +58,14 @@ func (ci *CompletionItem) GetValue() string {
return ci.Value
}
+func (ci *CompletionItem) GetProviderID() string {
+ return ci.ProviderID
+}
+
+func (ci *CompletionItem) GetRaw() any {
+ return ci.Raw
+}
+
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
return &completionItem
}
@@ -63,9 +77,8 @@ type CompletionProvider interface {
}
type CompletionSelectedMsg struct {
- SearchString string
- CompletionValue string
- ProviderID string
+ Item CompletionItemI
+ SearchString string
}
type CompletionDialogCompleteItemMsg struct {
@@ -83,7 +96,7 @@ type CompletionDialog interface {
type completionDialogComponent struct {
query string
- completionProvider CompletionProvider
+ providers []CompletionProvider
width int
height int
pseudoSearchTextArea textarea.Model
@@ -109,6 +122,52 @@ func (c *completionDialogComponent) Init() tea.Cmd {
return nil
}
+func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
+ return func() tea.Msg {
+ allItems := make([]CompletionItemI, 0)
+
+ // Collect results from all providers
+ for _, provider := range c.providers {
+ items, err := provider.GetChildEntries(query)
+ if err != nil {
+ slog.Error(
+ "Failed to get completion items",
+ "provider",
+ provider.GetId(),
+ "error",
+ err,
+ )
+ continue
+ }
+ allItems = append(allItems, items...)
+ }
+
+ // If there's a query, use fuzzy ranking to sort results
+ if query != "" && len(allItems) > 0 {
+ // Create a slice of display values for fuzzy matching
+ displayValues := make([]string, len(allItems))
+ for i, item := range allItems {
+ displayValues[i] = item.DisplayValue()
+ }
+
+ // Get fuzzy matches with ranking
+ matches := fuzzy.RankFindFold(query, displayValues)
+
+ // Sort by score (best matches first)
+ sort.Sort(matches)
+
+ // Reorder items based on fuzzy ranking
+ rankedItems := make([]CompletionItemI, 0, len(matches))
+ for _, match := range matches {
+ rankedItems = append(rankedItems, allItems[match.OriginalIndex])
+ }
+
+ return rankedItems
+ }
+
+ return allItems
+ }
+}
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
@@ -126,14 +185,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if query != c.query {
c.query = query
- cmd = func() tea.Msg {
- items, err := c.completionProvider.GetChildEntries(query)
- if err != nil {
- slog.Error("Failed to get completion items", "error", err)
- }
- return items
- }
- cmds = append(cmds, cmd)
+ cmds = append(cmds, c.getAllCompletions(query))
}
u, cmd := c.list.Update(msg)
@@ -149,23 +201,18 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return c, c.complete(item)
case key.Matches(msg, completionDialogKeys.Cancel):
- // Only close on backspace when there are no characters left, unless we're back to just the trigger
value := c.pseudoSearchTextArea.Value()
- if msg.String() != "backspace" || (len(value) <= len(c.trigger) && value != c.trigger) {
+ width := lipgloss.Width(value)
+ triggerWidth := lipgloss.Width(c.trigger)
+ // Only close on backspace when there are no characters left, unless we're back to just the trigger
+ if msg.String() != "backspace" || (width <= triggerWidth && value != c.trigger) {
return c, c.close()
}
}
return c, tea.Batch(cmds...)
} else {
- cmd := func() tea.Msg {
- items, err := c.completionProvider.GetChildEntries("")
- if err != nil {
- slog.Error("Failed to get completion items", "error", err)
- }
- return items
- }
- cmds = append(cmds, cmd)
+ cmds = append(cmds, c.getAllCompletions(""))
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
return c, tea.Batch(cmds...)
}
@@ -177,19 +224,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (c *completionDialogComponent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Foreground(t.Text())
-
- maxWidth := 40
- completions := c.list.GetItems()
-
- for _, cmd := range completions {
- title := cmd.DisplayValue()
- width := lipgloss.Width(title)
- if width > maxWidth-4 {
- maxWidth = width + 4
- }
- }
-
- c.list.SetMaxWidth(maxWidth)
+ c.list.SetMaxWidth(c.width)
return baseStyle.
Padding(0, 0).
@@ -213,12 +248,10 @@ func (c *completionDialogComponent) IsEmpty() bool {
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
-
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
- SearchString: value,
- CompletionValue: item.GetValue(),
- ProviderID: c.completionProvider.GetId(),
+ SearchString: value,
+ Item: item,
}),
c.close(),
)
@@ -230,32 +263,53 @@ func (c *completionDialogComponent) close() tea.Cmd {
return util.CmdHandler(CompletionDialogCloseMsg{})
}
-func NewCompletionDialogComponent(completionProvider CompletionProvider, trigger string) CompletionDialog {
+func NewCompletionDialogComponent(
+ trigger string,
+ providers ...CompletionProvider,
+) CompletionDialog {
ti := textarea.New()
+ ti.SetValue(trigger)
+
+ // Use a generic empty message if we have multiple providers
+ emptyMessage := "no matching items"
+ if len(providers) == 1 {
+ emptyMessage = providers[0].GetEmptyMessage()
+ }
li := list.NewListComponent(
[]CompletionItemI{},
7,
- completionProvider.GetEmptyMessage(),
+ emptyMessage,
false,
)
- go func() {
- items, err := completionProvider.GetChildEntries("")
- if err != nil {
- slog.Error("Failed to get completion items", "error", err)
- }
- li.SetItems(items)
- }()
-
- // Initialize the textarea with the trigger character
- ti.SetValue(trigger)
-
- return &completionDialogComponent{
+ c := &completionDialogComponent{
query: "",
- completionProvider: completionProvider,
+ providers: providers,
pseudoSearchTextArea: ti,
list: li,
trigger: trigger,
}
+
+ // Load initial items from all providers
+ go func() {
+ allItems := make([]CompletionItemI, 0)
+ for _, provider := range providers {
+ items, err := provider.GetChildEntries("")
+ if err != nil {
+ slog.Error(
+ "Failed to get completion items",
+ "provider",
+ provider.GetId(),
+ "error",
+ err,
+ )
+ continue
+ }
+ allItems = append(allItems, items...)
+ }
+ li.SetItems(allItems)
+ }()
+
+ return c
}