Просмотр исходного кода

feat(tui): @symbol attachments

adamdottv 7 месяцев назад
Родитель
Сommit
85dbfeb314

+ 14 - 1
packages/opencode/src/cli/cmd/debug/lsp.ts

@@ -5,7 +5,8 @@ import { Log } from "../../../util/log"
 
 export const LSPCommand = cmd({
   command: "lsp",
-  builder: (yargs) => yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
+  builder: (yargs) =>
+    yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
   async handler() {},
 })
 
@@ -31,3 +32,15 @@ export const SymbolsCommand = cmd({
     })
   },
 })
+
+export const DocumentSymbolsCommand = cmd({
+  command: "document-symbols <uri>",
+  builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
+  async handler(args) {
+    await bootstrap({ cwd: process.cwd() }, async () => {
+      using _ = Log.Default.time("document-symbols")
+      const results = await LSP.documentSymbol(args.uri)
+      console.log(JSON.stringify(results, null, 2))
+    })
+  },
+})

+ 85 - 10
packages/opencode/src/lsp/index.ts

@@ -9,22 +9,29 @@ import { Filesystem } from "../util/filesystem"
 export namespace LSP {
   const log = Log.create({ service: "lsp" })
 
+  export const Range = z
+    .object({
+      start: z.object({
+        line: z.number(),
+        character: z.number(),
+      }),
+      end: z.object({
+        line: z.number(),
+        character: z.number(),
+      }),
+    })
+    .openapi({
+      ref: "Range",
+    })
+  export type Range = z.infer<typeof Range>
+
   export const Symbol = z
     .object({
       name: z.string(),
       kind: z.number(),
       location: z.object({
         uri: z.string(),
-        range: z.object({
-          start: z.object({
-            line: z.number(),
-            character: z.number(),
-          }),
-          end: z.object({
-            line: z.number(),
-            character: z.number(),
-          }),
-        }),
+        range: Range,
       }),
     })
     .openapi({
@@ -32,6 +39,19 @@ export namespace LSP {
     })
   export type Symbol = z.infer<typeof Symbol>
 
+  export const DocumentSymbol = z
+    .object({
+      name: z.string(),
+      detail: z.string().optional(),
+      kind: z.number(),
+      range: Range,
+      selectionRange: Range,
+    })
+    .openapi({
+      ref: "DocumentSymbol",
+    })
+  export type DocumentSymbol = z.infer<typeof DocumentSymbol>
+
   const state = App.state(
     "lsp",
     async (app) => {
@@ -117,17 +137,72 @@ export namespace LSP {
     })
   }
 
+  enum SymbolKind {
+    File = 1,
+    Module = 2,
+    Namespace = 3,
+    Package = 4,
+    Class = 5,
+    Method = 6,
+    Property = 7,
+    Field = 8,
+    Constructor = 9,
+    Enum = 10,
+    Interface = 11,
+    Function = 12,
+    Variable = 13,
+    Constant = 14,
+    String = 15,
+    Number = 16,
+    Boolean = 17,
+    Array = 18,
+    Object = 19,
+    Key = 20,
+    Null = 21,
+    EnumMember = 22,
+    Struct = 23,
+    Event = 24,
+    Operator = 25,
+    TypeParameter = 26,
+  }
+
+  const kinds = [
+    SymbolKind.Class,
+    SymbolKind.Function,
+    SymbolKind.Method,
+    SymbolKind.Interface,
+    SymbolKind.Variable,
+    SymbolKind.Constant,
+    SymbolKind.Struct,
+    SymbolKind.Enum,
+  ]
+
   export async function workspaceSymbol(query: string) {
     return run((client) =>
       client.connection
         .sendRequest("workspace/symbol", {
           query,
         })
+        .then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
         .then((result: any) => result.slice(0, 10))
         .catch(() => []),
     ).then((result) => result.flat() as LSP.Symbol[])
   }
 
+  export async function documentSymbol(uri: string) {
+    return run((client) =>
+      client.connection
+        .sendRequest("textDocument/documentSymbol", {
+          textDocument: {
+            uri,
+          },
+        })
+        .catch(() => []),
+    )
+      .then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[])
+      .then((result) => result.filter(Boolean))
+  }
+
   async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
     const clients = await state().then((x) => x.clients)
     const tasks = clients.map((x) => input(x))

+ 49 - 10
packages/opencode/src/session/index.ts

@@ -36,6 +36,8 @@ import { SystemPrompt } from "./system"
 import { FileTime } from "../file/time"
 import { MessageV2 } from "./message-v2"
 import { Mode } from "./mode"
+import { LSP } from "../lsp"
+import { ReadTool } from "../tool/read"
 
 export namespace Session {
   const log = Log.create({ service: "session" })
@@ -346,31 +348,68 @@ export namespace Session {
           const url = new URL(part.url)
           switch (url.protocol) {
             case "file:":
-              const filepath = path.join(app.path.cwd, url.pathname)
-              let file = Bun.file(filepath)
+              // have to normalize, symbol search returns absolute paths
+              const relativePath = url.pathname.replace(app.path.cwd, ".")
+              const filePath = path.join(app.path.cwd, relativePath)
 
               if (part.mime === "text/plain") {
-                let text = await file.text()
+                let offset: number | undefined = undefined
+                let limit: number | undefined = undefined
                 const range = {
                   start: url.searchParams.get("start"),
                   end: url.searchParams.get("end"),
                 }
-                if (range.start != null && part.mime === "text/plain") {
-                  const lines = text.split("\n")
-                  const start = parseInt(range.start)
-                  const end = range.end ? parseInt(range.end) : lines.length
-                  text = lines.slice(start, end).join("\n")
+                if (range.start != null) {
+                  const filePath = part.url.split("?")[0]
+                  let start = parseInt(range.start)
+                  let end = range.end ? parseInt(range.end) : undefined
+                  // some LSP servers (eg, gopls) don't give full range in
+                  // workspace/symbol searches, so we'll try to find the
+                  // symbol in the document to get the full range
+                  if (start === end) {
+                    const symbols = await LSP.documentSymbol(filePath)
+                    for (const symbol of symbols) {
+                      let range: LSP.Range | undefined
+                      if ("range" in symbol) {
+                        range = symbol.range
+                      } else if ("location" in symbol) {
+                        range = symbol.location.range
+                      }
+                      if (range?.start?.line && range?.start?.line === start) {
+                        start = range.start.line
+                        end = range?.end?.line ?? start
+                        break
+                      }
+                    }
+                    offset = Math.max(start - 2, 0)
+                    if (end) {
+                      limit = end - offset + 2
+                    }
+                  }
                 }
-                FileTime.read(input.sessionID, filepath)
+                const args = { filePath, offset, limit }
+                const result = await ReadTool.execute(args, {
+                  sessionID: input.sessionID,
+                  abort: abort.signal,
+                  messageID: "", // read tool doesn't use message ID
+                  metadata: async () => {},
+                })
                 return [
                   {
                     type: "text",
                     synthetic: true,
-                    text: ["Called the Read tool on " + url.pathname, "<results>", text, "</results>"].join("\n"),
+                    text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
+                  },
+                  {
+                    type: "text",
+                    synthetic: true,
+                    text: result.output,
                   },
                 ]
               }
 
+              let file = Bun.file(filePath)
+              FileTime.read(input.sessionID, filePath)
               return [
                 {
                   type: "text",

+ 16 - 7
packages/tui/internal/completions/commands.go

@@ -29,17 +29,26 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
 	return "no matching commands"
 }
 
-func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
+func (c *CommandCompletionProvider) getCommandCompletionItem(
+	cmd commands.Command,
+	space int,
+	t theme.Theme,
+) dialog.CompletionItemI {
 	spacer := strings.Repeat(" ", space)
-	title := "  /" + cmd.PrimaryTrigger() + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
+	title := "  /" + cmd.PrimaryTrigger() + styles.NewStyle().
+		Foreground(t.TextMuted()).
+		Render(spacer+cmd.Description)
 	value := string(cmd.Name)
 	return dialog.NewCompletionItem(dialog.CompletionItem{
-		Title: title,
-		Value: value,
+		Title:      title,
+		Value:      value,
+		ProviderID: c.GetId(),
 	})
 }
 
-func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
+func (c *CommandCompletionProvider) GetChildEntries(
+	query string,
+) ([]dialog.CompletionItemI, error) {
 	t := theme.CurrentTheme()
 	commands := c.app.Commands
 
@@ -60,7 +69,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
 				continue
 			}
 			space := space - lipgloss.Width(cmd.PrimaryTrigger())
-			items = append(items, getCommandCompletionItem(cmd, space, t))
+			items = append(items, c.getCommandCompletionItem(cmd, space, t))
 		}
 		return items, nil
 	}
@@ -77,7 +86,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
 		// Add all triggers as searchable options
 		for _, trigger := range cmd.Trigger {
 			commandNames = append(commandNames, trigger)
-			commandMap[trigger] = getCommandCompletionItem(cmd, space, t)
+			commandMap[trigger] = c.getCommandCompletionItem(cmd, space, t)
 		}
 	}
 

+ 15 - 11
packages/tui/internal/completions/files-folders.go → packages/tui/internal/completions/files.go

@@ -14,20 +14,20 @@ import (
 	"github.com/sst/opencode/internal/theme"
 )
 
-type filesAndFoldersContextGroup struct {
+type filesContextGroup struct {
 	app      *app.App
 	gitFiles []dialog.CompletionItemI
 }
 
-func (cg *filesAndFoldersContextGroup) GetId() string {
+func (cg *filesContextGroup) GetId() string {
 	return "files"
 }
 
-func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
+func (cg *filesContextGroup) GetEmptyMessage() string {
 	return "no matching files"
 }
 
-func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
+func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
 	t := theme.CurrentTheme()
 	items := make([]dialog.CompletionItemI, 0)
 	base := styles.NewStyle().Background(t.BackgroundElement())
@@ -50,8 +50,10 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
 				title += red(" -" + strconv.Itoa(int(file.Removed)))
 			}
 			item := dialog.NewCompletionItem(dialog.CompletionItem{
-				Title: title,
-				Value: file.Path,
+				Title:      title,
+				Value:      file.Path,
+				ProviderID: cg.GetId(),
+				Raw:        file,
 			})
 			items = append(items, item)
 		}
@@ -60,7 +62,7 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
 	return items
 }
 
-func (cg *filesAndFoldersContextGroup) GetChildEntries(
+func (cg *filesContextGroup) GetChildEntries(
 	query string,
 ) ([]dialog.CompletionItemI, error) {
 	items := make([]dialog.CompletionItemI, 0)
@@ -94,8 +96,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
 		}
 		if !exists {
 			item := dialog.NewCompletionItem(dialog.CompletionItem{
-				Title: file,
-				Value: file,
+				Title:      file,
+				Value:      file,
+				ProviderID: cg.GetId(),
+				Raw:        file,
 			})
 			items = append(items, item)
 		}
@@ -104,8 +108,8 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
 	return items, nil
 }
 
-func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
-	cg := &filesAndFoldersContextGroup{
+func NewFileContextGroup(app *app.App) dialog.CompletionProvider {
+	cg := &filesContextGroup{
 		app: app,
 	}
 	go func() {

+ 118 - 0
packages/tui/internal/completions/symbols.go

@@ -0,0 +1,118 @@
+package completions
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"strings"
+
+	"github.com/sst/opencode-sdk-go"
+	"github.com/sst/opencode/internal/app"
+	"github.com/sst/opencode/internal/components/dialog"
+	"github.com/sst/opencode/internal/styles"
+	"github.com/sst/opencode/internal/theme"
+)
+
+type symbolsContextGroup struct {
+	app *app.App
+}
+
+func (cg *symbolsContextGroup) GetId() string {
+	return "symbols"
+}
+
+func (cg *symbolsContextGroup) GetEmptyMessage() string {
+	return "no matching symbols"
+}
+
+type SymbolKind int
+
+const (
+	SymbolKindFile          SymbolKind = 1
+	SymbolKindModule        SymbolKind = 2
+	SymbolKindNamespace     SymbolKind = 3
+	SymbolKindPackage       SymbolKind = 4
+	SymbolKindClass         SymbolKind = 5
+	SymbolKindMethod        SymbolKind = 6
+	SymbolKindProperty      SymbolKind = 7
+	SymbolKindField         SymbolKind = 8
+	SymbolKindConstructor   SymbolKind = 9
+	SymbolKindEnum          SymbolKind = 10
+	SymbolKindInterface     SymbolKind = 11
+	SymbolKindFunction      SymbolKind = 12
+	SymbolKindVariable      SymbolKind = 13
+	SymbolKindConstant      SymbolKind = 14
+	SymbolKindString        SymbolKind = 15
+	SymbolKindNumber        SymbolKind = 16
+	SymbolKindBoolean       SymbolKind = 17
+	SymbolKindArray         SymbolKind = 18
+	SymbolKindObject        SymbolKind = 19
+	SymbolKindKey           SymbolKind = 20
+	SymbolKindNull          SymbolKind = 21
+	SymbolKindEnumMember    SymbolKind = 22
+	SymbolKindStruct        SymbolKind = 23
+	SymbolKindEvent         SymbolKind = 24
+	SymbolKindOperator      SymbolKind = 25
+	SymbolKindTypeParameter SymbolKind = 26
+)
+
+func (cg *symbolsContextGroup) GetChildEntries(
+	query string,
+) ([]dialog.CompletionItemI, error) {
+	items := make([]dialog.CompletionItemI, 0)
+
+	query = strings.TrimSpace(query)
+	if query == "" {
+		return items, nil
+	}
+
+	symbols, err := cg.app.Client.Find.Symbols(
+		context.Background(),
+		opencode.FindSymbolsParams{Query: opencode.F(query)},
+	)
+	if err != nil {
+		slog.Error("Failed to get symbol completion items", "error", err)
+		return items, err
+	}
+	if symbols == nil {
+		return items, nil
+	}
+
+	t := theme.CurrentTheme()
+	baseStyle := styles.NewStyle().Background(t.BackgroundElement())
+	base := baseStyle.Render
+	muted := baseStyle.Foreground(t.TextMuted()).Render
+
+	for _, sym := range *symbols {
+		parts := strings.Split(sym.Name, ".")
+		lastPart := parts[len(parts)-1]
+		title := base(lastPart)
+
+		uriParts := strings.Split(sym.Location.Uri, "/")
+		lastTwoParts := uriParts[len(uriParts)-2:]
+		joined := strings.Join(lastTwoParts, "/")
+		title += muted(fmt.Sprintf(" %s", joined))
+
+		start := int(sym.Location.Range.Start.Line)
+		end := int(sym.Location.Range.End.Line)
+		title += muted(fmt.Sprintf(":L%d-%d", start, end))
+
+		value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end)
+
+		item := dialog.NewCompletionItem(dialog.CompletionItem{
+			Title:      title,
+			Value:      value,
+			ProviderID: cg.GetId(),
+			Raw:        sym,
+		})
+		items = append(items, item)
+	}
+
+	return items, nil
+}
+
+func NewSymbolsContextGroup(app *app.App) dialog.CompletionProvider {
+	return &symbolsContextGroup{
+		app: app,
+	}
+}

+ 29 - 12
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
 		}
 	}

+ 114 - 60
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
 }

