Compare commits

..
3 Commits
Author SHA1 Message Date
opencode 11bca51d8b release: v0.4.14 2025-08-11 05:58:03 +00:00
Aiden ClineandGitHub 62b8c7aee0 feat (tui): agents dialog (#1802) 2025-08-11 01:46:38 -04:00
Dax Raad 6145dfcca0 fix run command to be less messy 2025-08-11 01:45:05 -04:00
14 changed files with 357 additions and 19 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cloud-core",
"version": "0.4.12",
"version": "0.4.14",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-function",
"version": "0.4.12",
"version": "0.4.14",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-web",
"version": "0.4.12",
"version": "0.4.14",
"private": true,
"description": "",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "0.4.12",
"version": "0.4.14",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.4.12",
"version": "0.4.14",
"name": "opencode",
"type": "module",
"private": true,
+2 -9
View File
@@ -84,10 +84,6 @@ export const RunCommand = cmd({
return
}
UI.empty()
UI.println(UI.logo())
UI.empty()
const cfg = await Config.get()
if (cfg.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share) {
try {
@@ -101,7 +97,6 @@ export const RunCommand = cmd({
}
}
}
UI.empty()
const agent = await (async () => {
if (args.agent) return Agent.get(args.agent)
@@ -116,9 +111,6 @@ export const RunCommand = cmd({
return Provider.defaultModel()
})()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "@ ", UI.Style.TEXT_NORMAL + `${providerID}/${modelID}`)
UI.empty()
function printEvent(color: string, type: string, title: string) {
UI.println(
color + `|`,
@@ -137,7 +129,8 @@ export const RunCommand = cmd({
if (part.type === "tool" && part.state.status === "completed") {
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
const title =
part.state.title || Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown"
part.state.title ||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
printEvent(color, tool, title)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.4.12",
"version": "0.4.14",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.4.12",
"version": "0.4.14",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
+3
View File
@@ -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
+9 -1
View File
@@ -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",
@@ -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
}
+29
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/web",
"type": "module",
"version": "0.4.12",
"version": "0.4.14",
"scripts": {
"dev": "astro dev",
"dev:remote": "sst shell --stage=dev --target=Web astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "0.4.12",
"version": "0.4.14",
"publisher": "sst-dev",
"repository": {
"type": "git",