Merge agent and mode into one (#1689)

The concept of mode has been deprecated, there is now only the agent field in the config.

An agent can be cycled through as your primary agent with <tab> or you can spawn a subagent by @ mentioning it. if you include a description of when to use it, the primary agent will try to automatically use it

Full docs here: https://opencode.ai/docs/agents/
This commit is contained in:
Dax
2025-08-07 16:32:12 -04:00
committed by GitHub
parent 12f1ad521f
commit c34aec060f
42 changed files with 1755 additions and 930 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"log/slog"
@@ -27,15 +28,14 @@ type Message struct {
type App struct {
Info opencode.App
Modes []opencode.Mode
Agents []opencode.Agent
Providers []opencode.Provider
Version string
StatePath string
Config *opencode.Config
Client *opencode.Client
State *State
ModeIndex int
Mode *opencode.Mode
AgentIndex int
Provider *opencode.Provider
Model *opencode.Model
Session *opencode.Session
@@ -45,11 +45,15 @@ type App struct {
Commands commands.CommandRegistry
InitialModel *string
InitialPrompt *string
IntitialMode *string
InitialAgent *string
compactCancel context.CancelFunc
IsLeaderSequence bool
}
func (a *App) Agent() *opencode.Agent {
return &a.Agents[a.AgentIndex]
}
type SessionCreatedMsg = struct {
Session *opencode.Session
}
@@ -83,11 +87,11 @@ func New(
ctx context.Context,
version string,
appInfo opencode.App,
modes []opencode.Mode,
agents []opencode.Agent,
httpClient *opencode.Client,
initialModel *string,
initialPrompt *string,
initialMode *string,
initialAgent *string,
) (*App, error) {
util.RootPath = appInfo.Path.Root
util.CwdPath = appInfo.Path.Cwd
@@ -108,8 +112,8 @@ func New(
SaveState(appStatePath, appState)
}
if appState.ModeModel == nil {
appState.ModeModel = make(map[string]ModeModel)
if appState.AgentModel == nil {
appState.AgentModel = make(map[string]AgentModel)
}
if configInfo.Theme != "" {
@@ -121,27 +125,29 @@ func New(
appState.Theme = themeEnv
}
var modeIndex int
var mode *opencode.Mode
agentIndex := slices.IndexFunc(agents, func(a opencode.Agent) bool {
return a.Mode != "subagent"
})
var agent *opencode.Agent
modeName := "build"
if appState.Mode != "" {
modeName = appState.Mode
if appState.Agent != "" {
modeName = appState.Agent
}
if initialMode != nil && *initialMode != "" {
modeName = *initialMode
if initialAgent != nil && *initialAgent != "" {
modeName = *initialAgent
}
for i, m := range modes {
for i, m := range agents {
if m.Name == modeName {
modeIndex = i
agentIndex = i
break
}
}
mode = &modes[modeIndex]
agent = &agents[agentIndex]
if mode.Model.ModelID != "" {
appState.ModeModel[mode.Name] = ModeModel{
ProviderID: mode.Model.ProviderID,
ModelID: mode.Model.ModelID,
if agent.Model.ModelID != "" {
appState.AgentModel[agent.Name] = AgentModel{
ProviderID: agent.Model.ProviderID,
ModelID: agent.Model.ModelID,
}
}
@@ -167,20 +173,19 @@ func New(
app := &App{
Info: appInfo,
Modes: modes,
Agents: agents,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
ModeIndex: modeIndex,
Mode: mode,
AgentIndex: agentIndex,
Session: &opencode.Session{},
Messages: []Message{},
Commands: commands.LoadFromConfig(configInfo),
InitialModel: initialModel,
InitialPrompt: initialPrompt,
IntitialMode: initialMode,
InitialAgent: initialAgent,
}
return app, nil
@@ -222,22 +227,24 @@ func SetClipboard(text string) tea.Cmd {
func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
if forward {
a.ModeIndex++
if a.ModeIndex >= len(a.Modes) {
a.ModeIndex = 0
a.AgentIndex++
if a.AgentIndex >= len(a.Agents) {
a.AgentIndex = 0
}
} else {
a.ModeIndex--
if a.ModeIndex < 0 {
a.ModeIndex = len(a.Modes) - 1
a.AgentIndex--
if a.AgentIndex < 0 {
a.AgentIndex = len(a.Agents) - 1
}
}
a.Mode = &a.Modes[a.ModeIndex]
if a.Agent().Mode == "subagent" {
return a.cycleMode(forward)
}
modelID := a.Mode.Model.ModelID
providerID := a.Mode.Model.ProviderID
modelID := a.Agent().Model.ModelID
providerID := a.Agent().Model.ProviderID
if modelID == "" {
if model, ok := a.State.ModeModel[a.Mode.Name]; ok {
if model, ok := a.State.AgentModel[a.Agent().Name]; ok {
modelID = model.ModelID
providerID = model.ProviderID
}
@@ -258,20 +265,23 @@ func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
}
}
a.State.Mode = a.Mode.Name
a.State.Agent = a.Agent().Name
return a, a.SaveState()
}
func (a *App) SwitchMode() (*App, tea.Cmd) {
func (a *App) SwitchAgent() (*App, tea.Cmd) {
return a.cycleMode(true)
}
func (a *App) SwitchModeReverse() (*App, tea.Cmd) {
func (a *App) SwitchAgentReverse() (*App, tea.Cmd) {
return a.cycleMode(false)
}
// findModelByFullID finds a model by its full ID in the format "provider/model"
func findModelByFullID(providers []opencode.Provider, fullModelID string) (*opencode.Provider, *opencode.Model) {
func findModelByFullID(
providers []opencode.Provider,
fullModelID string,
) (*opencode.Provider, *opencode.Model) {
modelParts := strings.SplitN(fullModelID, "/", 2)
if len(modelParts) < 2 {
return nil, nil
@@ -284,7 +294,10 @@ func findModelByFullID(providers []opencode.Provider, fullModelID string) (*open
}
// findModelByProviderAndModelID finds a model by provider ID and model ID
func findModelByProviderAndModelID(providers []opencode.Provider, providerID, modelID string) (*opencode.Provider, *opencode.Model) {
func findModelByProviderAndModelID(
providers []opencode.Provider,
providerID, modelID string,
) (*opencode.Provider, *opencode.Model) {
for _, provider := range providers {
if provider.ID != providerID {
continue
@@ -330,7 +343,7 @@ func (a *App) InitializeProvider() tea.Cmd {
a.Providers = providers
// retains backwards compatibility with old state format
if model, ok := a.State.ModeModel[a.State.Mode]; ok {
if model, ok := a.State.AgentModel[a.State.Agent]; ok {
a.State.Provider = model.ProviderID
a.State.Model = model.ModelID
}
@@ -340,10 +353,17 @@ func (a *App) InitializeProvider() tea.Cmd {
// Priority 1: Command line --model flag (InitialModel)
if a.InitialModel != nil && *a.InitialModel != "" {
if provider, model := findModelByFullID(providers, *a.InitialModel); provider != nil && model != nil {
if provider, model := findModelByFullID(providers, *a.InitialModel); provider != nil &&
model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from command line", "provider", provider.ID, "model", model.ID)
slog.Debug(
"Selected model from command line",
"provider",
provider.ID,
"model",
model.ID,
)
} else {
slog.Debug("Command line model not found", "model", *a.InitialModel)
}
@@ -351,7 +371,8 @@ func (a *App) InitializeProvider() tea.Cmd {
// Priority 2: Config file model setting
if selectedProvider == nil && a.Config.Model != "" {
if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil && model != nil {
if provider, model := findModelByFullID(providers, a.Config.Model); provider != nil &&
model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from config", "provider", provider.ID, "model", model.ID)
@@ -363,10 +384,17 @@ func (a *App) InitializeProvider() tea.Cmd {
// Priority 3: Recent model usage (most recently used model)
if selectedProvider == nil && len(a.State.RecentlyUsedModels) > 0 {
recentUsage := a.State.RecentlyUsedModels[0] // Most recent is first
if provider, model := findModelByProviderAndModelID(providers, recentUsage.ProviderID, recentUsage.ModelID); provider != nil && model != nil {
if provider, model := findModelByProviderAndModelID(providers, recentUsage.ProviderID, recentUsage.ModelID); provider != nil &&
model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from recent usage", "provider", provider.ID, "model", model.ID)
slog.Debug(
"Selected model from recent usage",
"provider",
provider.ID,
"model",
model.ID,
)
} else {
slog.Debug("Recent model not found", "provider", recentUsage.ProviderID, "model", recentUsage.ModelID)
}
@@ -374,7 +402,8 @@ func (a *App) InitializeProvider() tea.Cmd {
// Priority 4: State-based model (backwards compatibility)
if selectedProvider == nil && a.State.Provider != "" && a.State.Model != "" {
if provider, model := findModelByProviderAndModelID(providers, a.State.Provider, a.State.Model); provider != nil && model != nil {
if provider, model := findModelByProviderAndModelID(providers, a.State.Provider, a.State.Model); provider != nil &&
model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from state", "provider", provider.ID, "model", model.ID)
@@ -390,7 +419,13 @@ func (a *App) InitializeProvider() tea.Cmd {
if model := getDefaultModel(providersResponse, *provider); model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from internal priority (Anthropic)", "provider", provider.ID, "model", model.ID)
slog.Debug(
"Selected model from internal priority (Anthropic)",
"provider",
provider.ID,
"model",
model.ID,
)
}
}
@@ -400,7 +435,13 @@ func (a *App) InitializeProvider() tea.Cmd {
if model := getDefaultModel(providersResponse, *provider); model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from fallback (first available)", "provider", provider.ID, "model", model.ID)
slog.Debug(
"Selected model from fallback (first available)",
"provider",
provider.ID,
"model",
model.ID,
)
}
}
}
@@ -552,7 +593,7 @@ func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
Mode: opencode.F(a.Mode.Name),
Agent: opencode.F(a.Agent().Name),
MessageID: opencode.F(messageID),
Parts: opencode.F(message.ToSessionChatParams()),
})

View File

@@ -55,6 +55,22 @@ func (p Prompt) ToMessage(
Text: text,
}}
for _, attachment := range p.Attachments {
if attachment.Type == "agent" {
source, _ := attachment.GetAgentSource()
parts = append(parts, opencode.AgentPart{
ID: id.Ascending(id.Part),
MessageID: messageID,
SessionID: sessionID,
Name: source.Name,
Source: opencode.AgentPartSource{
Value: attachment.Display,
Start: int64(attachment.StartIndex),
End: int64(attachment.EndIndex),
},
})
continue
}
text := opencode.FilePartSourceText{
Start: int64(attachment.StartIndex),
End: int64(attachment.EndIndex),
@@ -122,6 +138,17 @@ func (m Message) ToPrompt() (*Prompt, error) {
continue
}
text += p.Text + " "
case opencode.AgentPart:
attachments = append(attachments, &attachment.Attachment{
ID: p.ID,
Type: "agent",
Display: p.Source.Value,
StartIndex: int(p.Source.Start),
EndIndex: int(p.Source.End),
Source: &attachment.AgentSource{
Name: p.Name,
},
})
case opencode.FilePart:
switch p.Source.Type {
case "file":
@@ -236,68 +263,18 @@ func (m Message) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
Filename: opencode.F(p.Filename),
Source: opencode.F(source),
})
case opencode.AgentPart:
parts = append(parts, opencode.AgentPartInputParam{
ID: opencode.F(p.ID),
Type: opencode.F(opencode.AgentPartInputTypeAgent),
Name: opencode.F(p.Name),
Source: opencode.F(opencode.AgentPartInputSourceParam{
Value: opencode.F(p.Source.Value),
Start: opencode.F(p.Source.Start),
End: opencode.F(p.Source.End),
}),
})
}
}
return parts
}
func (p Prompt) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
parts := []opencode.SessionChatParamsPartUnion{
opencode.TextPartInputParam{
Type: opencode.F(opencode.TextPartInputTypeText),
Text: opencode.F(p.Text),
},
}
for _, att := range p.Attachments {
filePart := opencode.FilePartInputParam{
Type: opencode.F(opencode.FilePartInputTypeFile),
Mime: opencode.F(att.MediaType),
URL: opencode.F(att.URL),
Filename: opencode.F(att.Filename),
}
switch att.Type {
case "file":
if fs, ok := att.GetFileSource(); ok {
filePart.Source = opencode.F(
opencode.FilePartSourceUnionParam(opencode.FileSourceParam{
Type: opencode.F(opencode.FileSourceTypeFile),
Path: opencode.F(fs.Path),
Text: opencode.F(opencode.FilePartSourceTextParam{
Start: opencode.F(int64(att.StartIndex)),
End: opencode.F(int64(att.EndIndex)),
Value: opencode.F(att.Display),
}),
}),
)
}
case "symbol":
if ss, ok := att.GetSymbolSource(); ok {
filePart.Source = opencode.F(
opencode.FilePartSourceUnionParam(opencode.SymbolSourceParam{
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
Path: opencode.F(ss.Path),
Name: opencode.F(ss.Name),
Kind: opencode.F(int64(ss.Kind)),
Range: opencode.F(opencode.SymbolSourceRangeParam{
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
Line: opencode.F(float64(ss.Range.Start.Line)),
Character: opencode.F(float64(ss.Range.Start.Char)),
}),
End: opencode.F(opencode.SymbolSourceRangeEndParam{
Line: opencode.F(float64(ss.Range.End.Line)),
Character: opencode.F(float64(ss.Range.End.Char)),
}),
}),
Text: opencode.F(opencode.FilePartSourceTextParam{
Start: opencode.F(int64(att.StartIndex)),
End: opencode.F(int64(att.EndIndex)),
Value: opencode.F(att.Display),
}),
}),
)
}
}
parts = append(parts, filePart)
}
return parts
}

View File

@@ -16,29 +16,29 @@ type ModelUsage struct {
LastUsed time.Time `toml:"last_used"`
}
type ModeModel struct {
type AgentModel struct {
ProviderID string `toml:"provider_id"`
ModelID string `toml:"model_id"`
}
type State struct {
Theme string `toml:"theme"`
ScrollSpeed *int `toml:"scroll_speed"`
ModeModel map[string]ModeModel `toml:"mode_model"`
Provider string `toml:"provider"`
Model string `toml:"model"`
Mode string `toml:"mode"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
MessageHistory []Prompt `toml:"message_history"`
Theme string `toml:"theme"`
ScrollSpeed *int `toml:"scroll_speed"`
AgentModel map[string]AgentModel `toml:"agent_model"`
Provider string `toml:"provider"`
Model string `toml:"model"`
Agent string `toml:"agent"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
MessageHistory []Prompt `toml:"message_history"`
}
func NewState() *State {
return &State{
Theme: "opencode",
Mode: "build",
ModeModel: make(map[string]ModeModel),
Agent: "build",
AgentModel: make(map[string]AgentModel),
RecentlyUsedModels: make([]ModelUsage, 0),
MessageHistory: make([]Prompt, 0),
}

View File

@@ -26,6 +26,10 @@ type SymbolRange struct {
End Position `toml:"end"`
}
type AgentSource struct {
Name string `toml:"name"`
}
type Position struct {
Line int `toml:"line"`
Char int `toml:"char"`
@@ -76,6 +80,15 @@ func (a *Attachment) GetSymbolSource() (*SymbolSource, bool) {
return ss, ok
}
// GetAgentSource returns the source as AgentSource if the attachment is an agent type
func (a *Attachment) GetAgentSource() (*AgentSource, bool) {
if a.Type != "agent" {
return nil, false
}
as, ok := a.Source.(*AgentSource)
return as, ok
}
// FromMap creates a TextSource from a map[string]any
func (ts *TextSource) FromMap(sourceMap map[string]any) {
if value, ok := sourceMap["value"].(string); ok {
@@ -128,6 +141,13 @@ func (ss *SymbolSource) FromMap(sourceMap map[string]any) {
}
}
// FromMap creates an AgentSource from a map[string]any
func (as *AgentSource) FromMap(sourceMap map[string]any) {
if name, ok := sourceMap["name"].(string); ok {
as.Name = name
}
}
// RestoreSourceType converts a map[string]any source back to the proper type
func (a *Attachment) RestoreSourceType() {
if a.Source == nil {
@@ -149,6 +169,10 @@ func (a *Attachment) RestoreSourceType() {
ss := &SymbolSource{}
ss.FromMap(sourceMap)
a.Source = ss
case "agent":
as := &AgentSource{}
as.FromMap(sourceMap)
a.Source = as
}
}
}

View File

@@ -107,8 +107,8 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
const (
AppHelpCommand CommandName = "app_help"
SwitchModeCommand CommandName = "switch_mode"
SwitchModeReverseCommand CommandName = "switch_mode_reverse"
SwitchAgentCommand CommandName = "switch_agent"
SwitchAgentReverseCommand CommandName = "switch_agent_reverse"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
@@ -181,13 +181,13 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Trigger: []string{"help"},
},
{
Name: SwitchModeCommand,
Description: "next mode",
Name: SwitchAgentCommand,
Description: "next agent",
Keybindings: parseBindings("tab"),
},
{
Name: SwitchModeReverseCommand,
Description: "previous mode",
Name: SwitchAgentReverseCommand,
Description: "previous agent",
Keybindings: parseBindings("shift+tab"),
},
{

View File

@@ -0,0 +1,74 @@
package completions
import (
"context"
"log/slog"
"strings"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type agentsContextGroup struct {
app *app.App
}
func (cg *agentsContextGroup) GetId() string {
return "agents"
}
func (cg *agentsContextGroup) GetEmptyMessage() string {
return "no matching agents"
}
func (cg *agentsContextGroup) GetChildEntries(
query string,
) ([]CompletionSuggestion, error) {
items := make([]CompletionSuggestion, 0)
query = strings.TrimSpace(query)
agents, err := cg.app.Client.App.Agents(
context.Background(),
)
if err != nil {
slog.Error("Failed to get agent list", "error", err)
return items, err
}
if agents == nil {
return items, nil
}
for _, agent := range *agents {
if query != "" && !strings.Contains(strings.ToLower(agent.Name), strings.ToLower(query)) {
continue
}
if agent.Mode == opencode.AgentModePrimary || agent.Name == "general" {
continue
}
displayFunc := func(s styles.Style) string {
t := theme.CurrentTheme()
muted := s.Foreground(t.TextMuted()).Render
return s.Render(agent.Name) + muted(" (agent)")
}
item := CompletionSuggestion{
Display: displayFunc,
Value: agent.Name,
ProviderID: cg.GetId(),
RawData: agent,
}
items = append(items, item)
}
return items, nil
}
func NewAgentsContextGroup(app *app.App) CompletionProvider {
return &agentsContextGroup{
app: app,
}
}

View File

@@ -288,6 +288,31 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
case "agents":
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.Item.Value + " ")
return m, nil
}
cursorCol := m.textarea.CursorColumn()
m.textarea.ReplaceRange(atIndex, cursorCol, "")
name := msg.Item.Value
attachment := &attachment.Attachment{
ID: uuid.NewString(),
Type: "agent",
Display: "@" + name,
Source: &attachment.AgentSource{
Name: name,
},
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
default:
slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
return m, nil

View File

@@ -209,6 +209,7 @@ func renderText(
width int,
extra string,
fileParts []opencode.FilePart,
agentParts []opencode.AgentPart,
toolCalls ...opencode.ToolPart,
) string {
t := theme.CurrentTheme()
@@ -229,9 +230,47 @@ func renderText(
// Apply highlighting to filenames and base style to rest of text BEFORE wrapping
textLen := int64(len(text))
// Collect all parts to highlight (both file and agent parts)
type highlightPart struct {
start int64
end int64
color compat.AdaptiveColor
}
var highlights []highlightPart
// Add file parts with secondary color
for _, filePart := range fileParts {
highlight := base.Foreground(t.Secondary())
start, end := filePart.Source.Text.Start, filePart.Source.Text.End
highlights = append(highlights, highlightPart{
start: filePart.Source.Text.Start,
end: filePart.Source.Text.End,
color: t.Secondary(),
})
}
// Add agent parts with secondary color (same as file parts)
for _, agentPart := range agentParts {
highlights = append(highlights, highlightPart{
start: agentPart.Source.Start,
end: agentPart.Source.End,
color: t.Secondary(),
})
}
// Sort highlights by start position
slices.SortFunc(highlights, func(a, b highlightPart) int {
if a.start < b.start {
return -1
}
if a.start > b.start {
return 1
}
return 0
})
for _, part := range highlights {
highlight := base.Foreground(part.color)
start, end := part.start, part.end
if end > textLen {
end = textLen

View File

@@ -300,12 +300,17 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
remainingParts := message.Parts[partIndex+1:]
fileParts := make([]opencode.FilePart, 0)
agentParts := make([]opencode.AgentPart, 0)
for _, part := range remainingParts {
switch part := part.(type) {
case opencode.FilePart:
if part.Source.Text.Start >= 0 && part.Source.Text.End >= part.Source.Text.Start {
fileParts = append(fileParts, part)
}
case opencode.AgentPart:
if part.Source.Start >= 0 && part.Source.End >= part.Source.Start {
agentParts = append(agentParts, part)
}
}
}
flexItems := []layout.FlexItem{}
@@ -355,6 +360,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
width,
files,
fileParts,
agentParts,
)
content = lipgloss.PlaceHorizontal(
m.width,
@@ -433,6 +439,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
width,
"",
[]opencode.FilePart{},
[]opencode.AgentPart{},
toolCallParts...,
)
content = lipgloss.PlaceHorizontal(
@@ -453,6 +460,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
width,
"",
[]opencode.FilePart{},
[]opencode.AgentPart{},
toolCallParts...,
)
content = lipgloss.PlaceHorizontal(

View File

@@ -66,11 +66,16 @@ func (c *completionDialogComponent) Init() tea.Cmd {
func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
return func() tea.Msg {
allItems := make([]completions.CompletionSuggestion, 0)
// Collect results from all providers and preserve provider order
type providerItems struct {
idx int
items []completions.CompletionSuggestion
}
itemsByProvider := make([]providerItems, 0, len(c.providers))
providersWithResults := 0
// Collect results from all providers
for _, provider := range c.providers {
for idx, provider := range c.providers {
items, err := provider.GetChildEntries(query)
if err != nil {
slog.Error(
@@ -84,33 +89,46 @@ func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
}
if len(items) > 0 {
providersWithResults++
allItems = append(allItems, items...)
itemsByProvider = append(itemsByProvider, providerItems{idx: idx, items: items})
}
}
// If there's a query, use fuzzy ranking to sort results
if query != "" && providersWithResults > 1 {
// If there's a query, fuzzy-rank within each provider, then concatenate by provider order
if query != "" && providersWithResults > 0 {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
// Create a slice of display values for fuzzy matching
displayValues := make([]string, len(allItems))
for i, item := range allItems {
displayValues[i] = item.Display(baseStyle)
// Ensure stable provider order just in case
sort.SliceStable(itemsByProvider, func(i, j int) bool { return itemsByProvider[i].idx < itemsByProvider[j].idx })
final := make([]completions.CompletionSuggestion, 0)
for _, entry := range itemsByProvider {
// Build display values for fuzzy matching within this provider
displayValues := make([]string, len(entry.items))
for i, item := range entry.items {
displayValues[i] = item.Display(baseStyle)
}
matches := fuzzy.RankFindFold(query, displayValues)
sort.Sort(matches)
// Reorder items for this provider based on fuzzy ranking
ranked := make([]completions.CompletionSuggestion, 0, len(matches))
for _, m := range matches {
ranked = append(ranked, entry.items[m.OriginalIndex])
}
final = append(final, ranked...)
}
matches := fuzzy.RankFindFold(query, displayValues)
sort.Sort(matches)
// Reorder items based on fuzzy ranking
rankedItems := make([]completions.CompletionSuggestion, 0, len(matches))
for _, match := range matches {
rankedItems = append(rankedItems, allItems[match.OriginalIndex])
}
return rankedItems
return final
}
return allItems
// No query or no results: just concatenate in provider order
all := make([]completions.CompletionSuggestion, 0)
for _, entry := range itemsByProvider {
all = append(all, entry.items...)
}
return all
}
}
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

View File

@@ -121,7 +121,7 @@ func (m *statusComponent) View() string {
var modeBackground compat.AdaptiveColor
var modeForeground compat.AdaptiveColor
switch m.app.ModeIndex {
switch m.app.AgentIndex {
case 0:
modeBackground = t.BackgroundElement()
modeForeground = t.TextMuted()
@@ -148,31 +148,31 @@ func (m *statusComponent) View() string {
modeForeground = t.BackgroundPanel()
}
command := m.app.Commands[commands.SwitchModeCommand]
command := m.app.Commands[commands.SwitchAgentCommand]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
key = m.app.Config.Keybinds.Leader + " " + kb.Key
}
modeStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
modeNameStyle := modeStyle.Bold(true).Render
modeDescStyle := modeStyle.Render
mode := modeNameStyle(strings.ToUpper(m.app.Mode.Name)) + modeDescStyle(" MODE")
mode = modeStyle.
agentStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
agentNameStyle := agentStyle.Bold(true).Render
agentDescStyle := agentStyle.Render
agent := agentNameStyle(strings.ToUpper(m.app.Agent().Name)) + agentDescStyle(" AGENT")
agent = agentStyle.
Padding(0, 1).
BorderLeft(true).
BorderStyle(lipgloss.ThickBorder()).
BorderForeground(modeBackground).
BorderBackground(t.BackgroundPanel()).
Render(mode)
Render(agent)
faintStyle := styles.NewStyle().
Faint(true).
Background(t.BackgroundPanel()).
Foreground(t.TextMuted())
mode = faintStyle.Render(key+" ") + mode
modeWidth := lipgloss.Width(mode)
agent = faintStyle.Render(key+" ") + agent
modeWidth := lipgloss.Width(agent)
availableWidth := m.width - logoWidth - modeWidth
branchSuffix := ""
@@ -206,7 +206,7 @@ func (m *statusComponent) View() string {
View: logo + cwd,
},
layout.FlexItem{
View: mode,
View: agent,
},
)

View File

@@ -69,6 +69,7 @@ type Model struct {
commandProvider completions.CompletionProvider
fileProvider completions.CompletionProvider
symbolsProvider completions.CompletionProvider
agentsProvider completions.CompletionProvider
showCompletionDialog bool
leaderBinding *key.Binding
toastManager *toast.ToastManager
@@ -211,8 +212,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
// Set both file and symbols providers for @ completion
a.completions = dialog.NewCompletionDialogComponent("@", a.fileProvider, a.symbolsProvider)
// Set file, symbols, and agents providers for @ completion
a.completions = dialog.NewCompletionDialogComponent("@", a.agentsProvider, a.fileProvider, a.symbolsProvider)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
@@ -585,7 +586,7 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
a.app.State.ModeModel[a.app.Mode.Name] = app.ModeModel{
a.app.State.AgentModel[a.app.Agent().Name] = app.AgentModel{
ProviderID: msg.Provider.ID,
ModelID: msg.Model.ID,
}
@@ -951,12 +952,12 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
case commands.AppHelpCommand:
helpDialog := dialog.NewHelpDialog(a.app)
a.modal = helpDialog
case commands.SwitchModeCommand:
updated, cmd := a.app.SwitchMode()
case commands.SwitchAgentCommand:
updated, cmd := a.app.SwitchAgent()
a.app = updated
cmds = append(cmds, cmd)
case commands.SwitchModeReverseCommand:
updated, cmd := a.app.SwitchModeReverse()
case commands.SwitchAgentReverseCommand:
updated, cmd := a.app.SwitchAgentReverse()
a.app = updated
cmds = append(cmds, cmd)
case commands.EditorOpenCommand:
@@ -1220,6 +1221,7 @@ func NewModel(app *app.App) tea.Model {
commandProvider := completions.NewCommandCompletionProvider(app)
fileProvider := completions.NewFileContextGroup(app)
symbolsProvider := completions.NewSymbolsContextGroup(app)
agentsProvider := completions.NewAgentsContextGroup(app)
messages := chat.NewMessagesComponent(app)
editor := chat.NewEditorComponent(app)
@@ -1240,6 +1242,7 @@ func NewModel(app *app.App) tea.Model {
commandProvider: commandProvider,
fileProvider: fileProvider,
symbolsProvider: symbolsProvider,
agentsProvider: agentsProvider,
leaderBinding: leaderBinding,
showCompletionDialog: false,
toastManager: toast.NewToastManager(),