+ 9 - 6
packages/tui/internal/tui/tui.go

@@ -65,6 +65,7 @@ type appModel struct {
 	completions          dialog.CompletionDialog
 	commandProvider      dialog.CompletionProvider
 	fileProvider         dialog.CompletionProvider
+	symbolsProvider      dialog.CompletionProvider
 	showCompletionDialog bool
 	fileCompletionActive bool
 	leaderBinding        *key.Binding
@@ -202,7 +203,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, cmd)
 
 			// Set command provider for command completion
-			a.completions = dialog.NewCompletionDialogComponent(a.commandProvider, "/")
+			a.completions = dialog.NewCompletionDialogComponent("/", a.commandProvider)
 			updated, cmd = a.completions.Update(msg)
 			a.completions = updated.(dialog.CompletionDialog)
 			cmds = append(cmds, cmd)
@@ -220,8 +221,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			a.editor = updated.(chat.EditorComponent)
 			cmds = append(cmds, cmd)
 
-			// Set file provider for file completion
-			a.completions = dialog.NewCompletionDialogComponent(a.fileProvider, "@")
+			// Set both file and symbols providers for @ completion
+			a.completions = dialog.NewCompletionDialogComponent("@", a.fileProvider, a.symbolsProvider)
 			updated, cmd = a.completions.Update(msg)
 			a.completions = updated.(dialog.CompletionDialog)
 			cmds = append(cmds, cmd)
