diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 9794d048..517cafd2 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -70,6 +70,9 @@ type ModelSelectedMsg struct { Provider opencode.Provider Model opencode.Model } +type AgentSelectedMsg struct { + Agent opencode.Agent +} type SessionClearedMsg struct{} type CompactSessionMsg struct{} type SendPrompt = Prompt diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index cb54bd09..55f118aa 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -64,12 +64,13 @@ func (r CommandRegistry) Sorted() []Command { commands = append(commands, command) } slices.SortFunc(commands, func(a, b Command) int { - // Priority order: session_new, session_share, model_list, app_help first, app_exit last + // Priority order: session_new, session_share, model_list, agent_list, app_help first, app_exit last priorityOrder := map[CommandName]int{ SessionNewCommand: 0, AppHelpCommand: 1, SessionShareCommand: 2, ModelListCommand: 3, + AgentListCommand: 4, } aPriority, aHasPriority := priorityOrder[a.Name] @@ -119,6 +120,7 @@ const ( SessionExportCommand CommandName = "session_export" ToolDetailsCommand CommandName = "tool_details" ModelListCommand CommandName = "model_list" + AgentListCommand CommandName = "agent_list" ThemeListCommand CommandName = "theme_list" FileListCommand CommandName = "file_list" FileCloseCommand CommandName = "file_close" @@ -248,6 +250,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Keybindings: parseBindings("m"), Trigger: []string{"models"}, }, + { + Name: AgentListCommand, + Description: "list agents", + Keybindings: parseBindings("a"), + Trigger: []string{"agents"}, + }, { Name: ThemeListCommand, Description: "list themes", diff --git a/packages/tui/internal/components/dialog/agents.go b/packages/tui/internal/components/dialog/agents.go new file mode 100644 index 00000000..49e3e025 --- /dev/null +++ b/packages/tui/internal/components/dialog/agents.go @@ -0,0 +1,305 @@ +package dialog + +import ( + "fmt" + "sort" + + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/sst/opencode-sdk-go" + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/components/list" + "github.com/sst/opencode/internal/components/modal" + "github.com/sst/opencode/internal/layout" + "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" +) + +const ( + numVisibleAgents = 10 + minAgentDialogWidth = 54 + maxAgentDialogWidth = 108 + maxDescriptionLength = 80 +) + +// AgentDialog interface for the agent selection dialog +type AgentDialog interface { + layout.Modal +} + +type agentDialog struct { + app *app.App + allAgents []opencode.Agent + width int + height int + modal *modal.Modal + searchDialog *SearchDialog + dialogWidth int +} + +// agentItem is a custom list item for agent selections +type agentItem struct { + agent opencode.Agent +} + +func (a agentItem) Render( + selected bool, + width int, + baseStyle styles.Style, +) string { + t := theme.CurrentTheme() + + itemStyle := baseStyle. + Background(t.BackgroundPanel()). + Foreground(t.Text()) + + if selected { + itemStyle = itemStyle.Foreground(t.Primary()) + } + + descStyle := baseStyle. + Foreground(t.TextMuted()). + Background(t.BackgroundPanel()) + + // Calculate available width (accounting for padding and margins) + availableWidth := width - 2 // Account for left padding + + agentName := a.agent.Name + description := a.agent.Description + if description == "" { + description = fmt.Sprintf("(%s)", a.agent.Mode) + } + + separator := " - " + + // Calculate how much space we have for the description + nameAndSeparatorLength := len(agentName) + len(separator) + descriptionMaxLength := availableWidth - nameAndSeparatorLength + + // Truncate description if it's too long + if len(description) > descriptionMaxLength && descriptionMaxLength > 3 { + description = description[:descriptionMaxLength-3] + "..." + } + + namePart := itemStyle.Render(agentName) + descPart := descStyle.Render(separator + description) + combinedText := namePart + descPart + + return baseStyle. + Background(t.BackgroundPanel()). + PaddingLeft(1). + Width(width). + Render(combinedText) +} + +func (a agentItem) Selectable() bool { + // All agents in the dialog are selectable (subagents are filtered out) + return true +} + +type agentKeyMap struct { + Enter key.Binding + Escape key.Binding +} + +var agentKeys = agentKeyMap{ + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select agent"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "close"), + ), +} + +func (a *agentDialog) Init() tea.Cmd { + a.setupAllAgents() + return a.searchDialog.Init() +} + +func (a *agentDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case SearchSelectionMsg: + // Handle selection from search dialog + if item, ok := msg.Item.(agentItem); ok { + return a, tea.Sequence( + util.CmdHandler(modal.CloseModalMsg{}), + util.CmdHandler( + app.AgentSelectedMsg{ + Agent: item.agent, + }), + ) + } + return a, util.CmdHandler(modal.CloseModalMsg{}) + case SearchCancelledMsg: + return a, util.CmdHandler(modal.CloseModalMsg{}) + + case SearchQueryChangedMsg: + // Update the list based on search query + items := a.buildDisplayList(msg.Query) + a.searchDialog.SetItems(items) + return a, nil + + case tea.WindowSizeMsg: + a.width = msg.Width + a.height = msg.Height + a.searchDialog.SetWidth(a.dialogWidth) + a.searchDialog.SetHeight(msg.Height) + } + + updatedDialog, cmd := a.searchDialog.Update(msg) + a.searchDialog = updatedDialog.(*SearchDialog) + return a, cmd +} + +func (a *agentDialog) View() string { + return a.searchDialog.View() +} + +func (a *agentDialog) calculateOptimalWidth(agents []opencode.Agent) int { + maxWidth := minAgentDialogWidth + + for _, agent := range agents { + // Calculate the width needed for this item: "AgentName - Description" + itemWidth := len(agent.Name) + if agent.Description != "" { + itemWidth += len(agent.Description) + 3 // " - " + } else { + itemWidth += len(string(agent.Mode)) + 3 // " (mode)" + } + + if itemWidth > maxWidth { + maxWidth = itemWidth + } + } + + maxWidth = min(maxWidth, maxAgentDialogWidth) + + return maxWidth +} + +func (a *agentDialog) setupAllAgents() { + // Get agents from the app, filtering out subagents + a.allAgents = []opencode.Agent{} + for _, agent := range a.app.Agents { + if agent.Mode != "subagent" { + a.allAgents = append(a.allAgents, agent) + } + } + + a.sortAgents() + + // Calculate optimal width based on all agents + a.dialogWidth = a.calculateOptimalWidth(a.allAgents) + + // Ensure minimum width to prevent textinput issues + a.dialogWidth = max(a.dialogWidth, minAgentDialogWidth) + + a.searchDialog = NewSearchDialog("Search agents...", numVisibleAgents) + a.searchDialog.SetWidth(a.dialogWidth) + + items := a.buildDisplayList("") + a.searchDialog.SetItems(items) +} + +func (a *agentDialog) sortAgents() { + sort.Slice(a.allAgents, func(i, j int) bool { + agentA := a.allAgents[i] + agentB := a.allAgents[j] + + // Current agent goes first + if agentA.Name == a.app.Agent().Name { + return true + } + if agentB.Name == a.app.Agent().Name { + return false + } + + // Alphabetical order for all other agents + return agentA.Name < agentB.Name + }) +} + +func (a *agentDialog) buildDisplayList(query string) []list.Item { + if query != "" { + return a.buildSearchResults(query) + } + return a.buildGroupedResults() +} + +func (a *agentDialog) buildSearchResults(query string) []list.Item { + agentNames := []string{} + agentMap := make(map[string]opencode.Agent) + + for _, agent := range a.allAgents { + // Search by name + searchStr := agent.Name + agentNames = append(agentNames, searchStr) + agentMap[searchStr] = agent + + // Search by description if available + if agent.Description != "" { + searchStr = fmt.Sprintf("%s %s", agent.Name, agent.Description) + agentNames = append(agentNames, searchStr) + agentMap[searchStr] = agent + } + } + + matches := fuzzy.RankFindFold(query, agentNames) + sort.Sort(matches) + + items := []list.Item{} + seenAgents := make(map[string]bool) + + for _, match := range matches { + agent := agentMap[match.Target] + // Create a unique key to avoid duplicates + key := agent.Name + if seenAgents[key] { + continue + } + seenAgents[key] = true + items = append(items, agentItem{agent: agent}) + } + + return items +} + +func (a *agentDialog) buildGroupedResults() []list.Item { + var items []list.Item + + items = append(items, list.HeaderItem("Agents")) + + // Add all agents (subagents are already filtered out) + for _, agent := range a.allAgents { + items = append(items, agentItem{agent: agent}) + } + + return items +} + +func (a *agentDialog) Render(background string) string { + return a.modal.Render(a.View(), background) +} + +func (s *agentDialog) Close() tea.Cmd { + return nil +} + +func NewAgentDialog(app *app.App) AgentDialog { + dialog := &agentDialog{ + app: app, + } + + dialog.setupAllAgents() + + dialog.modal = modal.New( + modal.WithTitle("Select Agent"), + modal.WithMaxWidth(dialog.dialogWidth+4), + ) + + return dialog +} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 639d15d0..ea9f0560 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -599,6 +599,32 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID) cmds = append(cmds, a.app.SaveState()) + case app.AgentSelectedMsg: + // Find the agent index + for i, agent := range a.app.Agents { + if agent.Name == msg.Agent.Name { + a.app.AgentIndex = i + break + } + } + a.app.State.Agent = msg.Agent.Name + + // Switch to the agent's preferred model if available + if model, ok := a.app.State.AgentModel[msg.Agent.Name]; ok { + for _, provider := range a.app.Providers { + if provider.ID == model.ProviderID { + a.app.Provider = &provider + for _, m := range provider.Models { + if m.ID == model.ModelID { + a.app.Model = &m + break + } + } + break + } + } + } + cmds = append(cmds, a.app.SaveState()) case dialog.ThemeSelectedMsg: a.app.State.Theme = msg.ThemeName cmds = append(cmds, a.app.SaveState()) @@ -1119,6 +1145,9 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) { case commands.ModelListCommand: modelDialog := dialog.NewModelDialog(a.app) a.modal = modelDialog + case commands.AgentListCommand: + agentDialog := dialog.NewAgentDialog(a.app) + a.modal = agentDialog case commands.ThemeListCommand: themeDialog := dialog.NewThemeDialog() a.modal = themeDialog