agents.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. package dialog
  2. import (
  3. "sort"
  4. "strings"
  5. "github.com/charmbracelet/bubbles/v2/key"
  6. tea "github.com/charmbracelet/bubbletea/v2"
  7. "github.com/lithammer/fuzzysearch/fuzzy"
  8. "github.com/sst/opencode-sdk-go"
  9. "github.com/sst/opencode/internal/app"
  10. "github.com/sst/opencode/internal/components/list"
  11. "github.com/sst/opencode/internal/components/modal"
  12. "github.com/sst/opencode/internal/layout"
  13. "github.com/sst/opencode/internal/styles"
  14. "github.com/sst/opencode/internal/theme"
  15. "github.com/sst/opencode/internal/util"
  16. )
  17. const (
  18. numVisibleAgents = 10
  19. minAgentDialogWidth = 40
  20. maxAgentDialogWidth = 60
  21. maxDescriptionLength = 60
  22. maxRecentAgents = 5
  23. )
  24. // AgentDialog interface for the agent selection dialog
  25. type AgentDialog interface {
  26. layout.Modal
  27. }
  28. type agentDialog struct {
  29. app *app.App
  30. allAgents []agentSelectItem
  31. width int
  32. height int
  33. modal *modal.Modal
  34. searchDialog *SearchDialog
  35. dialogWidth int
  36. }
  37. // agentSelectItem combines the visual improvements with code patterns
  38. type agentSelectItem struct {
  39. name string
  40. displayName string
  41. description string
  42. mode string // "primary", "subagent", "all"
  43. isCurrent bool
  44. agentIndex int
  45. agent opencode.Agent // Keep original agent for compatibility
  46. }
  47. func (a agentSelectItem) Render(
  48. selected bool,
  49. width int,
  50. baseStyle styles.Style,
  51. ) string {
  52. t := theme.CurrentTheme()
  53. itemStyle := baseStyle.
  54. Background(t.BackgroundPanel()).
  55. Foreground(t.Text())
  56. if selected {
  57. // Use agent color for highlighting when selected (visual improvement)
  58. agentColor := util.GetAgentColor(a.agentIndex)
  59. itemStyle = itemStyle.Foreground(agentColor)
  60. }
  61. descStyle := baseStyle.
  62. Foreground(t.TextMuted()).
  63. Background(t.BackgroundPanel())
  64. // Calculate available width (accounting for padding and margins)
  65. availableWidth := width - 2 // Account for left padding
  66. agentName := a.displayName
  67. // For user agents and subagents, show description; for built-in, show mode
  68. var displayText string
  69. if a.description != "" && (a.mode == "all" || a.mode == "subagent") {
  70. // User agent or subagent with description
  71. displayText = a.description
  72. } else {
  73. // Built-in without description - show mode
  74. switch a.mode {
  75. case "primary":
  76. displayText = "(built-in)"
  77. case "all":
  78. displayText = "(user)"
  79. default:
  80. displayText = ""
  81. }
  82. }
  83. separator := " - "
  84. // Calculate how much space we have for the description (visual improvement)
  85. nameAndSeparatorLength := len(agentName) + len(separator)
  86. descriptionMaxLength := availableWidth - nameAndSeparatorLength
  87. // Cap description length to the maximum allowed
  88. if descriptionMaxLength > maxDescriptionLength {
  89. descriptionMaxLength = maxDescriptionLength
  90. }
  91. // Truncate description if it's too long (visual improvement)
  92. if len(displayText) > descriptionMaxLength && descriptionMaxLength > 3 {
  93. displayText = displayText[:descriptionMaxLength-3] + "..."
  94. }
  95. namePart := itemStyle.Render(agentName)
  96. descPart := descStyle.Render(separator + displayText)
  97. combinedText := namePart + descPart
  98. return baseStyle.
  99. Background(t.BackgroundPanel()).
  100. PaddingLeft(1).
  101. Width(width).
  102. Render(combinedText)
  103. }
  104. func (a agentSelectItem) Selectable() bool {
  105. return true
  106. }
  107. type agentKeyMap struct {
  108. Enter key.Binding
  109. Escape key.Binding
  110. }
  111. var agentKeys = agentKeyMap{
  112. Enter: key.NewBinding(
  113. key.WithKeys("enter"),
  114. key.WithHelp("enter", "select agent"),
  115. ),
  116. Escape: key.NewBinding(
  117. key.WithKeys("esc"),
  118. key.WithHelp("esc", "close"),
  119. ),
  120. }
  121. func (a *agentDialog) Init() tea.Cmd {
  122. a.setupAllAgents()
  123. return a.searchDialog.Init()
  124. }
  125. func (a *agentDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  126. switch msg := msg.(type) {
  127. case tea.WindowSizeMsg:
  128. a.width = msg.Width
  129. a.height = msg.Height
  130. a.searchDialog.SetWidth(a.dialogWidth)
  131. a.searchDialog.SetHeight(msg.Height)
  132. case SearchSelectionMsg:
  133. // Handle selection from search dialog
  134. if item, ok := msg.Item.(agentSelectItem); ok {
  135. if !item.isCurrent {
  136. // Switch to selected agent (using their better pattern)
  137. return a, tea.Sequence(
  138. util.CmdHandler(modal.CloseModalMsg{}),
  139. util.CmdHandler(app.AgentSelectedMsg{AgentName: item.name}),
  140. )
  141. }
  142. }
  143. return a, util.CmdHandler(modal.CloseModalMsg{})
  144. case SearchCancelledMsg:
  145. return a, util.CmdHandler(modal.CloseModalMsg{})
  146. case SearchRemoveItemMsg:
  147. if item, ok := msg.Item.(agentSelectItem); ok {
  148. if a.isAgentInRecentSection(item, msg.Index) {
  149. a.app.State.RemoveAgentFromRecentlyUsed(item.name)
  150. items := a.buildDisplayList(a.searchDialog.GetQuery())
  151. a.searchDialog.SetItems(items)
  152. return a, a.app.SaveState()
  153. }
  154. }
  155. return a, nil
  156. case SearchQueryChangedMsg:
  157. // Update the list based on search query
  158. items := a.buildDisplayList(msg.Query)
  159. a.searchDialog.SetItems(items)
  160. return a, nil
  161. }
  162. updatedDialog, cmd := a.searchDialog.Update(msg)
  163. a.searchDialog = updatedDialog.(*SearchDialog)
  164. return a, cmd
  165. }
  166. func (a *agentDialog) SetSize(width, height int) {
  167. a.width = width
  168. a.height = height
  169. }
  170. func (a *agentDialog) View() string {
  171. return a.searchDialog.View()
  172. }
  173. func (a *agentDialog) calculateOptimalWidth(agents []agentSelectItem) int {
  174. maxWidth := minAgentDialogWidth
  175. for _, agent := range agents {
  176. // Calculate the width needed for this item: "AgentName - Description" (visual improvement)
  177. itemWidth := len(agent.displayName)
  178. if agent.description != "" && (agent.mode == "all" || agent.mode == "subagent") {
  179. // User agent or subagent - use description (capped to maxDescriptionLength)
  180. descLength := len(agent.description)
  181. if descLength > maxDescriptionLength {
  182. descLength = maxDescriptionLength
  183. }
  184. itemWidth += descLength + 3 // " - "
  185. } else {
  186. // Built-in without description - use mode
  187. var modeText string
  188. switch agent.mode {
  189. case "primary":
  190. modeText = "(built-in)"
  191. case "all":
  192. modeText = "(user)"
  193. }
  194. itemWidth += len(modeText) + 3 // " - "
  195. }
  196. if itemWidth > maxWidth {
  197. maxWidth = itemWidth
  198. }
  199. }
  200. maxWidth = min(maxWidth, maxAgentDialogWidth)
  201. return maxWidth
  202. }
  203. func (a *agentDialog) setupAllAgents() {
  204. currentAgentName := a.app.Agent().Name
  205. // Build agent items from app.Agents (no API call needed) - their pattern
  206. a.allAgents = make([]agentSelectItem, 0, len(a.app.Agents))
  207. for i, agent := range a.app.Agents {
  208. isCurrent := agent.Name == currentAgentName
  209. // Create display name (capitalize first letter)
  210. displayName := strings.Title(agent.Name)
  211. a.allAgents = append(a.allAgents, agentSelectItem{
  212. name: agent.Name,
  213. displayName: displayName,
  214. description: agent.Description, // Keep for search but don't use in display
  215. mode: string(agent.Mode),
  216. isCurrent: isCurrent,
  217. agentIndex: i,
  218. agent: agent, // Keep original for compatibility
  219. })
  220. }
  221. a.sortAgents()
  222. // Calculate optimal width based on all agents (visual improvement)
  223. a.dialogWidth = a.calculateOptimalWidth(a.allAgents)
  224. // Ensure minimum width to prevent textinput issues
  225. a.dialogWidth = max(a.dialogWidth, minAgentDialogWidth)
  226. a.searchDialog = NewSearchDialog("Search agents...", numVisibleAgents)
  227. a.searchDialog.SetWidth(a.dialogWidth)
  228. // Build initial display list (empty query shows grouped view)
  229. items := a.buildDisplayList("")
  230. a.searchDialog.SetItems(items)
  231. }
  232. func (a *agentDialog) sortAgents() {
  233. sort.Slice(a.allAgents, func(i, j int) bool {
  234. agentA := a.allAgents[i]
  235. agentB := a.allAgents[j]
  236. // Current agent goes first (your preference)
  237. if agentA.name == a.app.Agent().Name {
  238. return true
  239. }
  240. if agentB.name == a.app.Agent().Name {
  241. return false
  242. }
  243. // Alphabetical order for all other agents
  244. return agentA.name < agentB.name
  245. })
  246. }
  247. // buildDisplayList creates the list items based on search query
  248. func (a *agentDialog) buildDisplayList(query string) []list.Item {
  249. if query != "" {
  250. // Search mode: use fuzzy matching
  251. return a.buildSearchResults(query)
  252. } else {
  253. // Grouped mode: show Recent agents section and alphabetical list (their pattern)
  254. return a.buildGroupedResults()
  255. }
  256. }
  257. // buildSearchResults creates a flat list of search results using fuzzy matching
  258. func (a *agentDialog) buildSearchResults(query string) []list.Item {
  259. agentNames := []string{}
  260. agentMap := make(map[string]agentSelectItem)
  261. for _, agent := range a.allAgents {
  262. // Search by name only
  263. searchStr := agent.name
  264. agentNames = append(agentNames, searchStr)
  265. agentMap[searchStr] = agent
  266. }
  267. matches := fuzzy.RankFindFold(query, agentNames)
  268. sort.Sort(matches)
  269. items := []list.Item{}
  270. seenAgents := make(map[string]bool)
  271. for _, match := range matches {
  272. agent := agentMap[match.Target]
  273. // Create a unique key to avoid duplicates
  274. key := agent.name
  275. if seenAgents[key] {
  276. continue
  277. }
  278. seenAgents[key] = true
  279. items = append(items, agent)
  280. }
  281. return items
  282. }
  283. // buildGroupedResults creates a grouped list with Recent agents section and categorized agents
  284. func (a *agentDialog) buildGroupedResults() []list.Item {
  285. var items []list.Item
  286. // Add Recent section (their pattern)
  287. recentAgents := a.getRecentAgents(maxRecentAgents)
  288. if len(recentAgents) > 0 {
  289. items = append(items, list.HeaderItem("Recent"))
  290. for _, agent := range recentAgents {
  291. items = append(items, agent)
  292. }
  293. }
  294. // Create map of recent agent names for filtering
  295. recentAgentNames := make(map[string]bool)
  296. for _, recent := range recentAgents {
  297. recentAgentNames[recent.name] = true
  298. }
  299. // Separate agents by type (excluding recent ones)
  300. primaryAndUserAgents := make([]agentSelectItem, 0)
  301. subAgents := make([]agentSelectItem, 0)
  302. for _, agent := range a.allAgents {
  303. if !recentAgentNames[agent.name] {
  304. switch agent.mode {
  305. case "subagent":
  306. subAgents = append(subAgents, agent)
  307. default:
  308. // primary, all, and any other types go in main "Agents" section
  309. primaryAndUserAgents = append(primaryAndUserAgents, agent)
  310. }
  311. }
  312. }
  313. // Sort each category alphabetically
  314. sort.Slice(primaryAndUserAgents, func(i, j int) bool {
  315. return primaryAndUserAgents[i].name < primaryAndUserAgents[j].name
  316. })
  317. sort.Slice(subAgents, func(i, j int) bool {
  318. return subAgents[i].name < subAgents[j].name
  319. })
  320. // Add main agents section
  321. if len(primaryAndUserAgents) > 0 {
  322. items = append(items, list.HeaderItem("Agents"))
  323. for _, agent := range primaryAndUserAgents {
  324. items = append(items, agent)
  325. }
  326. }
  327. // Add subagents section
  328. if len(subAgents) > 0 {
  329. items = append(items, list.HeaderItem("Subagents"))
  330. for _, agent := range subAgents {
  331. items = append(items, agent)
  332. }
  333. }
  334. return items
  335. }
  336. func (a *agentDialog) Render(background string) string {
  337. return a.modal.Render(a.View(), background)
  338. }
  339. func (a *agentDialog) Close() tea.Cmd {
  340. return nil
  341. }
  342. // getRecentAgents returns the most recently used agents (their pattern)
  343. func (a *agentDialog) getRecentAgents(limit int) []agentSelectItem {
  344. var recentAgents []agentSelectItem
  345. // Get recent agents from app state
  346. for _, usage := range a.app.State.RecentlyUsedAgents {
  347. if len(recentAgents) >= limit {
  348. break
  349. }
  350. // Find the corresponding agent
  351. for _, agent := range a.allAgents {
  352. if agent.name == usage.AgentName {
  353. recentAgents = append(recentAgents, agent)
  354. break
  355. }
  356. }
  357. }
  358. // If no recent agents, use the current agent
  359. if len(recentAgents) == 0 {
  360. currentAgentName := a.app.Agent().Name
  361. for _, agent := range a.allAgents {
  362. if agent.name == currentAgentName {
  363. recentAgents = append(recentAgents, agent)
  364. break
  365. }
  366. }
  367. }
  368. return recentAgents
  369. }
  370. func (a *agentDialog) isAgentInRecentSection(agent agentSelectItem, index int) bool {
  371. // Only check if we're in grouped mode (no search query)
  372. if a.searchDialog.GetQuery() != "" {
  373. return false
  374. }
  375. recentAgents := a.getRecentAgents(maxRecentAgents)
  376. if len(recentAgents) == 0 {
  377. return false
  378. }
  379. // Index 0 is the "Recent" header, so recent agents are at indices 1 to len(recentAgents)
  380. if index >= 1 && index <= len(recentAgents) {
  381. if index-1 < len(recentAgents) {
  382. recentAgent := recentAgents[index-1]
  383. return recentAgent.name == agent.name
  384. }
  385. }
  386. return false
  387. }
  388. func NewAgentDialog(app *app.App) AgentDialog {
  389. dialog := &agentDialog{
  390. app: app,
  391. }
  392. dialog.setupAllAgents()
  393. dialog.modal = modal.New(
  394. modal.WithTitle("Select Agent"),
  395. modal.WithMaxWidth(dialog.dialogWidth+4),
  396. )
  397. return dialog
  398. }