mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 01:34:22 +01:00
feat (tui): agents dialog (#1802)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
packages/tui/internal/components/dialog/agents.go
Normal file
305
packages/tui/internal/components/dialog/agents.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user