diff options
| author | Dax <[email protected]> | 2025-08-07 16:32:12 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-08-07 16:32:12 -0400 |
| commit | c34aec060f2abb78486857b0e0d678cac52e2fab (patch) | |
| tree | 7d2b42a0c87ab016ffbc096e332b186ec4bce7ac /packages/tui | |
| parent | 12f1ad521fef2e1fa6f1b0ce6ec939664d61899f (diff) | |
| download | opencode-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.go | 12 | ||||
| -rw-r--r-- | packages/tui/internal/app/app.go | 141 | ||||
| -rw-r--r-- | packages/tui/internal/app/prompt.go | 99 | ||||
| -rw-r--r-- | packages/tui/internal/app/state.go | 26 | ||||
| -rw-r--r-- | packages/tui/internal/attachment/attachment.go | 24 | ||||
| -rw-r--r-- | packages/tui/internal/commands/command.go | 12 | ||||
| -rw-r--r-- | packages/tui/internal/completions/agents.go | 74 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/editor.go | 25 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/message.go | 43 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/messages.go | 8 | ||||
| -rw-r--r-- | packages/tui/internal/components/dialog/complete.go | 56 | ||||
| -rw-r--r-- | packages/tui/internal/components/status/status.go | 22 | ||||
| -rw-r--r-- | packages/tui/internal/tui/tui.go | 17 |
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(), |
