summaryrefslogtreecommitdiffhomepage
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
parent085c0e4e2b8518d740f75372367a44d19b22f90e (diff)
downloadopencode-85dbfeb3147cefa597938a315f0848a0d978640b.tar.gz
opencode-85dbfeb3147cefa597938a315f0848a0d978640b.zip
feat(tui): @symbol attachments
-rw-r--r--packages/opencode/src/cli/cmd/debug/lsp.ts15
-rw-r--r--packages/opencode/src/lsp/index.ts95
-rw-r--r--packages/opencode/src/session/index.ts59
-rw-r--r--packages/tui/internal/completions/commands.go23
-rw-r--r--packages/tui/internal/completions/files.go (renamed from packages/tui/internal/completions/files-folders.go)26
-rw-r--r--packages/tui/internal/completions/symbols.go118
-rw-r--r--packages/tui/internal/components/chat/editor.go41
-rw-r--r--packages/tui/internal/components/dialog/complete.go174
-rw-r--r--packages/tui/internal/tui/tui.go15
9 files changed, 449 insertions, 117 deletions
diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts
index 28e01912f..ac1bac7c1 100644
--- a/packages/opencode/src/cli/cmd/debug/lsp.ts
+++ b/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))
+ })
+ },
+})
diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts
index 6fea09d79..2c83f08dc 100644
--- a/packages/opencode/src/lsp/index.ts
+++ b/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))
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 7b885e105..c2ae4b2cc 100644
--- a/packages/opencode/src/session/index.ts
+++ b/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",
diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go
index 3a5dc3bb1..bb48d540c 100644
--- a/packages/tui/internal/completions/commands.go
+++ b/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)
}
}
diff --git a/packages/tui/internal/completions/files-folders.go b/packages/tui/internal/completions/files.go
index 55e0c1a1b..861762db8 100644
--- a/packages/tui/internal/completions/files-folders.go
+++ b/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() {
diff --git a/packages/tui/internal/completions/symbols.go b/packages/tui/internal/completions/symbols.go
new file mode 100644
index 000000000..fea1b7117
--- /dev/null
+++ b/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,
+ }
+}
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
}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 73dd01186..38b61efab 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/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,