mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 09:44:21 +01:00
feat (tui): agents dialog (#1802)
This commit is contained in:
@@ -70,6 +70,9 @@ type ModelSelectedMsg struct {
|
|||||||
Provider opencode.Provider
|
Provider opencode.Provider
|
||||||
Model opencode.Model
|
Model opencode.Model
|
||||||
}
|
}
|
||||||
|
type AgentSelectedMsg struct {
|
||||||
|
Agent opencode.Agent
|
||||||
|
}
|
||||||
type SessionClearedMsg struct{}
|
type SessionClearedMsg struct{}
|
||||||
type CompactSessionMsg struct{}
|
type CompactSessionMsg struct{}
|
||||||
type SendPrompt = Prompt
|
type SendPrompt = Prompt
|
||||||
|
|||||||
@@ -64,12 +64,13 @@ func (r CommandRegistry) Sorted() []Command {
|
|||||||
commands = append(commands, command)
|
commands = append(commands, command)
|
||||||
}
|
}
|
||||||
slices.SortFunc(commands, func(a, b Command) int {
|
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{
|
priorityOrder := map[CommandName]int{
|
||||||
SessionNewCommand: 0,
|
SessionNewCommand: 0,
|
||||||
AppHelpCommand: 1,
|
AppHelpCommand: 1,
|
||||||
SessionShareCommand: 2,
|
SessionShareCommand: 2,
|
||||||
ModelListCommand: 3,
|
ModelListCommand: 3,
|
||||||
|
AgentListCommand: 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
aPriority, aHasPriority := priorityOrder[a.Name]
|
aPriority, aHasPriority := priorityOrder[a.Name]
|
||||||
@@ -119,6 +120,7 @@ const (
|
|||||||
SessionExportCommand CommandName = "session_export"
|
SessionExportCommand CommandName = "session_export"
|
||||||
ToolDetailsCommand CommandName = "tool_details"
|
ToolDetailsCommand CommandName = "tool_details"
|
||||||
ModelListCommand CommandName = "model_list"
|
ModelListCommand CommandName = "model_list"
|
||||||
|
AgentListCommand CommandName = "agent_list"
|
||||||
ThemeListCommand CommandName = "theme_list"
|
ThemeListCommand CommandName = "theme_list"
|
||||||
FileListCommand CommandName = "file_list"
|
FileListCommand CommandName = "file_list"
|
||||||
FileCloseCommand CommandName = "file_close"
|
FileCloseCommand CommandName = "file_close"
|
||||||
@@ -248,6 +250,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
|
|||||||
Keybindings: parseBindings("<leader>m"),
|
Keybindings: parseBindings("<leader>m"),
|
||||||
Trigger: []string{"models"},
|
Trigger: []string{"models"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: AgentListCommand,
|
||||||
|
Description: "list agents",
|
||||||
|
Keybindings: parseBindings("<leader>a"),
|
||||||
|
Trigger: []string{"agents"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: ThemeListCommand,
|
Name: ThemeListCommand,
|
||||||
Description: "list themes",
|
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)
|
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
|
||||||
cmds = append(cmds, a.app.SaveState())
|
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:
|
case dialog.ThemeSelectedMsg:
|
||||||
a.app.State.Theme = msg.ThemeName
|
a.app.State.Theme = msg.ThemeName
|
||||||
cmds = append(cmds, a.app.SaveState())
|
cmds = append(cmds, a.app.SaveState())
|
||||||
@@ -1119,6 +1145,9 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
|||||||
case commands.ModelListCommand:
|
case commands.ModelListCommand:
|
||||||
modelDialog := dialog.NewModelDialog(a.app)
|
modelDialog := dialog.NewModelDialog(a.app)
|
||||||
a.modal = modelDialog
|
a.modal = modelDialog
|
||||||
|
case commands.AgentListCommand:
|
||||||
|
agentDialog := dialog.NewAgentDialog(a.app)
|
||||||
|
a.modal = agentDialog
|
||||||
case commands.ThemeListCommand:
|
case commands.ThemeListCommand:
|
||||||
themeDialog := dialog.NewThemeDialog()
|
themeDialog := dialog.NewThemeDialog()
|
||||||
a.modal = themeDialog
|
a.modal = themeDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user