Ver Fonte

refactor(agent-modal): revamped UI/UX for the agent modal (#1838)

Co-authored-by: Dax Raad <[email protected]>
Co-authored-by: Dax <[email protected]>
spoons-and-mirrors há 8 meses atrás
pai
commit
81583cddbd

+ 43 - 1
packages/tui/internal/app/app.go

@@ -71,9 +71,11 @@ type ModelSelectedMsg struct {
 	Provider opencode.Provider
 	Model    opencode.Model
 }
+
 type AgentSelectedMsg struct {
-	Agent opencode.Agent
+	AgentName string
 }
+
 type SessionClearedMsg struct{}
 type CompactSessionMsg struct{}
 type SendPrompt = Prompt
@@ -272,6 +274,7 @@ func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
 	}
 
 	a.State.Agent = a.Agent().Name
+	a.State.UpdateAgentUsage(a.Agent().Name)
 	return a, a.SaveState()
 }
 
@@ -316,6 +319,45 @@ func (a *App) CycleRecentModel() (*App, tea.Cmd) {
 	return a, toast.NewErrorToast("Recent model not found")
 }
 
+func (a *App) SwitchToAgent(agentName string) (*App, tea.Cmd) {
+	// Find the agent index by name
+	for i, agent := range a.Agents {
+		if agent.Name == agentName {
+			a.AgentIndex = i
+			break
+		}
+	}
+
+	// Set up model for the new agent
+	modelID := a.Agent().Model.ModelID
+	providerID := a.Agent().Model.ProviderID
+	if modelID == "" {
+		if model, ok := a.State.AgentModel[a.Agent().Name]; ok {
+			modelID = model.ModelID
+			providerID = model.ProviderID
+		}
+	}
+
+	if modelID != "" {
+		for _, provider := range a.Providers {
+			if provider.ID == providerID {
+				a.Provider = &provider
+				for _, model := range provider.Models {
+					if model.ID == modelID {
+						a.Model = &model
+						break
+					}
+				}
+				break
+			}
+		}
+	}
+
+	a.State.Agent = a.Agent().Name
+	a.State.UpdateAgentUsage(agentName)
+	return a, a.SaveState()
+}
+
 // findModelByFullID finds a model by its full ID in the format "provider/model"
 func findModelByFullID(
 	providers []opencode.Provider,

+ 43 - 0
packages/tui/internal/app/state.go

@@ -16,6 +16,11 @@ type ModelUsage struct {
 	LastUsed   time.Time `toml:"last_used"`
 }
 
+type AgentUsage struct {
+	AgentName string    `toml:"agent_name"`
+	LastUsed  time.Time `toml:"last_used"`
+}
+
 type AgentModel struct {
 	ProviderID string `toml:"provider_id"`
 	ModelID    string `toml:"model_id"`
@@ -29,6 +34,7 @@ type State struct {
 	Model              string                `toml:"model"`
 	Agent              string                `toml:"agent"`
 	RecentlyUsedModels []ModelUsage          `toml:"recently_used_models"`
+	RecentlyUsedAgents []AgentUsage          `toml:"recently_used_agents"`
 	MessagesRight      bool                  `toml:"messages_right"`
 	SplitDiff          bool                  `toml:"split_diff"`
 	MessageHistory     []Prompt              `toml:"message_history"`
@@ -42,6 +48,7 @@ func NewState() *State {
 		Agent:              "build",
 		AgentModel:         make(map[string]AgentModel),
 		RecentlyUsedModels: make([]ModelUsage, 0),
+		RecentlyUsedAgents: make([]AgentUsage, 0),
 		MessageHistory:     make([]Prompt, 0),
 	}
 }
@@ -83,6 +90,42 @@ func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
 	}
 }
 
