From 81583cddbdd588fa3eb9e3e15ea70909ce1b4b93 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 12 Aug 2025 22:21:57 +0200 Subject: [PATCH] refactor(agent-modal): revamped UI/UX for the agent modal (#1838) Co-authored-by: Dax Raad Co-authored-by: Dax --- packages/tui/internal/app/app.go | 44 ++- packages/tui/internal/app/state.go | 43 +++ .../tui/internal/components/dialog/agents.go | 311 ++++++++++++++---- packages/tui/internal/tui/tui.go | 29 +- 4 files changed, 330 insertions(+), 97 deletions(-) diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 47520257..34572648 100644 --- a/packages/tui/internal/app/app.go +++ b/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, diff --git a/packages/tui/internal/app/state.go b/packages/tui/internal/app/state.go index 283cbd15..0e4010be 100644 --- a/packages/tui/internal/app/state.go +++ b/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 { diff --git a/packages/tui/internal/components/dialog/agents.go b/packages/tui/internal/components/dialog/agents.go index 49e3e025..61537242 100644 --- a/packages/tui/internal/components/dialog/agents.go +++ b/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, diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 7895adea..6b91cbfe 100644 --- a/packages/tui/internal/tui/tui.go +++ b/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