summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorEd Zynda <[email protected]>2025-05-16 18:57:35 +0300
committerGitHub <[email protected]>2025-05-16 10:57:35 -0500
commitb71cae63f1b59cc3f095912d040b915312d144ff (patch)
tree68cae587324ca6c2e0d1d76f1698d86b501db964
parentc92f7c6630c5a4d010ea0c80380f2dbb6dd7e3e1 (diff)
downloadopencode-b71cae63f1b59cc3f095912d040b915312d144ff.tar.gz
opencode-b71cae63f1b59cc3f095912d040b915312d144ff.zip
feat: Add tools dialog accessible via F9 (#24)
* Add tools dialog * Remove sorting and double items * Update key handling
-rw-r--r--internal/tui/components/dialog/tools.go178
-rw-r--r--internal/tui/tui.go124
2 files changed, 302 insertions, 0 deletions
diff --git a/internal/tui/components/dialog/tools.go b/internal/tui/components/dialog/tools.go
new file mode 100644
index 000000000..76e6ff227
--- /dev/null
+++ b/internal/tui/components/dialog/tools.go
@@ -0,0 +1,178 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ utilComponents "github.com/sst/opencode/internal/tui/components/util"
+ "github.com/sst/opencode/internal/tui/layout"
+ "github.com/sst/opencode/internal/tui/styles"
+ "github.com/sst/opencode/internal/tui/theme"
+)
+
+const (
+ maxToolsDialogWidth = 60
+ maxVisibleTools = 15
+)
+
+// ToolsDialog interface for the tools list dialog
+type ToolsDialog interface {
+ tea.Model
+ layout.Bindings
+ SetTools(tools []string)
+}
+
+// ShowToolsDialogMsg is sent to show the tools dialog
+type ShowToolsDialogMsg struct {
+ Show bool
+}
+
+// CloseToolsDialogMsg is sent when the tools dialog is closed
+type CloseToolsDialogMsg struct{}
+
+type toolItem struct {
+ name string
+}
+
+func (t toolItem) Render(selected bool, width int) string {
+ th := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle().
+ Width(width).
+ Background(th.Background())
+
+ if selected {
+ baseStyle = baseStyle.
+ Background(th.Primary()).
+ Foreground(th.Background()).
+ Bold(true)
+ } else {
+ baseStyle = baseStyle.
+ Foreground(th.Text())
+ }
+
+ return baseStyle.Render(t.name)
+}
+
+type toolsDialogCmp struct {
+ tools []toolItem
+ width int
+ height int
+ list utilComponents.SimpleList[toolItem]
+}
+
+type toolsKeyMap struct {
+ Up key.Binding
+ Down key.Binding
+ Escape key.Binding
+ J key.Binding
+ K key.Binding
+}
+
+var toolsKeys = toolsKeyMap{
+ Up: key.NewBinding(
+ key.WithKeys("up"),
+ key.WithHelp("↑", "previous tool"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys("down"),
+ key.WithHelp("↓", "next tool"),
+ ),
+ Escape: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "close"),
+ ),
+ J: key.NewBinding(
+ key.WithKeys("j"),
+ key.WithHelp("j", "next tool"),
+ ),
+ K: key.NewBinding(
+ key.WithKeys("k"),
+ key.WithHelp("k", "previous tool"),
+ ),
+}
+
+func (m *toolsDialogCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (m *toolsDialogCmp) SetTools(tools []string) {
+ var toolItems []toolItem
+ for _, name := range tools {
+ toolItems = append(toolItems, toolItem{name: name})
+ }
+
+ m.tools = toolItems
+ m.list.SetItems(toolItems)
+}
+
+func (m *toolsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, toolsKeys.Escape):
+ return m, func() tea.Msg { return CloseToolsDialogMsg{} }
+ // Pass other key messages to the list component
+ default:
+ var cmd tea.Cmd
+ listModel, cmd := m.list.Update(msg)
+ m.list = listModel.(utilComponents.SimpleList[toolItem])
+ return m, cmd
+ }
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ }
+
+ // For non-key messages
+ var cmd tea.Cmd
+ listModel, cmd := m.list.Update(msg)
+ m.list = listModel.(utilComponents.SimpleList[toolItem])
+ return m, cmd
+}
+
+func (m *toolsDialogCmp) View() string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle().Background(t.Background())
+
+ title := baseStyle.
+ Foreground(t.Primary()).
+ Bold(true).
+ Width(maxToolsDialogWidth).
+ Padding(0, 0, 1).
+ Render("Available Tools")
+
+ // Calculate dialog width based on content
+ dialogWidth := min(maxToolsDialogWidth, m.width/2)
+ m.list.SetMaxWidth(dialogWidth)
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ title,
+ m.list.View(),
+ )
+
+ return baseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(t.Background()).
+ BorderForeground(t.TextMuted()).
+ Background(t.Background()).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
+}
+
+func (m *toolsDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(toolsKeys)
+}
+
+func NewToolsDialogCmp() ToolsDialog {
+ list := utilComponents.NewSimpleList[toolItem](
+ []toolItem{},
+ maxVisibleTools,
+ "No tools available",
+ true,
+ )
+
+ return &toolsDialogCmp{
+ list: list,
+ }
+} \ No newline at end of file
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index cad64d30f..299c69793 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
+ "github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
@@ -38,6 +39,7 @@ type keyMap struct {
Filepicker key.Binding
Models key.Binding
SwitchTheme key.Binding
+ Tools key.Binding
}
const (
@@ -81,6 +83,11 @@ var keys = keyMap{
key.WithKeys("ctrl+t"),
key.WithHelp("ctrl+t", "switch theme"),
),
+
+ Tools: key.NewBinding(
+ key.WithKeys("f9"),
+ key.WithHelp("f9", "show available tools"),
+ ),
}
var helpEsc = key.NewBinding(
@@ -137,6 +144,9 @@ type appModel struct {
showMultiArgumentsDialog bool
multiArgumentsDialog dialog.MultiArgumentsDialogCmp
+
+ showToolsDialog bool
+ toolsDialog dialog.ToolsDialog
}
func (a appModel) Init() tea.Cmd {
@@ -162,6 +172,8 @@ func (a appModel) Init() tea.Cmd {
cmds = append(cmds, cmd)
cmd = a.themeDialog.Init()
cmds = append(cmds, cmd)
+ cmd = a.toolsDialog.Init()
+ cmds = append(cmds, cmd)
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
@@ -287,6 +299,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.CloseThemeDialogMsg:
a.showThemeDialog = false
return a, nil
+
+ case dialog.CloseToolsDialogMsg:
+ a.showToolsDialog = false
+ return a, nil
+
+ case dialog.ShowToolsDialogMsg:
+ a.showToolsDialog = msg.Show
+ return a, nil
case dialog.ThemeChangedMsg:
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
@@ -404,9 +424,18 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.showMultiArgumentsDialog {
a.showMultiArgumentsDialog = false
}
+ if a.showToolsDialog {
+ a.showToolsDialog = false
+ }
return a, nil
case key.Matches(msg, keys.SwitchSession):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
+ // Close other dialogs
+ a.showToolsDialog = false
+ a.showThemeDialog = false
+ a.showModelDialog = false
+ a.showFilepicker = false
+
// Load sessions and show the dialog
sessions, err := a.app.Sessions.List(context.Background())
if err != nil {
@@ -424,6 +453,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
case key.Matches(msg, keys.Commands):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
+ // Close other dialogs
+ a.showToolsDialog = false
+ a.showModelDialog = false
+
// Show commands dialog
if len(a.commands) == 0 {
status.Warn("No commands available")
@@ -440,22 +473,52 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
+ // Close other dialogs
+ a.showToolsDialog = false
+ a.showThemeDialog = false
+ a.showFilepicker = false
+
a.showModelDialog = true
return a, nil
}
return a, nil
case key.Matches(msg, keys.SwitchTheme):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
+ // Close other dialogs
+ a.showToolsDialog = false
+ a.showModelDialog = false
+ a.showFilepicker = false
+
a.showThemeDialog = true
return a, a.themeDialog.Init()
}
return a, nil
+ case key.Matches(msg, keys.Tools):
+ // Check if any other dialog is open
+ if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions &&
+ !a.showSessionDialog && !a.showCommandDialog && !a.showThemeDialog &&
+ !a.showFilepicker && !a.showModelDialog && !a.showInitDialog &&
+ !a.showMultiArgumentsDialog {
+ // Toggle tools dialog
+ a.showToolsDialog = !a.showToolsDialog
+ if a.showToolsDialog {
+ // Get tool names dynamically
+ toolNames := getAvailableToolNames(a.app)
+ a.toolsDialog.SetTools(toolNames)
+ }
+ return a, nil
+ }
+ return a, nil
case key.Matches(msg, returnKey) || key.Matches(msg):
if msg.String() == quitKey {
if a.currentPage == page.LogsPage {
return a, a.moveToPage(page.ChatPage)
}
} else if !a.filepicker.IsCWDFocused() {
+ if a.showToolsDialog {
+ a.showToolsDialog = false
+ return a, nil
+ }
if a.showQuit {
a.showQuit = !a.showQuit
return a, nil
@@ -490,6 +553,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
a.showHelp = !a.showHelp
+
+ // Close other dialogs if opening help
+ if a.showHelp {
+ a.showToolsDialog = false
+ }
return a, nil
case key.Matches(msg, helpEsc):
if a.app.PrimaryAgent.IsBusy() {
@@ -500,8 +568,18 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
case key.Matches(msg, keys.Filepicker):
+ // Toggle filepicker
a.showFilepicker = !a.showFilepicker
a.filepicker.ToggleFilepicker(a.showFilepicker)
+
+ // Close other dialogs if opening filepicker
+ if a.showFilepicker {
+ a.showToolsDialog = false
+ a.showThemeDialog = false
+ a.showModelDialog = false
+ a.showCommandDialog = false
+ a.showSessionDialog = false
+ }
return a, nil
}
@@ -600,6 +678,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...)
}
}
+
+ if a.showToolsDialog {
+ d, toolsCmd := a.toolsDialog.Update(msg)
+ a.toolsDialog = d.(dialog.ToolsDialog)
+ cmds = append(cmds, toolsCmd)
+ // Only block key messages send all other messages down
+ if _, ok := msg.(tea.KeyMsg); ok {
+ return a, tea.Batch(cmds...)
+ }
+ }
s, cmd := a.status.Update(msg)
cmds = append(cmds, cmd)
@@ -615,6 +703,26 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
a.commands = append(a.commands, cmd)
}
+// getAvailableToolNames returns a list of all available tool names
+func getAvailableToolNames(app *app.App) []string {
+ // Get primary agent tools (which already include MCP tools)
+ allTools := agent.PrimaryAgentTools(
+ app.Permissions,
+ app.Sessions,
+ app.Messages,
+ app.History,
+ app.LSPClients,
+ )
+
+ // Extract tool names
+ var toolNames []string
+ for _, tool := range allTools {
+ toolNames = append(toolNames, tool.Info().Name)
+ }
+
+ return toolNames
+}
+
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
// Allow navigating to logs page even when agent is busy
if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage {
@@ -820,6 +928,21 @@ func (a appModel) View() string {
true,
)
}
+
+ if a.showToolsDialog {
+ overlay := a.toolsDialog.View()
+ row := lipgloss.Height(appView) / 2
+ row -= lipgloss.Height(overlay) / 2
+ col := lipgloss.Width(appView) / 2
+ col -= lipgloss.Width(overlay) / 2
+ appView = layout.PlaceOverlay(
+ col,
+ row,
+ overlay,
+ appView,
+ true,
+ )
+ }
return appView
}
@@ -838,6 +961,7 @@ func New(app *app.App) tea.Model {
permissions: dialog.NewPermissionDialogCmp(),
initDialog: dialog.NewInitDialogCmp(),
themeDialog: dialog.NewThemeDialogCmp(),
+ toolsDialog: dialog.NewToolsDialogCmp(),
app: app,
commands: []dialog.Command{},
pages: map[page.PageID]tea.Model{