+// UpdateAgentUsage updates the recently used agents list with the specified agent
+func (s *State) UpdateAgentUsage(agentName string) {
+	now := time.Now()
+
+	// Check if this agent is already in the list
+	for i, usage := range s.RecentlyUsedAgents {
+		if usage.AgentName == agentName {
+			s.RecentlyUsedAgents[i].LastUsed = now
+			usage := s.RecentlyUsedAgents[i]
+			copy(s.RecentlyUsedAgents[1:i+1], s.RecentlyUsedAgents[0:i])
+			s.RecentlyUsedAgents[0] = usage
+			return
+		}
+	}
+
+	newUsage := AgentUsage{
+		AgentName: agentName,
+		LastUsed:  now,
+	}
+
+	// Prepend to slice and limit to last 20 entries
+	s.RecentlyUsedAgents = append([]AgentUsage{newUsage}, s.RecentlyUsedAgents...)
+	if len(s.RecentlyUsedAgents) > 20 {
+		s.RecentlyUsedAgents = s.RecentlyUsedAgents[:20]
+	}
+}
+
+func (s *State) RemoveAgentFromRecentlyUsed(agentName string) {
+	for i, usage := range s.RecentlyUsedAgents {
+		if usage.AgentName == agentName {
+			s.RecentlyUsedAgents = append(s.RecentlyUsedAgents[:i], s.RecentlyUsedAgents[i+1:]...)
+			return
+		}
+	}
+}
+
 func (s *State) AddPromptToHistory(prompt Prompt) {
 	s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
 	if len(s.MessageHistory) > 50 {

+ 240 - 71
packages/tui/internal/components/dialog/agents.go

@@ -1,8 +1,8 @@
 package dialog
 
 import (
-	"fmt"
 	"sort"
+	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -19,9 +19,10 @@ import (
 
 const (
 	numVisibleAgents     = 10
-	minAgentDialogWidth  = 54
-	maxAgentDialogWidth  = 108
-	maxDescriptionLength = 80
+	minAgentDialogWidth  = 40
+	maxAgentDialogWidth  = 60
+	maxDescriptionLength = 60
+	maxRecentAgents      = 5
 )
 
 // AgentDialog interface for the agent selection dialog
@@ -31,7 +32,7 @@ type AgentDialog interface {
 
 type agentDialog struct {
 	app          *app.App
-	allAgents    []opencode.Agent
+	allAgents    []agentSelectItem
 	width        int
 	height       int
 	modal        *modal.Modal
@@ -39,24 +40,31 @@ type agentDialog struct {
 	dialogWidth  int
 }
 
-// agentItem is a custom list item for agent selections
-type agentItem struct {
-	agent opencode.Agent
+// agentSelectItem combines the visual improvements with code patterns
+type agentSelectItem struct {
+	name        string
+	displayName string
+	description string
+	mode        string // "primary", "subagent", "all"
+	isCurrent   bool
+	agentIndex  int
+	agent       opencode.Agent // Keep original agent for compatibility
 }
 
-func (a agentItem) Render(
+func (a agentSelectItem) 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())
+		// Use agent color for highlighting when selected (visual improvement)
+		agentColor := util.GetAgentColor(a.agentIndex)
+		itemStyle = itemStyle.Foreground(agentColor)
 	}
 
 	descStyle := baseStyle.
@@ -66,25 +74,43 @@ func (a agentItem) Render(
 	// 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)
+	agentName := a.displayName
+
+	// For user agents and subagents, show description; for built-in, show mode
+	var displayText string
+	if a.description != "" && (a.mode == "all" || a.mode == "subagent") {
+		// User agent or subagent with description
+		displayText = a.description
+	} else {
+		// Built-in without description - show mode
+		switch a.mode {
+		case "primary":
+			displayText = "(built-in)"
+		case "all":
+			displayText = "(user)"
+		default:
+			displayText = ""
+		}
 	}
 
 	separator := " - "
 
-	// Calculate how much space we have for the description
+	// Calculate how much space we have for the description (visual improvement)
 	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] + "..."
+	// Cap description length to the maximum allowed
+	if descriptionMaxLength > maxDescriptionLength {
+		descriptionMaxLength = maxDescriptionLength
+	}
+
+	// Truncate description if it's too long (visual improvement)
+	if len(displayText) > descriptionMaxLength && descriptionMaxLength > 3 {
+		displayText = displayText[:descriptionMaxLength-3] + "..."
 	}
 
 	namePart := itemStyle.Render(agentName)
-	descPart := descStyle.Render(separator + description)
+	descPart := descStyle.Render(separator + displayText)
 	combinedText := namePart + descPart
 
 	return baseStyle.
@@ -94,8 +120,7 @@ func (a agentItem) Render(
 		Render(combinedText)
 }
 
-func (a agentItem) Selectable() bool {
-	// All agents in the dialog are selectable (subagents are filtered out)
+func (a agentSelectItem) Selectable() bool {
 	return true
 }
 
@@ -122,32 +147,43 @@ func (a *agentDialog) Init() tea.Cmd {
 
 func (a *agentDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		a.width = msg.Width
+		a.height = msg.Height
+		a.searchDialog.SetWidth(a.dialogWidth)
+		a.searchDialog.SetHeight(msg.Height)
+
 	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,
-					}),
-			)
+		if item, ok := msg.Item.(agentSelectItem); ok {
+			if !item.isCurrent {
+				// Switch to selected agent (using their better pattern)
+				return a, tea.Sequence(
+					util.CmdHandler(modal.CloseModalMsg{}),
+					util.CmdHandler(app.AgentSelectedMsg{AgentName: item.name}),
+				)
+			}
 		}
 		return a, util.CmdHandler(modal.CloseModalMsg{})
 	case SearchCancelledMsg:
 		return a, util.CmdHandler(modal.CloseModalMsg{})
 
+	case SearchRemoveItemMsg:
+		if item, ok := msg.Item.(agentSelectItem); ok {
+			if a.isAgentInRecentSection(item, msg.Index) {
+				a.app.State.RemoveAgentFromRecentlyUsed(item.name)
+				items := a.buildDisplayList(a.searchDialog.GetQuery())
+				a.searchDialog.SetItems(items)
+				return a, a.app.SaveState()
+			}
+		}
+		return a, nil
+
 	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)
@@ -155,20 +191,38 @@ func (a *agentDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return a, cmd
 }
 
+func (a *agentDialog) SetSize(width, height int) {
+	a.width = width
+	a.height = height
+}
+
 func (a *agentDialog) View() string {
 	return a.searchDialog.View()
 }
 
-func (a *agentDialog) calculateOptimalWidth(agents []opencode.Agent) int {
+func (a *agentDialog) calculateOptimalWidth(agents []agentSelectItem) 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 // " - "
+		// Calculate the width needed for this item: "AgentName - Description" (visual improvement)
+		itemWidth := len(agent.displayName)
+		if agent.description != "" && (agent.mode == "all" || agent.mode == "subagent") {
+			// User agent or subagent - use description (capped to maxDescriptionLength)
+			descLength := len(agent.description)
+			if descLength > maxDescriptionLength {
+				descLength = maxDescriptionLength
+			}
+			itemWidth += descLength + 3 // " - "
 		} else {
-			itemWidth += len(string(agent.Mode)) + 3 // " (mode)"
+			// Built-in without description - use mode
+			var modeText string
+			switch agent.mode {
+			case "primary":
+				modeText = "(built-in)"
+			case "all":
+				modeText = "(user)"
+			}
+			itemWidth += len(modeText) + 3 // " - "
 		}
 
 		if itemWidth > maxWidth {
@@ -177,22 +231,34 @@ func (a *agentDialog) calculateOptimalWidth(agents []opencode.Agent) int {
 	}
 
 	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)
-		}
+	currentAgentName := a.app.Agent().Name
+
+	// Build agent items from app.Agents (no API call needed) - their pattern
+	a.allAgents = make([]agentSelectItem, 0, len(a.app.Agents))
+	for i, agent := range a.app.Agents {
+		isCurrent := agent.Name == currentAgentName
+
+		// Create display name (capitalize first letter)
+		displayName := strings.Title(agent.Name)
+
+		a.allAgents = append(a.allAgents, agentSelectItem{
+			name:        agent.Name,
+			displayName: displayName,
+			description: agent.Description, // Keep for search but don't use in display
+			mode:        string(agent.Mode),
+			isCurrent:   isCurrent,
+			agentIndex:  i,
+			agent:       agent, // Keep original for compatibility
+		})
 	}
 
 	a.sortAgents()
 
-	// Calculate optimal width based on all agents
+	// Calculate optimal width based on all agents (visual improvement)
 	a.dialogWidth = a.calculateOptimalWidth(a.allAgents)
 
 	// Ensure minimum width to prevent textinput issues
@@ -201,6 +267,7 @@ func (a *agentDialog) setupAllAgents() {
 	a.searchDialog = NewSearchDialog("Search agents...", numVisibleAgents)
 	a.searchDialog.SetWidth(a.dialogWidth)
 
+	// Build initial display list (empty query shows grouped view)
 	items := a.buildDisplayList("")
 	a.searchDialog.SetItems(items)
 }
@@ -210,42 +277,40 @@ func (a *agentDialog) sortAgents() {
 		agentA := a.allAgents[i]
 		agentB := a.allAgents[j]
 
-		// Current agent goes first
-		if agentA.Name == a.app.Agent().Name {
+		// Current agent goes first (your preference)
+		if agentA.name == a.app.Agent().Name {
 			return true
 		}
-		if agentB.Name == a.app.Agent().Name {
+		if agentB.name == a.app.Agent().Name {
 			return false
 		}
 
 		// Alphabetical order for all other agents
-		return agentA.Name < agentB.Name
+		return agentA.name < agentB.name
 	})
 }
 
+// buildDisplayList creates the list items based on search query
 func (a *agentDialog) buildDisplayList(query string) []list.Item {
 	if query != "" {
+		// Search mode: use fuzzy matching
 		return a.buildSearchResults(query)
+	} else {
+		// Grouped mode: show Recent agents section and alphabetical list (their pattern)
+		return a.buildGroupedResults()
 	}
-	return a.buildGroupedResults()
 }
 
+// buildSearchResults creates a flat list of search results using fuzzy matching
 func (a *agentDialog) buildSearchResults(query string) []list.Item {
 	agentNames := []string{}
-	agentMap := make(map[string]opencode.Agent)
+	agentMap := make(map[string]agentSelectItem)
 
 	for _, agent := range a.allAgents {
-		// Search by name
-		searchStr := agent.Name
+		// Search by name only
+		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)
@@ -257,25 +322,74 @@ func (a *agentDialog) buildSearchResults(query string) []list.Item {
 	for _, match := range matches {
 		agent := agentMap[match.Target]
 		// Create a unique key to avoid duplicates
-		key := agent.Name
+		key := agent.name
 		if seenAgents[key] {
 			continue
 		}
 		seenAgents[key] = true
-		items = append(items, agentItem{agent: agent})
+		items = append(items, agent)
 	}
 
 	return items
 }
 
+// buildGroupedResults creates a grouped list with Recent agents section and categorized agents
 func (a *agentDialog) buildGroupedResults() []list.Item {
 	var items []list.Item
 
-	items = append(items, list.HeaderItem("Agents"))
+	// Add Recent section (their pattern)
+	recentAgents := a.getRecentAgents(maxRecentAgents)
+	if len(recentAgents) > 0 {
+		items = append(items, list.HeaderItem("Recent"))
+		for _, agent := range recentAgents {
+			items = append(items, agent)
+		}
+	}
+
+	// Create map of recent agent names for filtering
+	recentAgentNames := make(map[string]bool)
+	for _, recent := range recentAgents {
+		recentAgentNames[recent.name] = true
+	}
+
+	// Separate agents by type (excluding recent ones)
+	primaryAndUserAgents := make([]agentSelectItem, 0)
+	subAgents := make([]agentSelectItem, 0)
 
-	// Add all agents (subagents are already filtered out)
 	for _, agent := range a.allAgents {
-		items = append(items, agentItem{agent: agent})
+		if !recentAgentNames[agent.name] {
+			switch agent.mode {
+			case "subagent":
+				subAgents = append(subAgents, agent)
+			default:
+				// primary, all, and any other types go in main "Agents" section
+				primaryAndUserAgents = append(primaryAndUserAgents, agent)
+			}
+		}
+	}
+
+	// Sort each category alphabetically
+	sort.Slice(primaryAndUserAgents, func(i, j int) bool {
+		return primaryAndUserAgents[i].name < primaryAndUserAgents[j].name
+	})
+	sort.Slice(subAgents, func(i, j int) bool {
+		return subAgents[i].name < subAgents[j].name
+	})
+
+	// Add main agents section
+	if len(primaryAndUserAgents) > 0 {
+		items = append(items, list.HeaderItem("Agents"))
+		for _, agent := range primaryAndUserAgents {
+			items = append(items, agent)
+		}
+	}
+
+	// Add subagents section
+	if len(subAgents) > 0 {
+		items = append(items, list.HeaderItem("Subagents"))
+		for _, agent := range subAgents {
+			items = append(items, agent)
+		}
 	}
 
 	return items
@@ -285,10 +399,65 @@ func (a *agentDialog) Render(background string) string {
 	return a.modal.Render(a.View(), background)
 }
 
-func (s *agentDialog) Close() tea.Cmd {
+func (a *agentDialog) Close() tea.Cmd {
 	return nil
 }
 
+// getRecentAgents returns the most recently used agents (their pattern)
+func (a *agentDialog) getRecentAgents(limit int) []agentSelectItem {
+	var recentAgents []agentSelectItem
+
+	// Get recent agents from app state
+	for _, usage := range a.app.State.RecentlyUsedAgents {
+		if len(recentAgents) >= limit {
+			break
+		}
+
+		// Find the corresponding agent
+		for _, agent := range a.allAgents {
+			if agent.name == usage.AgentName {
+				recentAgents = append(recentAgents, agent)
+				break
+			}
+		}
+	}
+
+	// If no recent agents, use the current agent
+	if len(recentAgents) == 0 {
+		currentAgentName := a.app.Agent().Name
+		for _, agent := range a.allAgents {
+			if agent.name == currentAgentName {
+				recentAgents = append(recentAgents, agent)
+				break
+			}
+		}
+	}
+
+	return recentAgents
+}
+
+func (a *agentDialog) isAgentInRecentSection(agent agentSelectItem, index int) bool {
+	// Only check if we're in grouped mode (no search query)
+	if a.searchDialog.GetQuery() != "" {
+		return false
+	}
+
+	recentAgents := a.getRecentAgents(maxRecentAgents)
+	if len(recentAgents) == 0 {
+		return false
+	}
+
+	// Index 0 is the "Recent" header, so recent agents are at indices 1 to len(recentAgents)
+	if index >= 1 && index <= len(recentAgents) {
+		if index-1 < len(recentAgents) {
+			recentAgent := recentAgents[index-1]
+			return recentAgent.name == agent.name
+		}
+	}
+
+	return false
+}
+
 func NewAgentDialog(app *app.App) AgentDialog {
 	dialog := &agentDialog{
 		app: app,

+ 4 - 25
packages/tui/internal/tui/tui.go

@@ -599,31 +599,9 @@ 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())
+		updated, cmd := a.app.SwitchToAgent(msg.AgentName)
+		a.app = updated
+		cmds = append(cmds, cmd)
 	case dialog.ThemeSelectedMsg:
 		a.app.State.Theme = msg.ThemeName
 		cmds = append(cmds, a.app.SaveState())
@@ -1171,6 +1149,7 @@ 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