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:
@@ -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()),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
{
|
||||
|
||||
74
packages/tui/internal/completions/agents.go
Normal file
74
packages/tui/internal/completions/agents.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user