summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2025-08-11 00:46:38 -0500
committerGitHub <[email protected]>2025-08-11 01:46:38 -0400
commit62b8c7aee0cb4da28b3dbb99ed1aed4be4360127 (patch)
treed96030970a95c5390e430e9e4680185e8fab5369
parent6145dfcca0b6c611abef992610fd57370f9e3886 (diff)
downloadopencode-62b8c7aee0cb4da28b3dbb99ed1aed4be4360127.tar.gz
opencode-62b8c7aee0cb4da28b3dbb99ed1aed4be4360127.zip
feat (tui): agents dialog (#1802)
-rw-r--r--packages/tui/internal/app/app.go3
-rw-r--r--packages/tui/internal/commands/command.go10
-rw-r--r--packages/tui/internal/components/dialog/agents.go305
-rw-r--r--packages/tui/internal/tui/tui.go29
4 files changed, 346 insertions, 1 deletions
diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go
index 9794d0489..517cafd23 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 cb54bd09d..55f118aaa 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"
@@ -249,6 +251,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Trigger: []string{"models"},
},
{
+ Name: AgentListCommand,
+ Description: "list agents",
+ Keybindings: parseBindings("<leader>a"),
+ Trigger: []string{"agents"},
+ },
+ {
Name: ThemeListCommand,
Description: "list themes",
Keybindings: parseBindings("<leader>t"),
diff --git a/packages/tui/internal/components/dialog/agents.go b/packages/tui/internal/components/dialog/agents.go
new file mode 100644
index 000000000..49e3e025b
--- /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 639d15d04..ea9f0560b 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