summaryrefslogtreecommitdiffhomepage
path: root/packages/tui
diff options
context:
space:
mode:
authorDax <[email protected]>2025-08-07 16:32:12 -0400
committerGitHub <[email protected]>2025-08-07 16:32:12 -0400
commitc34aec060f2abb78486857b0e0d678cac52e2fab (patch)
tree7d2b42a0c87ab016ffbc096e332b186ec4bce7ac /packages/tui
parent12f1ad521fef2e1fa6f1b0ce6ec939664d61899f (diff)
downloadopencode-c34aec060f2abb78486857b0e0d678cac52e2fab.tar.gz
opencode-c34aec060f2abb78486857b0e0d678cac52e2fab.zip
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/
Diffstat (limited to 'packages/tui')
-rw-r--r--packages/tui/cmd/opencode/main.go12
-rw-r--r--packages/tui/internal/app/app.go141
-rw-r--r--packages/tui/internal/app/prompt.go99
-rw-r--r--packages/tui/internal/app/state.go26
-rw-r--r--packages/tui/internal/attachment/attachment.go24
-rw-r--r--packages/tui/internal/commands/command.go12
-rw-r--r--packages/tui/internal/completions/agents.go74
-rw-r--r--packages/tui/internal/components/chat/editor.go25
-rw-r--r--packages/tui/internal/components/chat/message.go43
-rw-r--r--packages/tui/internal/components/chat/messages.go8
-rw-r--r--packages/tui/internal/components/dialog/complete.go56
-rw-r--r--packages/tui/internal/components/status/status.go22
-rw-r--r--packages/tui/internal/tui/tui.go17
13 files changed, 384 insertions, 175 deletions
diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go
index 5532289fd..654eb0adc 100644
--- a/packages/tui/cmd/opencode/main.go
+++ b/packages/tui/cmd/opencode/main.go
@@ -31,7 +31,7 @@ func main() {
var model *string = flag.String("model", "", "model to begin with")
var prompt *string = flag.String("prompt", "", "prompt to begin with")
- var mode *string = flag.String("mode", "", "mode to begin with")
+ var agent *string = flag.String("agent", "", "agent to begin with")
flag.Parse()
url := os.Getenv("OPENCODE_SERVER")
@@ -44,9 +44,9 @@ func main() {
os.Exit(1)
}
- modesStr := os.Getenv("OPENCODE_MODES")
- var modes []opencode.Mode
- err = json.Unmarshal([]byte(modesStr), &modes)
+ agentsStr := os.Getenv("OPENCODE_AGENTS")
+ var agents []opencode.Agent
+ err = json.Unmarshal([]byte(agentsStr), &agents)
if err != nil {
slog.Error("Failed to unmarshal modes", "error", err)
os.Exit(1)
@@ -86,7 +86,7 @@ func main() {
logger := slog.New(apiHandler)
slog.SetDefault(logger)
- slog.Debug("TUI launched", "app", appInfoStr, "modes", modesStr, "url", url)
+ slog.Debug("TUI launched", "app", appInfoStr, "modes", agentsStr, "url", url)
go func() {
err = clipboard.Init()
@@ -96,7 +96,7 @@ func main() {
}()
// Create main context for the application
- app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
+ app_, err := app.New(ctx, version, appInfo, agents, httpClient, model, prompt, agent)
if err != nil {
panic(err)
}
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index a0e68b53a..9794d0489 100644
--- a/packages/tui/internal/app/app.go
+++ b/packages/tui/internal/app/app.go
@@ -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()),
})
diff --git a/packages/tui/internal/app/prompt.go b/packages/tui/internal/app/prompt.go
index 282ced704..81c4b6a7d 100644
--- a/packages/tui/internal/app/prompt.go
+++ b/packages/tui/internal/app/prompt.go
@@ -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
-}
diff --git a/packages/tui/internal/app/state.go b/packages/tui/internal/app/state.go
index bf2a602b3..478022351 100644
--- a/packages/tui/internal/app/state.go
+++ b/packages/tui/internal/app/state.go
@@ -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),
}
diff --git a/packages/tui/internal/attachment/attachment.go b/packages/tui/internal/attachment/attachment.go
index 038209ae8..3ecd86198 100644
--- a/packages/tui/internal/attachment/attachment.go
+++ b/packages/tui/internal/attachment/attachment.go
@@ -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
}
}
}
diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go
index f87794796..cb54bd09d 100644
--- a/packages/tui/internal/commands/command.go
+++ b/packages/tui/internal/commands/command.go
@@ -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"),
},
{
diff --git a/packages/tui/internal/completions/agents.go b/packages/tui/internal/completions/agents.go
new file mode 100644
index 000000000..cda2be883
--- /dev/null
+++ b/packages/tui/internal/completions/agents.go
@@ -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,
+ }
+}
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 1263a6e72..509de6244 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -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
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
index 0a1982718..60d0fab55 100644
--- a/packages/tui/internal/components/chat/message.go
+++ b/packages/tui/internal/components/chat/message.go
@@ -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
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index 5702cec5d..46036b75f 100644
--- a/packages/tui/internal/components/chat/messages.go
+++ b/packages/tui/internal/components/chat/messages.go
@@ -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(
diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go
index f18d97510..d3d2ea9d7 100644
--- a/packages/tui/internal/components/dialog/complete.go
+++ b/packages/tui/internal/components/dialog/complete.go
@@ -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)
- }
- matches := fuzzy.RankFindFold(query, displayValues)
- sort.Sort(matches)
+ // Ensure stable provider order just in case
+ sort.SliceStable(itemsByProvider, func(i, j int) bool { return itemsByProvider[i].idx < itemsByProvider[j].idx })
- // Reorder items based on fuzzy ranking
- rankedItems := make([]completions.CompletionSuggestion, 0, len(matches))
- for _, match := range matches {
- rankedItems = append(rankedItems, allItems[match.OriginalIndex])
+ 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...)
}
- 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) {
diff --git a/packages/tui/internal/components/status/status.go b/packages/tui/internal/components/status/status.go
index d57c228c5..be34dd938 100644
--- a/packages/tui/internal/components/status/status.go
+++ b/packages/tui/internal/components/status/status.go
@@ -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,
},
)
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 7f5b48714..22e8afdb3 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -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(),