@@ -922,7 +923,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
 		a.modal = themeDialog
 	case commands.FileListCommand:
 		a.editor.Blur()
-		provider := completions.NewFileAndFolderContextGroup(a.app)
+		provider := completions.NewFileContextGroup(a.app)
 		findDialog := dialog.NewFindDialog(provider)
 		findDialog.SetWidth(layout.Current.Container.Width - 8)
 		a.modal = findDialog
@@ -1030,11 +1031,12 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
 
 func NewModel(app *app.App) tea.Model {
 	commandProvider := completions.NewCommandCompletionProvider(app)
-	fileProvider := completions.NewFileAndFolderContextGroup(app)
+	fileProvider := completions.NewFileContextGroup(app)
+	symbolsProvider := completions.NewSymbolsContextGroup(app)
 
 	messages := chat.NewMessagesComponent(app)
 	editor := chat.NewEditorComponent(app)
-	completions := dialog.NewCompletionDialogComponent(commandProvider, "/")
+	completions := dialog.NewCompletionDialogComponent("/", commandProvider)
 
 	var leaderBinding *key.Binding
 	if app.Config.Keybinds.Leader != "" {
@@ -1050,6 +1052,7 @@ func NewModel(app *app.App) tea.Model {
 		completions:          completions,
 		commandProvider:      commandProvider,
 		fileProvider:         fileProvider,
+		symbolsProvider:      symbolsProvider,
 		leaderBinding:        leaderBinding,
 		isLeaderSequence:     false,
 		showCompletionDialog: false,