Просмотр исходного кода

feat: Add tools dialog accessible via F9 (#24)

* Add tools dialog

* Remove sorting and double items

* Update key handling
Ed Zynda 9 месяцев назад
Родитель
Сommit
b71cae63f1
2 измененных файлов с 302 добавлено и 0 удалено
  1. 178 0
      internal/tui/components/dialog/tools.go
  2. 124 0
      internal/tui/tui.go

+ 178 - 0
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,
+	}
+}

+ 124 - 0
internal/tui/tui.go

@@ -13,6 +13,7 @@ import (
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/app"
 	"github.com/sst/opencode/internal/config"
 	"github.com/sst/opencode/internal/config"
+	"github.com/sst/opencode/internal/llm/agent"
 	"github.com/sst/opencode/internal/logging"
 	"github.com/sst/opencode/internal/logging"
 	"github.com/sst/opencode/internal/message"
 	"github.com/sst/opencode/internal/message"
 	"github.com/sst/opencode/internal/permission"
 	"github.com/sst/opencode/internal/permission"
@@ -38,6 +39,7 @@ type keyMap struct {
 	Filepicker    key.Binding
 	Filepicker    key.Binding
 	Models        key.Binding
 	Models        key.Binding
 	SwitchTheme   key.Binding
 	SwitchTheme   key.Binding
+	Tools         key.Binding
 }
 }
 
 
 const (
 const (
@@ -81,6 +83,11 @@ var keys = keyMap{
 		key.WithKeys("ctrl+t"),
 		key.WithKeys("ctrl+t"),
 		key.WithHelp("ctrl+t", "switch theme"),
 		key.WithHelp("ctrl+t", "switch theme"),
 	),
 	),
+	
+	Tools: key.NewBinding(
+		key.WithKeys("f9"),
+		key.WithHelp("f9", "show available tools"),
+	),
 }
 }
 
 
 var helpEsc = key.NewBinding(
 var helpEsc = key.NewBinding(
@@ -137,6 +144,9 @@ type appModel struct {
 
 
 	showMultiArgumentsDialog bool
 	showMultiArgumentsDialog bool
 	multiArgumentsDialog     dialog.MultiArgumentsDialogCmp
 	multiArgumentsDialog     dialog.MultiArgumentsDialogCmp
+	
+	showToolsDialog bool
+	toolsDialog     dialog.ToolsDialog
 }
 }
 
 
 func (a appModel) Init() tea.Cmd {
 func (a appModel) Init() tea.Cmd {
@@ -162,6 +172,8 @@ func (a appModel) Init() tea.Cmd {
 	cmds = append(cmds, cmd)
 	cmds = append(cmds, cmd)
 	cmd = a.themeDialog.Init()
 	cmd = a.themeDialog.Init()
 	cmds = append(cmds, cmd)
 	cmds = append(cmds, cmd)
+	cmd = a.toolsDialog.Init()
+	cmds = append(cmds, cmd)
 
 
 	// Check if we should show the init dialog
 	// Check if we should show the init dialog
 	cmds = append(cmds, func() tea.Msg {
 	cmds = append(cmds, func() tea.Msg {
@@ -287,6 +299,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case dialog.CloseThemeDialogMsg:
 	case dialog.CloseThemeDialogMsg:
 		a.showThemeDialog = false
 		a.showThemeDialog = false
 		return a, nil
 		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:
 	case dialog.ThemeChangedMsg:
 		a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
 		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 {
 			if a.showMultiArgumentsDialog {
 				a.showMultiArgumentsDialog = false
 				a.showMultiArgumentsDialog = false
 			}
 			}
+			if a.showToolsDialog {
+				a.showToolsDialog = false
+			}
 			return a, nil
 			return a, nil
 		case key.Matches(msg, keys.SwitchSession):
 		case key.Matches(msg, keys.SwitchSession):
 			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
 			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
 				// Load sessions and show the dialog
 				sessions, err := a.app.Sessions.List(context.Background())
 				sessions, err := a.app.Sessions.List(context.Background())
 				if err != nil {
 				if err != nil {
@@ -424,6 +453,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, nil
 			return a, nil
 		case key.Matches(msg, keys.Commands):
 		case key.Matches(msg, keys.Commands):
 			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
 			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
 				// Show commands dialog
 				if len(a.commands) == 0 {
 				if len(a.commands) == 0 {
 					status.Warn("No commands available")
 					status.Warn("No commands available")
@@ -440,22 +473,52 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return a, nil
 				return a, nil
 			}
 			}
 			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
 			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
 				a.showModelDialog = true
 				return a, nil
 				return a, nil
 			}
 			}
 			return a, nil
 			return a, nil
 		case key.Matches(msg, keys.SwitchTheme):
 		case key.Matches(msg, keys.SwitchTheme):
 			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
 			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
 				a.showThemeDialog = true
 				return a, a.themeDialog.Init()
 				return a, a.themeDialog.Init()
 			}
 			}
 			return a, nil
 			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):
 		case key.Matches(msg, returnKey) || key.Matches(msg):
 			if msg.String() == quitKey {
 			if msg.String() == quitKey {
 				if a.currentPage == page.LogsPage {
 				if a.currentPage == page.LogsPage {
 					return a, a.moveToPage(page.ChatPage)
 					return a, a.moveToPage(page.ChatPage)
 				}
 				}
 			} else if !a.filepicker.IsCWDFocused() {
 			} else if !a.filepicker.IsCWDFocused() {
+				if a.showToolsDialog {
+					a.showToolsDialog = false
+					return a, nil
+				}
 				if a.showQuit {
 				if a.showQuit {
 					a.showQuit = !a.showQuit
 					a.showQuit = !a.showQuit
 					return a, nil
 					return a, nil
@@ -490,6 +553,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return a, nil
 				return a, nil
 			}
 			}
 			a.showHelp = !a.showHelp
 			a.showHelp = !a.showHelp
+			
+			// Close other dialogs if opening help
+			if a.showHelp {
+				a.showToolsDialog = false
+			}
 			return a, nil
 			return a, nil
 		case key.Matches(msg, helpEsc):
 		case key.Matches(msg, helpEsc):
 			if a.app.PrimaryAgent.IsBusy() {
 			if a.app.PrimaryAgent.IsBusy() {
@@ -500,8 +568,18 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return a, nil
 				return a, nil
 			}
 			}
 		case key.Matches(msg, keys.Filepicker):
 		case key.Matches(msg, keys.Filepicker):
+			// Toggle filepicker
 			a.showFilepicker = !a.showFilepicker
 			a.showFilepicker = !a.showFilepicker
 			a.filepicker.ToggleFilepicker(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
 			return a, nil
 		}
 		}
 
 
@@ -600,6 +678,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, tea.Batch(cmds...)
 			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)
 	s, cmd := a.status.Update(msg)
 	cmds = append(cmds, cmd)
 	cmds = append(cmds, cmd)
@@ -615,6 +703,26 @@ func (a *appModel) RegisterCommand(cmd dialog.Command) {
 	a.commands = append(a.commands, cmd)
 	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 {
 func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 	// Allow navigating to logs page even when agent is busy
 	// Allow navigating to logs page even when agent is busy
 	if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage {
 	if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage {
@@ -820,6 +928,21 @@ func (a appModel) View() string {
 			true,
 			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
 	return appView
 }
 }
@@ -838,6 +961,7 @@ func New(app *app.App) tea.Model {
 		permissions:   dialog.NewPermissionDialogCmp(),
 		permissions:   dialog.NewPermissionDialogCmp(),
 		initDialog:    dialog.NewInitDialogCmp(),
 		initDialog:    dialog.NewInitDialogCmp(),
 		themeDialog:   dialog.NewThemeDialogCmp(),
 		themeDialog:   dialog.NewThemeDialogCmp(),
+		toolsDialog:   dialog.NewToolsDialogCmp(),
 		app:           app,
 		app:           app,
 		commands:      []dialog.Command{},
 		commands:      []dialog.Command{},
 		pages: map[page.PageID]tea.Model{
 		pages: map[page.PageID]tea.Model{