Aiden Cline 6 месяцев назад
Родитель
Сommit
62b8c7aee0

+ 3 - 0
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

+ 9 - 1
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("<leader>m"),
 			Trigger:     []string{"models"},
 		},
+		{
+			Name:        AgentListCommand,
+			Description: "list agents",
+			Keybindings: parseBindings("<leader>a"),
+			Trigger:     []string{"agents"},
+		},
 		{
 			Name:        ThemeListCommand,
 			Description: "list themes",

+ 305 - 0
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
+}

+ 29 - 0
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