summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorspoons-and-mirrors <[email protected]>2025-08-12 22:21:57 +0200
committerGitHub <[email protected]>2025-08-12 16:21:57 -0400
commit81583cddbdd588fa3eb9e3e15ea70909ce1b4b93 (patch)
treeae32dc2bc337d569647c4f5af14abecffde6cf65
parentd16ae1fc4e6732dfb0d497f389f10c5ab04c253a (diff)
downloadopencode-81583cddbdd588fa3eb9e3e15ea70909ce1b4b93.tar.gz
opencode-81583cddbdd588fa3eb9e3e15ea70909ce1b4b93.zip
refactor(agent-modal): revamped UI/UX for the agent modal (#1838)
Co-authored-by: Dax Raad <[email protected]> Co-authored-by: Dax <[email protected]>
-rw-r--r--packages/tui/internal/app/app.go44
-rw-r--r--packages/tui/internal/app/state.go43
-rw-r--r--packages/tui/internal/components/dialog/agents.go311
-rw-r--r--packages/tui/internal/tui/tui.go29
4 files changed, 330 insertions, 97 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index 47520257d..345726483 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 283cbd15e..0e4010bec 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 49e3e025b..615372427 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 7895adeac..6b91cbfeb 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