summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/components
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-05-30 20:47:56 -0400
committerDax Raad <[email protected]>2025-05-30 20:48:36 -0400
commitf3da73553c45f17e04b1e77cb13eb0fca714d1bd (patch)
treea24317a19e1ab2a89da50db669dc6894f15d00d1 /internal/tui/components
parent9a26b3058ffc1023e5c7e54b6d571c903d15888e (diff)
downloadopencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.tar.gz
opencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.zip
sync
Diffstat (limited to 'internal/tui/components')
-rw-r--r--internal/tui/components/chat/chat.go133
-rw-r--r--internal/tui/components/chat/editor.go406
-rw-r--r--internal/tui/components/chat/message.go716
-rw-r--r--internal/tui/components/chat/messages.go344
-rw-r--r--internal/tui/components/chat/sidebar.go220
-rw-r--r--internal/tui/components/core/status.go366
-rw-r--r--internal/tui/components/dialog/arguments.go257
-rw-r--r--internal/tui/components/dialog/commands.go180
-rw-r--r--internal/tui/components/dialog/complete.go263
-rw-r--r--internal/tui/components/dialog/custom_commands.go186
-rw-r--r--internal/tui/components/dialog/custom_commands_test.go106
-rw-r--r--internal/tui/components/dialog/filepicker.go485
-rw-r--r--internal/tui/components/dialog/help.go200
-rw-r--r--internal/tui/components/dialog/init.go189
-rw-r--r--internal/tui/components/dialog/models.go327
-rw-r--r--internal/tui/components/dialog/permission.go502
-rw-r--r--internal/tui/components/dialog/quit.go136
-rw-r--r--internal/tui/components/dialog/session.go230
-rw-r--r--internal/tui/components/dialog/theme.go199
-rw-r--r--internal/tui/components/dialog/tools.go178
-rw-r--r--internal/tui/components/qr/qr.go58
-rw-r--r--internal/tui/components/spinner/spinner.go127
-rw-r--r--internal/tui/components/spinner/spinner_test.go24
-rw-r--r--internal/tui/components/util/simple-list.go159
24 files changed, 0 insertions, 5991 deletions
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
deleted file mode 100644
index 2fabea43d..000000000
--- a/internal/tui/components/chat/chat.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package chat
-
-import (
- "fmt"
- "sort"
-
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/x/ansi"
- "github.com/sst/opencode/internal/config"
- "github.com/sst/opencode/internal/tui/app"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/internal/version"
-)
-
-type SendMsg struct {
- Text string
- Attachments []app.Attachment
-}
-
-func header(width int) string {
- return lipgloss.JoinVertical(
- lipgloss.Top,
- logo(width),
- repo(width),
- "",
- cwd(width),
- )
-}
-
-func lspsConfigured(width int) string {
- // cfg := config.Get()
- title := "LSP Servers"
- title = ansi.Truncate(title, width, "…")
-
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- lsps := baseStyle.
- Width(width).
- Foreground(t.Primary()).
- Bold(true).
- Render(title)
-
- // Get LSP names and sort them for consistent ordering
- var lspNames []string
- // for name := range cfg.LSP {
- // lspNames = append(lspNames, name)
- // }
- sort.Strings(lspNames)
-
- var lspViews []string
- // for _, name := range lspNames {
- // lsp := cfg.LSP[name]
- // lspName := baseStyle.
- // Foreground(t.Text()).
- // Render(fmt.Sprintf("• %s", name))
-
- // cmd := lsp.Command
- // cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
-
- // lspPath := baseStyle.
- // Foreground(t.TextMuted()).
- // Render(fmt.Sprintf(" (%s)", cmd))
-
- // lspViews = append(lspViews,
- // baseStyle.
- // Width(width).
- // Render(
- // lipgloss.JoinHorizontal(
- // lipgloss.Left,
- // lspName,
- // lspPath,
- // ),
- // ),
- // )
- // }
-
- return baseStyle.
- Width(width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- lsps,
- lipgloss.JoinVertical(
- lipgloss.Left,
- lspViews...,
- ),
- ),
- )
-}
-
-func logo(width int) string {
- logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- versionText := baseStyle.
- Foreground(t.TextMuted()).
- Render(version.Version)
-
- return baseStyle.
- Bold(true).
- Width(width).
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- logo,
- " ",
- versionText,
- ),
- )
-}
-
-func repo(width int) string {
- repo := "github.com/sst/opencode"
- t := theme.CurrentTheme()
-
- return styles.BaseStyle().
- Foreground(t.TextMuted()).
- Width(width).
- Render(repo)
-}
-
-func cwd(width int) string {
- cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
- t := theme.CurrentTheme()
-
- return styles.BaseStyle().
- Foreground(t.TextMuted()).
- Width(width).
- Render(cwd)
-}
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
deleted file mode 100644
index 6586f2020..000000000
--- a/internal/tui/components/chat/editor.go
+++ /dev/null
@@ -1,406 +0,0 @@
-package chat
-
-import (
- "fmt"
- "log/slog"
- "os"
- "os/exec"
- "slices"
- "strings"
- "unicode"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/textarea"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/status"
- "github.com/sst/opencode/internal/tui/app"
- "github.com/sst/opencode/internal/tui/components/dialog"
- "github.com/sst/opencode/internal/tui/image"
- "github.com/sst/opencode/internal/tui/layout"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/internal/tui/util"
-)
-
-type editorCmp struct {
- width int
- height int
- app *app.App
- textarea textarea.Model
- attachments []app.Attachment
- deleteMode bool
- history []string
- historyIndex int
- currentMessage string
-}
-
-type EditorKeyMaps struct {
- Send key.Binding
- OpenEditor key.Binding
- Paste key.Binding
- HistoryUp key.Binding
- HistoryDown key.Binding
-}
-
-type bluredEditorKeyMaps struct {
- Send key.Binding
- Focus key.Binding
- OpenEditor key.Binding
-}
-type DeleteAttachmentKeyMaps struct {
- AttachmentDeleteMode key.Binding
- Escape key.Binding
- DeleteAllAttachments key.Binding
-}
-
-var editorMaps = EditorKeyMaps{
- Send: key.NewBinding(
- key.WithKeys("enter", "ctrl+s"),
- key.WithHelp("enter", "send message"),
- ),
- OpenEditor: key.NewBinding(
- key.WithKeys("ctrl+e"),
- key.WithHelp("ctrl+e", "open editor"),
- ),
- Paste: key.NewBinding(
- key.WithKeys("ctrl+v"),
- key.WithHelp("ctrl+v", "paste content"),
- ),
- HistoryUp: key.NewBinding(
- key.WithKeys("up"),
- key.WithHelp("up", "previous message"),
- ),
- HistoryDown: key.NewBinding(
- key.WithKeys("down"),
- key.WithHelp("down", "next message"),
- ),
-}
-
-var DeleteKeyMaps = DeleteAttachmentKeyMaps{
- AttachmentDeleteMode: key.NewBinding(
- key.WithKeys("ctrl+r"),
- key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel delete mode"),
- ),
- DeleteAllAttachments: key.NewBinding(
- key.WithKeys("r"),
- key.WithHelp("ctrl+r+r", "delete all attachments"),
- ),
-}
-
-const (
- maxAttachments = 5
-)
-
-func (m *editorCmp) openEditor(value string) tea.Cmd {
- editor := os.Getenv("EDITOR")
- if editor == "" {
- editor = "nvim"
- }
-
- tmpfile, err := os.CreateTemp("", "msg_*.md")
- tmpfile.WriteString(value)
- if err != nil {
- status.Error(err.Error())
- return nil
- }
- tmpfile.Close()
- c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
- return tea.ExecProcess(c, func(err error) tea.Msg {
- if err != nil {
- status.Error(err.Error())
- return nil
- }
- content, err := os.ReadFile(tmpfile.Name())
- if err != nil {
- status.Error(err.Error())
- return nil
- }
- if len(content) == 0 {
- status.Warn("Message is empty")
- return nil
- }
- os.Remove(tmpfile.Name())
- attachments := m.attachments
- m.attachments = nil
- return SendMsg{
- Text: string(content),
- Attachments: attachments,
- }
- })
-}
-
-func (m *editorCmp) Init() tea.Cmd {
- return textarea.Blink
-}
-
-func (m *editorCmp) send() tea.Cmd {
- value := m.textarea.Value()
- m.textarea.Reset()
- attachments := m.attachments
-
- // Save to history if not empty and not a duplicate of the last entry
- if value != "" {
- if len(m.history) == 0 || m.history[len(m.history)-1] != value {
- m.history = append(m.history, value)
- }
- m.historyIndex = len(m.history)
- m.currentMessage = ""
- }
-
- m.attachments = nil
- if value == "" {
- return nil
- }
- return tea.Batch(
- util.CmdHandler(SendMsg{
- Text: value,
- Attachments: attachments,
- }),
- )
-}
-
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmd tea.Cmd
- switch msg := msg.(type) {
- case dialog.ThemeChangedMsg:
- m.textarea = CreateTextArea(&m.textarea)
- case dialog.CompletionSelectedMsg:
- existingValue := m.textarea.Value()
- modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
-
- m.textarea.SetValue(modifiedValue)
- return m, nil
- case dialog.AttachmentAddedMsg:
- if len(m.attachments) >= maxAttachments {
- status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments))
- return m, cmd
- }
- m.attachments = append(m.attachments, msg.Attachment)
- case tea.KeyMsg:
- if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
- m.deleteMode = true
- return m, nil
- }
- if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
- m.deleteMode = false
- m.attachments = nil
- return m, nil
- }
- if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
- num := int(msg.Runes[0] - '0')
- m.deleteMode = false
- if num < 10 && len(m.attachments) > num {
- if num == 0 {
- m.attachments = m.attachments[num+1:]
- } else {
- m.attachments = slices.Delete(m.attachments, num, num+1)
- }
- return m, nil
- }
- }
- if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
- key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
- return m, nil
- }
- if key.Matches(msg, editorMaps.OpenEditor) {
- // if m.app.PrimaryAgentOLD.IsSessionBusy(m.app.CurrentSessionOLD.ID) {
- // status.Warn("Agent is working, please wait...")
- // return m, nil
- // }
- value := m.textarea.Value()
- m.textarea.Reset()
- return m, m.openEditor(value)
- }
- if key.Matches(msg, DeleteKeyMaps.Escape) {
- m.deleteMode = false
- return m, nil
- }
-
- if key.Matches(msg, editorMaps.Paste) {
- imageBytes, text, err := image.GetImageFromClipboard()
- if err != nil {
- slog.Error(err.Error())
- return m, cmd
- }
- if len(imageBytes) != 0 {
- attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
- attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
- m.attachments = append(m.attachments, attachment)
- } else {
- m.textarea.SetValue(m.textarea.Value() + text)
- }
- return m, cmd
- }
-
- // Handle history navigation with up/down arrow keys
- // Only handle history navigation if the filepicker is not open and completion dialog is not open
- if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
- // Get the current line number
- currentLine := m.textarea.Line()
-
- // Only navigate history if we're at the first line
- if currentLine == 0 && len(m.history) > 0 {
- // Save current message if we're just starting to navigate
- if m.historyIndex == len(m.history) {
- m.currentMessage = m.textarea.Value()
- }
-
- // Go to previous message in history
- if m.historyIndex > 0 {
- m.historyIndex--
- m.textarea.SetValue(m.history[m.historyIndex])
- }
- return m, nil
- }
- }
-
- if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
- // Get the current line number and total lines
- currentLine := m.textarea.Line()
- value := m.textarea.Value()
- lines := strings.Split(value, "\n")
- totalLines := len(lines)
-
- // Only navigate history if we're at the last line
- if currentLine == totalLines-1 {
- if m.historyIndex < len(m.history)-1 {
- // Go to next message in history
- m.historyIndex++
- m.textarea.SetValue(m.history[m.historyIndex])
- } else if m.historyIndex == len(m.history)-1 {
- // Return to the current message being composed
- m.historyIndex = len(m.history)
- m.textarea.SetValue(m.currentMessage)
- }
- return m, nil
- }
- }
-
- // Handle Enter key
- if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
- value := m.textarea.Value()
- if len(value) > 0 && value[len(value)-1] == '\\' {
- // If the last character is a backslash, remove it and add a newline
- m.textarea.SetValue(value[:len(value)-1] + "\n")
- return m, nil
- } else {
- // Otherwise, send the message
- return m, m.send()
- }
- }
-
- }
- m.textarea, cmd = m.textarea.Update(msg)
- return m, cmd
-}
-
-func (m *editorCmp) View() string {
- t := theme.CurrentTheme()
-
- // Style the prompt with theme colors
- style := lipgloss.NewStyle().
- Padding(0, 0, 0, 1).
- Bold(true).
- Foreground(t.Primary())
-
- if len(m.attachments) == 0 {
- return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
- }
- m.textarea.SetHeight(m.height - 1)
- return lipgloss.JoinVertical(lipgloss.Top,
- m.attachmentsContent(),
- lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
- m.textarea.View()),
- )
-}
-
-func (m *editorCmp) SetSize(width, height int) tea.Cmd {
- m.width = width
- m.height = height
- m.textarea.SetWidth(width - 3) // account for the prompt and padding right
- m.textarea.SetHeight(height)
- return nil
-}
-
-func (m *editorCmp) GetSize() (int, int) {
- return m.textarea.Width(), m.textarea.Height()
-}
-
-func (m *editorCmp) attachmentsContent() string {
- var styledAttachments []string
- t := theme.CurrentTheme()
- attachmentStyles := styles.BaseStyle().
- MarginLeft(1).
- Background(t.TextMuted()).
- Foreground(t.Text())
- for i, attachment := range m.attachments {
- var filename string
- if len(attachment.FileName) > 10 {
- filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
- } else {
- filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
- }
- if m.deleteMode {
- filename = fmt.Sprintf("%d%s", i, filename)
- }
- styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
- }
- content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
- return content
-}
-
-func (m *editorCmp) BindingKeys() []key.Binding {
- bindings := []key.Binding{}
- bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
- bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
- return bindings
-}
-
-func CreateTextArea(existing *textarea.Model) textarea.Model {
- t := theme.CurrentTheme()
- bgColor := t.Background()
- textColor := t.Text()
- textMutedColor := t.TextMuted()
-
- ta := textarea.New()
- ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
- ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
- ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
- ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
- ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
- ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
- ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
- ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
-
- ta.Prompt = " "
- ta.ShowLineNumbers = false
- ta.CharLimit = -1
-
- if existing != nil {
- ta.SetValue(existing.Value())
- ta.SetWidth(existing.Width())
- ta.SetHeight(existing.Height())
- }
-
- ta.Focus()
- return ta
-}
-
-func NewEditorCmp(app *app.App) tea.Model {
- ta := CreateTextArea(nil)
- return &editorCmp{
- app: app,
- textarea: ta,
- history: []string{},
- historyIndex: 0,
- currentMessage: "",
- }
-}
diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go
deleted file mode 100644
index feed7ec59..000000000
--- a/internal/tui/components/chat/message.go
+++ /dev/null
@@ -1,716 +0,0 @@
-package chat
-
-import (
- "fmt"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/x/ansi"
- "github.com/sst/opencode/internal/config"
- "github.com/sst/opencode/internal/diff"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/pkg/client"
- "golang.org/x/text/cases"
- "golang.org/x/text/language"
-)
-
-const (
- maxResultHeight = 10
-)
-
-func toMarkdown(content string, width int) string {
- r := styles.GetMarkdownRenderer(width)
- rendered, _ := r.Render(content)
- return strings.TrimSuffix(rendered, "\n")
-}
-
-func renderUserMessage(msg client.MessageInfo, width int) string {
- t := theme.CurrentTheme()
- style := styles.BaseStyle().
- BorderLeft(true).
- Foreground(t.TextMuted()).
- BorderForeground(t.Secondary()).
- BorderStyle(lipgloss.ThickBorder())
-
- baseStyle := styles.BaseStyle()
- // var styledAttachments []string
- // attachmentStyles := baseStyle.
- // MarginLeft(1).
- // Background(t.TextMuted()).
- // Foreground(t.Text())
- // for _, attachment := range msg.BinaryContent() {
- // file := filepath.Base(attachment.Path)
- // var filename string
- // if len(file) > 10 {
- // filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
- // } else {
- // filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
- // }
- // styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
- // }
-
- // Add timestamp info
- timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
- username, _ := config.GetUsername()
- info := baseStyle.
- Foreground(t.TextMuted()).
- Render(fmt.Sprintf(" %s (%s)", username, timestamp))
-
- content := ""
- // if len(styledAttachments) > 0 {
- // attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
- // content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
- // } else {
- for _, p := range msg.Parts {
- part, err := p.ValueByDiscriminator()
- if err != nil {
- continue //TODO: handle error?
- }
-
- switch part.(type) {
- case client.MessagePartText:
- textPart := part.(client.MessagePartText)
- text := toMarkdown(textPart.Text, width)
- content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
- }
- }
-
- return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
-}
-
-func convertToMap(input *any) (map[string]any, bool) {
- if input == nil {
- return nil, false // Handle nil pointer
- }
- value := *input // Dereference the pointer to get the interface value
- m, ok := value.(map[string]any) // Type assertion
- return m, ok
-}
-
-func renderAssistantMessage(
- msg client.MessageInfo,
- width int,
- showToolMessages bool,
-) string {
- t := theme.CurrentTheme()
- style := styles.BaseStyle().
- BorderLeft(true).
- Foreground(t.TextMuted()).
- BorderForeground(t.Primary()).
- BorderStyle(lipgloss.ThickBorder())
- toolStyle := styles.BaseStyle().
- BorderLeft(true).
- Foreground(t.TextMuted()).
- BorderForeground(t.TextMuted()).
- BorderStyle(lipgloss.ThickBorder())
-
- baseStyle := styles.BaseStyle()
- messages := []string{}
-
- // content := strings.TrimSpace(msg.Content().String())
- // thinking := msg.IsThinking()
- // thinkingContent := msg.ReasoningContent().Thinking
- // finished := msg.IsFinished()
- // finishData := msg.FinishPart()
-
- // Add timestamp info
- timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
- modelName := msg.Metadata.Assistant.ModelID
- info := baseStyle.
- Foreground(t.TextMuted()).
- Render(fmt.Sprintf(" %s (%s)", modelName, timestamp))
-
- for _, p := range msg.Parts {
- part, err := p.ValueByDiscriminator()
- if err != nil {
- continue //TODO: handle error?
- }
-
- switch part.(type) {
- case client.MessagePartText:
- textPart := part.(client.MessagePartText)
- text := toMarkdown(textPart.Text, width)
- content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
- message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
- messages = append(messages, message)
-
- case client.MessagePartToolInvocation:
- if !showToolMessages {
- continue
- }
-
- toolInvocationPart := part.(client.MessagePartToolInvocation)
- toolInvocation, _ := toolInvocationPart.ToolInvocation.ValueByDiscriminator()
- switch toolInvocation.(type) {
- case client.MessageToolInvocationToolCall:
- toolCall := toolInvocation.(client.MessageToolInvocationToolCall)
- toolName := renderToolName(toolCall.ToolName)
-
- var toolArgs []string
- toolMap, _ := convertToMap(toolCall.Args)
- for _, arg := range toolMap {
- toolArgs = append(toolArgs, fmt.Sprintf("%v", arg))
- }
- params := renderParams(width-lipgloss.Width(toolName)-1, toolArgs...)
- title := styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, params))
-
- content := toolStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
- title,
- " In progress...",
- ))
- message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
- messages = append(messages, message)
-
- case client.MessageToolInvocationToolResult:
- toolInvocationResult := toolInvocation.(client.MessageToolInvocationToolResult)
- toolName := renderToolName(toolInvocationResult.ToolName)
- var toolArgs []string
- toolMap, _ := convertToMap(toolInvocationResult.Args)
- for _, arg := range toolMap {
- toolArgs = append(toolArgs, fmt.Sprintf("%v", arg))
- }
- params := renderParams(width-lipgloss.Width(toolName)-1, toolArgs...)
- title := styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, params))
- metadata := msg.Metadata.Tool[toolInvocationResult.ToolCallId].(map[string]any)
-
- var markdown string
- if toolInvocationResult.ToolName == "edit" {
- filename := toolMap["filePath"].(string)
- title = styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, filename))
- oldString := toolMap["oldString"].(string)
- newString := toolMap["newString"].(string)
- patch, _, _ := diff.GenerateDiff(oldString, newString, filename)
- formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width))
- markdown = strings.TrimSpace(formattedDiff)
- message := toolStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
- title,
- markdown,
- ))
- messages = append(messages, message)
- } else if toolInvocationResult.ToolName == "view" {
- result := toolInvocationResult.Result
- if metadata["preview"] != nil {
- result = metadata["preview"].(string)
- }
- filename := toolMap["filePath"].(string)
- ext := filepath.Ext(filename)
- if ext == "" {
- ext = ""
- } else {
- ext = strings.ToLower(ext[1:])
- }
- result = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(result, 10))
- markdown = toMarkdown(result, width)
- content := toolStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
- title,
- markdown,
- ))
- message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
- messages = append(messages, message)
- } else {
- result := truncateHeight(strings.TrimSpace(toolInvocationResult.Result), 10)
- markdown = toMarkdown(result, width)
- content := toolStyle.Render(lipgloss.JoinVertical(lipgloss.Left,
- title,
- markdown,
- ))
- message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
- messages = append(messages, message)
- }
- }
- }
- }
-
- // if finished {
- // // Add finish info if available
- // switch finishData.Reason {
- // case message.FinishReasonCanceled:
- // info = append(info, baseStyle.
- // Width(width-1).
- // Foreground(t.Warning()).
- // Render("(canceled)"),
- // )
- // case message.FinishReasonError:
- // info = append(info, baseStyle.
- // Width(width-1).
- // Foreground(t.Error()).
- // Render("(error)"),
- // )
- // case message.FinishReasonPermissionDenied:
- // info = append(info, baseStyle.
- // Width(width-1).
- // Foreground(t.Info()).
- // Render("(permission denied)"),
- // )
- // }
- // }
-
- // if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
- // if content == "" {
- // content = "*Finished without output*"
- // }
- //
- // content = renderMessage(content, false, width, info...)
- // messages = append(messages, content)
- // // position += messages[0].height
- // position++ // for the space
- // } else if thinking && thinkingContent != "" {
- // // Render the thinking content with timestamp
- // content = renderMessage(thinkingContent, false, width, info...)
- // messages = append(messages, content)
- // position += lipgloss.Height(content)
- // position++ // for the space
- // }
-
- // Only render tool messages if they should be shown
- if showToolMessages {
- // for i, toolCall := range msg.ToolCalls() {
- // toolCallContent := renderToolMessage(
- // toolCall,
- // allMessages,
- // messagesService,
- // focusedUIMessageId,
- // false,
- // width,
- // i+1,
- // )
- // messages = append(messages, toolCallContent)
- // }
- }
-
- return strings.Join(messages, "\n\n")
-}
-
-func renderToolName(name string) string {
- switch name {
- // case agent.AgentToolName:
- // return "Task"
- case "ls":
- return "List"
- default:
- return cases.Title(language.Und).String(name)
- }
-}
-
-func renderToolAction(name string) string {
- switch name {
- // case agent.AgentToolName:
- // return "Preparing prompt..."
- case "bash":
- return "Building command..."
- case "edit":
- return "Preparing edit..."
- case "fetch":
- return "Writing fetch..."
- case "glob":
- return "Finding files..."
- case "grep":
- return "Searching content..."
- case "ls":
- return "Listing directory..."
- case "view":
- return "Reading file..."
- case "write":
- return "Preparing write..."
- case "patch":
- return "Preparing patch..."
- case "batch":
- return "Running batch operations..."
- }
- return "Working..."
-}
-
-// renders params, params[0] (params[1]=params[2] ....)
-func renderParams(paramsWidth int, params ...string) string {
- if len(params) == 0 {
- return ""
- }
- mainParam := params[0]
- if len(mainParam) > paramsWidth {
- mainParam = mainParam[:paramsWidth-3] + "..."
- }
-
- if len(params) == 1 {
- return mainParam
- }
- otherParams := params[1:]
- // create pairs of key/value
- // if odd number of params, the last one is a key without value
- if len(otherParams)%2 != 0 {
- otherParams = append(otherParams, "")
- }
- parts := make([]string, 0, len(otherParams)/2)
- for i := 0; i < len(otherParams); i += 2 {
- key := otherParams[i]
- value := otherParams[i+1]
- if value == "" {
- continue
- }
- parts = append(parts, fmt.Sprintf("%s=%s", key, value))
- }
-
- partsRendered := strings.Join(parts, ", ")
- remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
- if remainingWidth < 30 {
- // No space for the params, just show the main
- return mainParam
- }
-
- if len(parts) > 0 {
- mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
- }
-
- return ansi.Truncate(mainParam, paramsWidth, "...")
-}
-
-func removeWorkingDirPrefix(path string) string {
- wd := config.WorkingDirectory()
- if strings.HasPrefix(path, wd) {
- path = strings.TrimPrefix(path, wd)
- }
- if strings.HasPrefix(path, "/") {
- path = strings.TrimPrefix(path, "/")
- }
- if strings.HasPrefix(path, "./") {
- path = strings.TrimPrefix(path, "./")
- }
- if strings.HasPrefix(path, "../") {
- path = strings.TrimPrefix(path, "../")
- }
- return path
-}
-
-func renderToolParams(paramWidth int, toolCall any) string {
- params := ""
- switch toolCall {
- // // case agent.AgentToolName:
- // // var params agent.AgentParams
- // // json.Unmarshal([]byte(toolCall.Input), &params)
- // // prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
- // // return renderParams(paramWidth, prompt)
- // case "bash":
- // var params tools.BashParams
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // command := strings.ReplaceAll(params.Command, "\n", " ")
- // return renderParams(paramWidth, command)
- // case "edit":
- // var params tools.EditParams
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // filePath := removeWorkingDirPrefix(params.FilePath)
- // return renderParams(paramWidth, filePath)
- // case "fetch":
- // var params tools.FetchParams
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // url := params.URL
- // toolParams := []string{
- // url,
- // }
- // if params.Format != "" {
- // toolParams = append(toolParams, "format", params.Format)
- // }
- // if params.Timeout != 0 {
- // toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
- // }
- // return renderParams(paramWidth, toolParams...)
- // case tools.GlobToolName:
- // var params tools.GlobParams
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // pattern := params.Pattern
- // toolParams := []string{
- // pattern,
- // }
- // if params.Path != "" {
- // toolParams = append(toolParams, "path", params.Path)
- // }
- // return renderParams(paramWidth, toolParams...)
- // case tools.GrepToolName:
- // var params tools.GrepParams
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // pattern := params.Pattern
- // toolParams := []string{
- // pattern,
- // }
- // if params.Path != "" {
- // toolParams = append(toolParams, "path", params.Path)
- // }
- // if params.Include != "" {
- // toolParams = append(toolParams, "include", params.Include)
- // }
- // if params.LiteralText {
- // toolParams = append(toolParams, "literal", "true")
- // }
- // return renderParams(paramWidth, toolParams...)
- // case tools.LSToolName:
- // var params tools.LSParams
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // path := params.Path
- // if path == "" {
- // path = "."
- // }
- // return renderParams(paramWidth, path)
- // case tools.ViewToolName:
- // var params tools.ViewParams
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // filePath := removeWorkingDirPrefix(params.FilePath)
- // toolParams := []string{
- // filePath,
- // }
- // if params.Limit != 0 {
- // toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
- // }
- // if params.Offset != 0 {
- // toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
- // }
- // return renderParams(paramWidth, toolParams...)
- // case tools.WriteToolName:
- // var params tools.WriteParams
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // filePath := removeWorkingDirPrefix(params.FilePath)
- // return renderParams(paramWidth, filePath)
- // case tools.BatchToolName:
- // var params tools.BatchParams
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // return renderParams(paramWidth, fmt.Sprintf("%d parallel calls", len(params.Calls)))
- // default:
- // input := strings.ReplaceAll(toolCall, "\n", " ")
- // params = renderParams(paramWidth, input)
- }
- return params
-}
-
-func truncateHeight(content string, height int) string {
- lines := strings.Split(content, "\n")
- if len(lines) > height {
- return strings.Join(lines[:height], "\n")
- }
- return content
-}
-
-func renderToolResponse(toolCall any, response any, width int) string {
- return ""
- // t := theme.CurrentTheme()
- // baseStyle := styles.BaseStyle()
- //
- // if response.IsError {
- // errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
- // errContent = ansi.Truncate(errContent, width-1, "...")
- // return baseStyle.
- // Width(width).
- // Foreground(t.Error()).
- // Render(errContent)
- // }
- //
- // resultContent := truncateHeight(response.Content, maxResultHeight)
- // switch toolCall.Name {
- // case agent.AgentToolName:
- // return styles.ForceReplaceBackgroundWithLipgloss(
- // toMarkdown(resultContent, false, width),
- // t.Background(),
- // )
- // case tools.BashToolName:
- // resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
- // return styles.ForceReplaceBackgroundWithLipgloss(
- // toMarkdown(resultContent, width),
- // t.Background(),
- // )
- // case tools.EditToolName:
- // metadata := tools.EditResponseMetadata{}
- // json.Unmarshal([]byte(response.Metadata), &metadata)
- // formattedDiff, _ := diff.FormatDiff(metadata.Diff, diff.WithTotalWidth(width))
- // return formattedDiff
- // case tools.FetchToolName:
- // var params tools.FetchParams
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // mdFormat := "markdown"
- // switch params.Format {
- // case "text":
- // mdFormat = "text"
- // case "html":
- // mdFormat = "html"
- // }
- // resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
- // return styles.ForceReplaceBackgroundWithLipgloss(
- // toMarkdown(resultContent, width),
- // t.Background(),
- // )
- // case tools.GlobToolName:
- // return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
- // case tools.GrepToolName:
- // return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
- // case tools.LSToolName:
- // return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
- // case tools.ViewToolName:
- // metadata := tools.ViewResponseMetadata{}
- // json.Unmarshal([]byte(response.Metadata), &metadata)
- // ext := filepath.Ext(metadata.FilePath)
- // if ext == "" {
- // ext = ""
- // } else {
- // ext = strings.ToLower(ext[1:])
- // }
- // resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
- // return styles.ForceReplaceBackgroundWithLipgloss(
- // toMarkdown(resultContent, width),
- // t.Background(),
- // )
- // case tools.WriteToolName:
- // params := tools.WriteParams{}
- // json.Unmarshal([]byte(toolCall.Input), &params)
- // metadata := tools.WriteResponseMetadata{}
- // json.Unmarshal([]byte(response.Metadata), &metadata)
- // ext := filepath.Ext(params.FilePath)
- // if ext == "" {
- // ext = ""
- // } else {
- // ext = strings.ToLower(ext[1:])
- // }
- // resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
- // return styles.ForceReplaceBackgroundWithLipgloss(
- // toMarkdown(resultContent, width),
- // t.Background(),
- // )
- // case tools.BatchToolName:
- // var batchResult tools.BatchResult
- // if err := json.Unmarshal([]byte(resultContent), &batchResult); err != nil {
- // return baseStyle.Width(width).Foreground(t.Error()).Render(fmt.Sprintf("Error parsing batch result: %s", err))
- // }
- //
- // var toolCalls []string
- // for i, result := range batchResult.Results {
- // toolName := renderToolName(result.ToolName)
- //
- // // Format the tool input as a string
- // inputStr := string(result.ToolInput)
- //
- // // Format the result
- // var resultStr string
- // if result.Error != "" {
- // resultStr = fmt.Sprintf("Error: %s", result.Error)
- // } else {
- // var toolResponse tools.ToolResponse
- // if err := json.Unmarshal(result.Result, &toolResponse); err != nil {
- // resultStr = "Error parsing tool response"
- // } else {
- // resultStr = truncateHeight(toolResponse.Content, 3)
- // }
- // }
- //
- // // Format the tool call
- // toolCall := fmt.Sprintf("%d. %s: %s\n %s", i+1, toolName, inputStr, resultStr)
- // toolCalls = append(toolCalls, toolCall)
- // }
- //
- // return baseStyle.Width(width).Foreground(t.TextMuted()).Render(strings.Join(toolCalls, "\n\n"))
- // default:
- // resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
- // return styles.ForceReplaceBackgroundWithLipgloss(
- // toMarkdown(resultContent, width),
- // t.Background(),
- // )
- // }
-}
-
-// func renderToolMessage(
-// toolCall message.ToolCall,
-// allMessages []message.Message,
-// messagesService message.Service,
-// focusedUIMessageId string,
-// nested bool,
-// width int,
-// position int,
-// ) string {
-// if nested {
-// width = width - 3
-// }
-//
-// t := theme.CurrentTheme()
-// baseStyle := styles.BaseStyle()
-//
-// style := baseStyle.
-// Width(width - 1).
-// BorderLeft(true).
-// BorderStyle(lipgloss.ThickBorder()).
-// PaddingLeft(1).
-// BorderForeground(t.TextMuted())
-//
-// response := findToolResponse(toolCall.ID, allMessages)
-// toolNameText := baseStyle.Foreground(t.TextMuted()).
-// Render(fmt.Sprintf("%s: ", renderToolName(toolCall.Name)))
-//
-// if !toolCall.Finished {
-// // Get a brief description of what the tool is doing
-// toolAction := renderToolAction(toolCall.Name)
-//
-// progressText := baseStyle.
-// Width(width - 2 - lipgloss.Width(toolNameText)).
-// Foreground(t.TextMuted()).
-// Render(fmt.Sprintf("%s", toolAction))
-//
-// content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
-// return content
-// }
-//
-// params := renderToolParams(width-1-lipgloss.Width(toolNameText), toolCall)
-// responseContent := ""
-// if response != nil {
-// responseContent = renderToolResponse(toolCall, *response, width-2)
-// responseContent = strings.TrimSuffix(responseContent, "\n")
-// } else {
-// responseContent = baseStyle.
-// Italic(true).
-// Width(width - 2).
-// Foreground(t.TextMuted()).
-// Render("Waiting for response...")
-// }
-//
-// parts := []string{}
-// if !nested {
-// formattedParams := baseStyle.
-// Width(width - 2 - lipgloss.Width(toolNameText)).
-// Foreground(t.TextMuted()).
-// Render(params)
-//
-// parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
-// } else {
-// prefix := baseStyle.
-// Foreground(t.TextMuted()).
-// Render(" └ ")
-// formattedParams := baseStyle.
-// Width(width - 2 - lipgloss.Width(toolNameText)).
-// Foreground(t.TextMuted()).
-// Render(params)
-// parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
-// }
-//
-// // if toolCall.Name == agent.AgentToolName {
-// // taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
-// // toolCalls := []message.ToolCall{}
-// // for _, v := range taskMessages {
-// // toolCalls = append(toolCalls, v.ToolCalls()...)
-// // }
-// // for _, call := range toolCalls {
-// // rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
-// // parts = append(parts, rendered.content)
-// // }
-// // }
-// if responseContent != "" && !nested {
-// parts = append(parts, responseContent)
-// }
-//
-// content := style.Render(
-// lipgloss.JoinVertical(
-// lipgloss.Left,
-// parts...,
-// ),
-// )
-// if nested {
-// content = lipgloss.JoinVertical(
-// lipgloss.Left,
-// parts...,
-// )
-// }
-// return content
-// }
diff --git a/internal/tui/components/chat/messages.go b/internal/tui/components/chat/messages.go
deleted file mode 100644
index ae6f2a687..000000000
--- a/internal/tui/components/chat/messages.go
+++ /dev/null
@@ -1,344 +0,0 @@
-package chat
-
-import (
- "fmt"
- "time"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/spinner"
- "github.com/charmbracelet/bubbles/viewport"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/tui/app"
- "github.com/sst/opencode/internal/tui/components/dialog"
- "github.com/sst/opencode/internal/tui/state"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/pkg/client"
-)
-
-type messagesCmp struct {
- app *app.App
- width, height int
- viewport viewport.Model
- spinner spinner.Model
- rendering bool
- attachments viewport.Model
- showToolMessages bool
-}
-type renderFinishedMsg struct{}
-type ToggleToolMessagesMsg struct{}
-
-type MessageKeys struct {
- PageDown key.Binding
- PageUp key.Binding
- HalfPageUp key.Binding
- HalfPageDown key.Binding
-}
-
-var messageKeys = MessageKeys{
- PageDown: key.NewBinding(
- key.WithKeys("pgdown"),
- key.WithHelp("f/pgdn", "page down"),
- ),
- PageUp: key.NewBinding(
- key.WithKeys("pgup"),
- key.WithHelp("b/pgup", "page up"),
- ),
- HalfPageUp: key.NewBinding(
- key.WithKeys("ctrl+u"),
- key.WithHelp("ctrl+u", "½ page up"),
- ),
- HalfPageDown: key.NewBinding(
- key.WithKeys("ctrl+d", "ctrl+d"),
- key.WithHelp("ctrl+d", "½ page down"),
- ),
-}
-
-func (m *messagesCmp) Init() tea.Cmd {
- return tea.Batch(m.viewport.Init(), m.spinner.Tick)
-}
-
-func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case dialog.ThemeChangedMsg:
- m.renderView()
- return m, nil
- case ToggleToolMessagesMsg:
- m.showToolMessages = !m.showToolMessages
- m.renderView()
- return m, nil
- case state.SessionSelectedMsg:
- cmd := m.Reload()
- return m, cmd
- case state.SessionClearedMsg:
- cmd := m.Reload()
- return m, cmd
- case tea.KeyMsg:
- if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
- key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
- u, cmd := m.viewport.Update(msg)
- m.viewport = u
- cmds = append(cmds, cmd)
- }
- case renderFinishedMsg:
- m.rendering = false
- m.viewport.GotoBottom()
- case state.StateUpdatedMsg:
- m.renderView()
- m.viewport.GotoBottom()
- }
-
- spinner, cmd := m.spinner.Update(msg)
- m.spinner = spinner
- cmds = append(cmds, cmd)
- return m, tea.Batch(cmds...)
-}
-
-func (m *messagesCmp) renderView() {
- if m.width == 0 {
- return
- }
-
- messages := make([]string, 0)
- for _, msg := range m.app.Messages {
- switch msg.Role {
- case client.User:
- content := renderUserMessage(msg, m.width)
- messages = append(messages, content+"\n")
- case client.Assistant:
- content := renderAssistantMessage(msg, m.width, m.showToolMessages)
- messages = append(messages, content+"\n")
- }
- }
-
- m.viewport.SetContent(
- styles.BaseStyle().
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- messages...,
- ),
- ),
- )
-}
-
-func (m *messagesCmp) View() string {
- baseStyle := styles.BaseStyle()
-
- if m.rendering {
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- "Loading...",
- m.working(),
- m.help(),
- ),
- )
- }
-
- if len(m.app.Messages) == 0 {
- content := baseStyle.
- Width(m.width).
- Height(m.height - 1).
- Render(
- m.initialScreen(),
- )
-
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- content,
- "",
- m.help(),
- ),
- )
- }
-
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- m.viewport.View(),
- m.working(),
- m.help(),
- ),
- )
-}
-
-// func hasToolsWithoutResponse(messages []message.Message) bool {
-// toolCalls := make([]message.ToolCall, 0)
-// toolResults := make([]message.ToolResult, 0)
-// for _, m := range messages {
-// toolCalls = append(toolCalls, m.ToolCalls()...)
-// toolResults = append(toolResults, m.ToolResults()...)
-// }
-//
-// for _, v := range toolCalls {
-// found := false
-// for _, r := range toolResults {
-// if v.ID == r.ToolCallID {
-// found = true
-// break
-// }
-// }
-// if !found && v.Finished {
-// return true
-// }
-// }
-// return false
-// }
-
-// func hasUnfinishedToolCalls(messages []message.Message) bool {
-// toolCalls := make([]message.ToolCall, 0)
-// for _, m := range messages {
-// toolCalls = append(toolCalls, m.ToolCalls()...)
-// }
-// for _, v := range toolCalls {
-// if !v.Finished {
-// return true
-// }
-// }
-// return false
-// }
-
-func (m *messagesCmp) working() string {
- text := ""
- if len(m.app.Messages) > 0 {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- task := ""
-
- lastMessage := m.app.Messages[len(m.app.Messages)-1]
- if lastMessage.Metadata.Time.Completed == nil {
- task = "Working..."
- }
- // lastMessage := m.app.Messages[len(m.app.Messages)-1]
- // if hasToolsWithoutResponse(m.app.Messages) {
- // task = "Waiting for tool response..."
- // } else if hasUnfinishedToolCalls(m.app.Messages) {
- // task = "Building tool call..."
- // } else if !lastMessage.IsFinished() {
- // task = "Generating..."
- // }
- if task != "" {
- text += baseStyle.
- Width(m.width).
- Foreground(t.Primary()).
- Bold(true).
- Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
- }
- }
- return text
-}
-
-func (m *messagesCmp) help() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- text := ""
-
- if m.app.PrimaryAgentOLD.IsBusy() {
- text += lipgloss.JoinHorizontal(
- lipgloss.Left,
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
- baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to interrupt"),
- )
- } else {
- text += lipgloss.JoinHorizontal(
- lipgloss.Left,
- baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send,"),
- baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
- baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
- baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
- baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
- baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
- )
- }
- return baseStyle.
- Width(m.width).
- Render(text)
-}
-
-func (m *messagesCmp) initialScreen() string {
- baseStyle := styles.BaseStyle()
-
- return baseStyle.Width(m.width).Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(m.width),
- "",
- lspsConfigured(m.width),
- ),
- )
-}
-
-func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
- if m.width == width && m.height == height {
- return nil
- }
- m.width = width
- m.height = height
- m.viewport.Width = width
- m.viewport.Height = height - 2
- m.attachments.Width = width + 40
- m.attachments.Height = 3
- m.renderView()
- return nil
-}
-
-func (m *messagesCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func (m *messagesCmp) Reload() tea.Cmd {
- m.rendering = true
- return func() tea.Msg {
- m.renderView()
- return renderFinishedMsg{}
- }
-}
-
-func (m *messagesCmp) BindingKeys() []key.Binding {
- return []key.Binding{
- m.viewport.KeyMap.PageDown,
- m.viewport.KeyMap.PageUp,
- m.viewport.KeyMap.HalfPageUp,
- m.viewport.KeyMap.HalfPageDown,
- }
-}
-
-func NewMessagesCmp(app *app.App) tea.Model {
- customSpinner := spinner.Spinner{
- Frames: []string{" ", "┃", "┃"},
- FPS: time.Second / 3,
- }
- s := spinner.New(spinner.WithSpinner(customSpinner))
-
- vp := viewport.New(0, 0)
- attachments := viewport.New(0, 0)
- vp.KeyMap.PageUp = messageKeys.PageUp
- vp.KeyMap.PageDown = messageKeys.PageDown
- vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
- vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
-
- return &messagesCmp{
- app: app,
- viewport: vp,
- spinner: s,
- attachments: attachments,
- showToolMessages: true,
- }
-}
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
deleted file mode 100644
index d6895a164..000000000
--- a/internal/tui/components/chat/sidebar.go
+++ /dev/null
@@ -1,220 +0,0 @@
-package chat
-
-import (
- "fmt"
- "sort"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/config"
- "github.com/sst/opencode/internal/tui/app"
- "github.com/sst/opencode/internal/tui/state"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
-)
-
-type sidebarCmp struct {
- app *app.App
- width, height int
- modFiles map[string]struct {
- additions int
- removals int
- }
-}
-
-func (m *sidebarCmp) Init() tea.Cmd {
- // TODO: History service not implemented in API yet
- // Initialize the modified files map
- m.modFiles = make(map[string]struct {
- additions int
- removals int
- })
- return nil
-}
-
-func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg.(type) {
- case state.SessionSelectedMsg:
- // TODO: History service not implemented in API yet
- // ctx := context.Background()
- // m.loadModifiedFiles(ctx)
- // case pubsub.Event[history.File]:
- // TODO: History service not implemented in API yet
- // if msg.Payload.SessionID == m.app.CurrentSession.ID {
- // // Process the individual file change instead of reloading all files
- // ctx := context.Background()
- // m.processFileChanges(ctx, msg.Payload)
- // }
- }
- return m, nil
-}
-
-func (m *sidebarCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
- shareUrl := ""
- if m.app.Session.Share != nil {
- shareUrl = baseStyle.Foreground(t.TextMuted()).Render(m.app.Session.Share.Url)
- }
-
- // qrcode := ""
- // if m.app.Session.ShareID != nil {
- // url := "https://dev.opencode.ai/share?id="
- // qrcode, _, _ = qr.Generate(url + m.app.Session.Id)
- // }
-
- return baseStyle.
- Width(m.width).
- PaddingLeft(4).
- PaddingRight(1).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(m.width),
- " ",
- m.sessionSection(),
- shareUrl,
- ),
- )
-}
-
-func (m *sidebarCmp) sessionSection() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- sessionKey := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Render("Session")
-
- sessionValue := baseStyle.
- Foreground(t.Text()).
- Render(fmt.Sprintf(": %s", m.app.Session.Title))
-
- return sessionKey + sessionValue
-}
-
-func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- stats := ""
- if additions > 0 && removals > 0 {
- additionsStr := baseStyle.
- Foreground(t.Success()).
- PaddingLeft(1).
- Render(fmt.Sprintf("+%d", additions))
-
- removalsStr := baseStyle.
- Foreground(t.Error()).
- PaddingLeft(1).
- Render(fmt.Sprintf("-%d", removals))
-
- content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
- stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
- } else if additions > 0 {
- additionsStr := fmt.Sprintf(" %s", baseStyle.
- PaddingLeft(1).
- Foreground(t.Success()).
- Render(fmt.Sprintf("+%d", additions)))
- stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
- } else if removals > 0 {
- removalsStr := fmt.Sprintf(" %s", baseStyle.
- PaddingLeft(1).
- Foreground(t.Error()).
- Render(fmt.Sprintf("-%d", removals)))
- stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
- }
-
- filePathStr := baseStyle.Render(filePath)
-
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- filePathStr,
- stats,
- ),
- )
-}
-
-func (m *sidebarCmp) modifiedFiles() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- modifiedFiles := baseStyle.
- Width(m.width).
- Foreground(t.Primary()).
- Bold(true).
- Render("Modified Files:")
-
- // If no modified files, show a placeholder message
- if m.modFiles == nil || len(m.modFiles) == 0 {
- message := "No modified files"
- remainingWidth := m.width - lipgloss.Width(message)
- if remainingWidth > 0 {
- message += strings.Repeat(" ", remainingWidth)
- }
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- modifiedFiles,
- baseStyle.Foreground(t.TextMuted()).Render(message),
- ),
- )
- }
-
- // Sort file paths alphabetically for consistent ordering
- var paths []string
- for path := range m.modFiles {
- paths = append(paths, path)
- }
- sort.Strings(paths)
-
- // Create views for each file in sorted order
- var fileViews []string
- for _, path := range paths {
- stats := m.modFiles[path]
- fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
- }
-
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- modifiedFiles,
- lipgloss.JoinVertical(
- lipgloss.Left,
- fileViews...,
- ),
- ),
- )
-}
-
-func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
- m.width = width
- m.height = height
- return nil
-}
-
-func (m *sidebarCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func NewSidebarCmp(app *app.App) tea.Model {
- return &sidebarCmp{
- app: app,
- }
-}
-
-// Helper function to get the display path for a file
-func getDisplayPath(path string) string {
- workingDir := config.WorkingDirectory()
- displayPath := strings.TrimPrefix(path, workingDir)
- return strings.TrimPrefix(displayPath, "/")
-}
diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go
deleted file mode 100644
index 18a0ad6b1..000000000
--- a/internal/tui/components/core/status.go
+++ /dev/null
@@ -1,366 +0,0 @@
-package core
-
-import (
- "fmt"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/pubsub"
- "github.com/sst/opencode/internal/status"
- "github.com/sst/opencode/internal/tui/app"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
-)
-
-type StatusCmp interface {
- tea.Model
- SetHelpWidgetMsg(string)
-}
-
-type statusCmp struct {
- app *app.App
- queue []status.StatusMessage
- width int
- messageTTL time.Duration
- activeUntil time.Time
-}
-
-// clearMessageCmd is a command that clears status messages after a timeout
-func (m statusCmp) clearMessageCmd() tea.Cmd {
- return tea.Tick(time.Second, func(t time.Time) tea.Msg {
- return statusCleanupMsg{time: t}
- })
-}
-
-// statusCleanupMsg is a message that triggers cleanup of expired status messages
-type statusCleanupMsg struct {
- time time.Time
-}
-
-func (m statusCmp) Init() tea.Cmd {
- return m.clearMessageCmd()
-}
-
-func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.width = msg.Width
- return m, nil
- case pubsub.Event[status.StatusMessage]:
- if msg.Type == status.EventStatusPublished {
- // If this is a critical message, move it to the front of the queue
- if msg.Payload.Critical {
- // Insert at the front of the queue
- m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
-
- // Reset active time to show critical message immediately
- m.activeUntil = time.Time{}
- } else {
- // Otherwise, just add it to the queue
- m.queue = append(m.queue, msg.Payload)
-
- // If this is the first message and nothing is active, activate it immediately
- if len(m.queue) == 1 && m.activeUntil.IsZero() {
- now := time.Now()
- duration := m.messageTTL
- if msg.Payload.Duration > 0 {
- duration = msg.Payload.Duration
- }
- m.activeUntil = now.Add(duration)
- }
- }
- }
- case statusCleanupMsg:
- now := msg.time
-
- // If the active message has expired, remove it and activate the next one
- if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
- // Current message expired, remove it if we have one
- if len(m.queue) > 0 {
- m.queue = m.queue[1:]
- }
- m.activeUntil = time.Time{}
- }
-
- // If we have messages in queue but none are active, activate the first one
- if len(m.queue) > 0 && m.activeUntil.IsZero() {
- // Use custom duration if specified, otherwise use default
- duration := m.messageTTL
- if m.queue[0].Duration > 0 {
- duration = m.queue[0].Duration
- }
- m.activeUntil = now.Add(duration)
- }
-
- return m, m.clearMessageCmd()
- }
- return m, nil
-}
-
-var helpWidget = ""
-
-// getHelpWidget returns the help widget with current theme colors
-func getHelpWidget(helpText string) string {
- t := theme.CurrentTheme()
- if helpText == "" {
- helpText = "ctrl+? help"
- }
-
- return styles.Padded().
- Background(t.TextMuted()).
- Foreground(t.BackgroundDarker()).
- Bold(true).
- Render(helpText)
-}
-
-func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
- // Format tokens in human-readable format (e.g., 110K, 1.2M)
- var formattedTokens string
- switch {
- case tokens >= 1_000_000:
- formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
- case tokens >= 1_000:
- formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
- default:
- formattedTokens = fmt.Sprintf("%d", int(tokens))
- }
-
- // Remove .0 suffix if present
- if strings.HasSuffix(formattedTokens, ".0K") {
- formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
- }
- if strings.HasSuffix(formattedTokens, ".0M") {
- formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
- }
-
- // Format cost with $ symbol and 2 decimal places
- formattedCost := fmt.Sprintf("$%.2f", cost)
-
- percentage := (float64(tokens) / float64(contextWindow)) * 100
-
- return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
-}
-
-func (m statusCmp) View() string {
- t := theme.CurrentTheme()
- // modelID := config.Get().Agents[config.AgentPrimary].Model
- // model := models.SupportedModels[modelID]
-
- // Initialize the help widget
- status := getHelpWidget("")
-
- if m.app.Session.Id != "" {
- tokens := float32(0)
- cost := float32(0)
- contextWindow := float32(200_000) // TODO: Get context window from model
-
- for _, message := range m.app.Messages {
- if message.Metadata.Assistant != nil {
- cost += message.Metadata.Assistant.Cost
- usage := message.Metadata.Assistant.Tokens
- tokens += (usage.Input + usage.Output + usage.Reasoning)
- }
- }
-
- tokensInfo := styles.Padded().
- Background(t.Text()).
- Foreground(t.BackgroundSecondary()).
- Render(formatTokensAndCost(tokens, contextWindow, cost))
- status += tokensInfo
- }
-
- diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics())
-
- modelName := m.model()
-
- statusWidth := max(
- 0,
- m.width-
- lipgloss.Width(status)-
- lipgloss.Width(modelName)-
- lipgloss.Width(diagnostics),
- )
-
- const minInlineWidth = 30
-
- // Display the first status message if available
- var statusMessage string
- if len(m.queue) > 0 {
- sm := m.queue[0]
- infoStyle := styles.Padded().
- Foreground(t.Background())
-
- switch sm.Level {
- case "info":
- infoStyle = infoStyle.Background(t.Info())
- case "warn":
- infoStyle = infoStyle.Background(t.Warning())
- case "error":
- infoStyle = infoStyle.Background(t.Error())
- case "debug":
- infoStyle = infoStyle.Background(t.TextMuted())
- }
-
- // Truncate message if it's longer than available width
- msg := sm.Message
- availWidth := statusWidth - 10
-
- // If we have enough space, show inline
- if availWidth >= minInlineWidth {
- if len(msg) > availWidth && availWidth > 0 {
- msg = msg[:availWidth] + "..."
- }
- status += infoStyle.Width(statusWidth).Render(msg)
- } else {
- // Otherwise, prepare a full-width message to show above
- if len(msg) > m.width-10 && m.width > 10 {
- msg = msg[:m.width-10] + "..."
- }
- statusMessage = infoStyle.Width(m.width).Render(msg)
-
- // Add empty space in the status bar
- status += styles.Padded().
- Foreground(t.Text()).
- Background(t.BackgroundSecondary()).
- Width(statusWidth).
- Render("")
- }
- } else {
- status += styles.Padded().
- Foreground(t.Text()).
- Background(t.BackgroundSecondary()).
- Width(statusWidth).
- Render("")
- }
-
- status += diagnostics
- status += modelName
-
- // If we have a separate status message, prepend it
- if statusMessage != "" {
- return statusMessage + "\n" + status
- } else {
- blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
- return blank + "\n" + status
- }
-}
-
-func (m *statusCmp) projectDiagnostics() string {
- t := theme.CurrentTheme()
-
- // Check if any LSP server is still initializing
- initializing := false
- // for _, client := range m.app.LSPClients {
- // if client.GetServerState() == lsp.StateStarting {
- // initializing = true
- // break
- // }
- // }
-
- // If any server is initializing, show that status
- if initializing {
- return lipgloss.NewStyle().
- Foreground(t.Warning()).
- Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
- }
-
- // errorDiagnostics := []protocol.Diagnostic{}
- // warnDiagnostics := []protocol.Diagnostic{}
- // hintDiagnostics := []protocol.Diagnostic{}
- // infoDiagnostics := []protocol.Diagnostic{}
- // for _, client := range m.app.LSPClients {
- // for _, d := range client.GetDiagnostics() {
- // for _, diag := range d {
- // switch diag.Severity {
- // case protocol.SeverityError:
- // errorDiagnostics = append(errorDiagnostics, diag)
- // case protocol.SeverityWarning:
- // warnDiagnostics = append(warnDiagnostics, diag)
- // case protocol.SeverityHint:
- // hintDiagnostics = append(hintDiagnostics, diag)
- // case protocol.SeverityInformation:
- // infoDiagnostics = append(infoDiagnostics, diag)
- // }
- // }
- // }
- // }
- return styles.ForceReplaceBackgroundWithLipgloss(
- styles.Padded().Render("No diagnostics"),
- t.BackgroundDarker(),
- )
-
- // if len(errorDiagnostics) == 0 &&
- // len(warnDiagnostics) == 0 &&
- // len(infoDiagnostics) == 0 &&
- // len(hintDiagnostics) == 0 {
- // return styles.ForceReplaceBackgroundWithLipgloss(
- // styles.Padded().Render("No diagnostics"),
- // t.BackgroundDarker(),
- // )
- // }
-
- // diagnostics := []string{}
- //
- // errStr := lipgloss.NewStyle().
- // Background(t.BackgroundDarker()).
- // Foreground(t.Error()).
- // Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
- // diagnostics = append(diagnostics, errStr)
- //
- // warnStr := lipgloss.NewStyle().
- // Background(t.BackgroundDarker()).
- // Foreground(t.Warning()).
- // Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
- // diagnostics = append(diagnostics, warnStr)
- //
- // infoStr := lipgloss.NewStyle().
- // Background(t.BackgroundDarker()).
- // Foreground(t.Info()).
- // Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
- // diagnostics = append(diagnostics, infoStr)
- //
- // hintStr := lipgloss.NewStyle().
- // Background(t.BackgroundDarker()).
- // Foreground(t.Text()).
- // Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
- // diagnostics = append(diagnostics, hintStr)
- //
- // return styles.ForceReplaceBackgroundWithLipgloss(
- // styles.Padded().Render(strings.Join(diagnostics, " ")),
- // t.BackgroundDarker(),
- // )
-}
-
-func (m statusCmp) model() string {
- t := theme.CurrentTheme()
- model := "None"
- if m.app.Model != nil {
- model = *m.app.Model.Name
- }
-
- return styles.Padded().
- Background(t.Secondary()).
- Foreground(t.Background()).
- Render(model)
-}
-
-func (m statusCmp) SetHelpWidgetMsg(s string) {
- // Update the help widget text using the getHelpWidget function
- helpWidget = getHelpWidget(s)
-}
-
-func NewStatusCmp(app *app.App) StatusCmp {
- // Initialize the help widget with default text
- helpWidget = getHelpWidget("")
-
- statusComponent := &statusCmp{
- app: app,
- queue: []status.StatusMessage{},
- messageTTL: 4 * time.Second,
- activeUntil: time.Time{},
- }
-
- return statusComponent
-}
diff --git a/internal/tui/components/dialog/arguments.go b/internal/tui/components/dialog/arguments.go
deleted file mode 100644
index fed79bce3..000000000
--- a/internal/tui/components/dialog/arguments.go
+++ /dev/null
@@ -1,257 +0,0 @@
-package dialog
-
-import (
- "fmt"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/internal/tui/util"
-)
-
-type argumentsDialogKeyMap struct {
- Enter key.Binding
- Escape key.Binding
-}
-
-// ShortHelp implements key.Map.
-func (k argumentsDialogKeyMap) ShortHelp() []key.Binding {
- return []key.Binding{
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "confirm"),
- ),
- key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
- }
-}
-
-// FullHelp implements key.Map.
-func (k argumentsDialogKeyMap) FullHelp() [][]key.Binding {
- return [][]key.Binding{k.ShortHelp()}
-}
-
-// ShowMultiArgumentsDialogMsg is a message that is sent to show the multi-arguments dialog.
-type ShowMultiArgumentsDialogMsg struct {
- CommandID string
- Content string
- ArgNames []string
-}
-
-// CloseMultiArgumentsDialogMsg is a message that is sent when the multi-arguments dialog is closed.
-type CloseMultiArgumentsDialogMsg struct {
- Submit bool
- CommandID string
- Content string
- Args map[string]string
-}
-
-// MultiArgumentsDialogCmp is a component that asks the user for multiple command arguments.
-type MultiArgumentsDialogCmp struct {
- width, height int
- inputs []textinput.Model
- focusIndex int
- keys argumentsDialogKeyMap
- commandID string
- content string
- argNames []string
-}
-
-// NewMultiArgumentsDialogCmp creates a new MultiArgumentsDialogCmp.
-func NewMultiArgumentsDialogCmp(commandID, content string, argNames []string) MultiArgumentsDialogCmp {
- t := theme.CurrentTheme()
- inputs := make([]textinput.Model, len(argNames))
-
- for i, name := range argNames {
- ti := textinput.New()
- ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
- ti.Width = 40
- ti.Prompt = ""
- ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background())
- ti.PromptStyle = ti.PromptStyle.Background(t.Background())
- ti.TextStyle = ti.TextStyle.Background(t.Background())
-
- // Only focus the first input initially
- if i == 0 {
- ti.Focus()
- ti.PromptStyle = ti.PromptStyle.Foreground(t.Primary())
- ti.TextStyle = ti.TextStyle.Foreground(t.Primary())
- } else {
- ti.Blur()
- }
-
- inputs[i] = ti
- }
-
- return MultiArgumentsDialogCmp{
- inputs: inputs,
- keys: argumentsDialogKeyMap{},
- commandID: commandID,
- content: content,
- argNames: argNames,
- focusIndex: 0,
- }
-}
-
-// Init implements tea.Model.
-func (m MultiArgumentsDialogCmp) Init() tea.Cmd {
- // Make sure only the first input is focused
- for i := range m.inputs {
- if i == 0 {
- m.inputs[i].Focus()
- } else {
- m.inputs[i].Blur()
- }
- }
-
- return textinput.Blink
-}
-
-// Update implements tea.Model.
-func (m MultiArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- t := theme.CurrentTheme()
-
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
- return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
- Submit: false,
- CommandID: m.commandID,
- Content: m.content,
- Args: nil,
- })
- case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
- // If we're on the last input, submit the form
- if m.focusIndex == len(m.inputs)-1 {
- args := make(map[string]string)
- for i, name := range m.argNames {
- args[name] = m.inputs[i].Value()
- }
- return m, util.CmdHandler(CloseMultiArgumentsDialogMsg{
- Submit: true,
- CommandID: m.commandID,
- Content: m.content,
- Args: args,
- })
- }
- // Otherwise, move to the next input
- m.inputs[m.focusIndex].Blur()
- m.focusIndex++
- m.inputs[m.focusIndex].Focus()
- m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
- m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
- case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
- // Move to the next input
- m.inputs[m.focusIndex].Blur()
- m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
- m.inputs[m.focusIndex].Focus()
- m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
- m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
- case key.Matches(msg, key.NewBinding(key.WithKeys("shift+tab"))):
- // Move to the previous input
- m.inputs[m.focusIndex].Blur()
- m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs)
- m.inputs[m.focusIndex].Focus()
- m.inputs[m.focusIndex].PromptStyle = m.inputs[m.focusIndex].PromptStyle.Foreground(t.Primary())
- m.inputs[m.focusIndex].TextStyle = m.inputs[m.focusIndex].TextStyle.Foreground(t.Primary())
- }
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.height = msg.Height
- }
-
- // Update the focused input
- var cmd tea.Cmd
- m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
- cmds = append(cmds, cmd)
-
- return m, tea.Batch(cmds...)
-}
-
-// View implements tea.Model.
-func (m MultiArgumentsDialogCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- // Calculate width needed for content
- maxWidth := 60 // Width for explanation text
-
- title := lipgloss.NewStyle().
- Foreground(t.Primary()).
- Bold(true).
- Width(maxWidth).
- Padding(0, 1).
- Background(t.Background()).
- Render("Command Arguments")
-
- explanation := lipgloss.NewStyle().
- Foreground(t.Text()).
- Width(maxWidth).
- Padding(0, 1).
- Background(t.Background()).
- Render("This command requires multiple arguments. Please enter values for each:")
-
- // Create input fields for each argument
- inputFields := make([]string, len(m.inputs))
- for i, input := range m.inputs {
- // Highlight the label of the focused input
- labelStyle := lipgloss.NewStyle().
- Width(maxWidth).
- Padding(1, 1, 0, 1).
- Background(t.Background())
-
- if i == m.focusIndex {
- labelStyle = labelStyle.Foreground(t.Primary()).Bold(true)
- } else {
- labelStyle = labelStyle.Foreground(t.TextMuted())
- }
-
- label := labelStyle.Render(m.argNames[i] + ":")
-
- field := lipgloss.NewStyle().
- Foreground(t.Text()).
- Width(maxWidth).
- Padding(0, 1).
- Background(t.Background()).
- Render(input.View())
-
- inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
- }
-
- maxWidth = min(maxWidth, m.width-10)
-
- // Join all elements vertically
- elements := []string{title, explanation}
- elements = append(elements, inputFields...)
-
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- elements...,
- )
-
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Background(t.Background()).
- Width(lipgloss.Width(content) + 4).
- Render(content)
-}
-
-// SetSize sets the size of the component.
-func (m *MultiArgumentsDialogCmp) SetSize(width, height int) {
- m.width = width
- m.height = height
-}
-
-// Bindings implements layout.Bindings.
-func (m MultiArgumentsDialogCmp) Bindings() []key.Binding {
- return m.keys.ShortHelp()
-}
diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go
deleted file mode 100644
index b989154c6..000000000
--- a/internal/tui/components/dialog/commands.go
+++ /dev/null
@@ -1,180 +0,0 @@
-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"
- "github.com/sst/opencode/internal/tui/util"
-)
-
-// Command represents a command that can be executed
-type Command struct {
- ID string
- Title string
- Description string
- Handler func(cmd Command) tea.Cmd
-}
-
-func (ci Command) Render(selected bool, width int) string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- descStyle := baseStyle.Width(width).Foreground(t.TextMuted())
- itemStyle := baseStyle.Width(width).
- Foreground(t.Text()).
- Background(t.Background())
-
- if selected {
- itemStyle = itemStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
- Bold(true)
- descStyle = descStyle.
- Background(t.Primary()).
- Foreground(t.Background())
- }
-
- title := itemStyle.Padding(0, 1).Render(ci.Title)
- if ci.Description != "" {
- description := descStyle.Padding(0, 1).Render(ci.Description)
- return lipgloss.JoinVertical(lipgloss.Left, title, description)
- }
- return title
-}
-
-// CommandSelectedMsg is sent when a command is selected
-type CommandSelectedMsg struct {
- Command Command
-}
-
-// CloseCommandDialogMsg is sent when the command dialog is closed
-type CloseCommandDialogMsg struct{}
-
-// CommandDialog interface for the command selection dialog
-type CommandDialog interface {
- tea.Model
- layout.Bindings
- SetCommands(commands []Command)
-}
-
-type commandDialogCmp struct {
- listView utilComponents.SimpleList[Command]
- width int
- height int
-}
-
-type commandKeyMap struct {
- Enter key.Binding
- Escape key.Binding
-}
-
-var commandKeys = commandKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select command"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
-}
-
-func (c *commandDialogCmp) Init() tea.Cmd {
- return c.listView.Init()
-}
-
-func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, commandKeys.Enter):
- selectedItem, idx := c.listView.GetSelectedItem()
- if idx != -1 {
- return c, util.CmdHandler(CommandSelectedMsg{
- Command: selectedItem,
- })
- }
- case key.Matches(msg, commandKeys.Escape):
- return c, util.CmdHandler(CloseCommandDialogMsg{})
- }
- case tea.WindowSizeMsg:
- c.width = msg.Width
- c.height = msg.Height
- }
-
- u, cmd := c.listView.Update(msg)
- c.listView = u.(utilComponents.SimpleList[Command])
- cmds = append(cmds, cmd)
-
- return c, tea.Batch(cmds...)
-}
-
-func (c *commandDialogCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- maxWidth := 40
-
- commands := c.listView.GetItems()
-
- for _, cmd := range commands {
- if len(cmd.Title) > maxWidth-4 {
- maxWidth = len(cmd.Title) + 4
- }
- if cmd.Description != "" {
- if len(cmd.Description) > maxWidth-4 {
- maxWidth = len(cmd.Description) + 4
- }
- }
- }
-
- c.listView.SetMaxWidth(maxWidth)
-
- title := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Width(maxWidth).
- Padding(0, 1).
- Render("Commands")
-
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- title,
- baseStyle.Width(maxWidth).Render(""),
- baseStyle.Width(maxWidth).Render(c.listView.View()),
- baseStyle.Width(maxWidth).Render(""),
- )
-
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(lipgloss.Width(content) + 4).
- Render(content)
-}
-
-func (c *commandDialogCmp) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(commandKeys)
-}
-
-func (c *commandDialogCmp) SetCommands(commands []Command) {
- c.listView.SetItems(commands)
-}
-
-// NewCommandDialogCmp creates a new command selection dialog
-func NewCommandDialogCmp() CommandDialog {
- listView := utilComponents.NewSimpleList[Command](
- []Command{},
- 10,
- "No commands available",
- true,
- )
- return &commandDialogCmp{
- listView: listView,
- }
-}
diff --git a/internal/tui/components/dialog/complete.go b/internal/tui/components/dialog/complete.go
deleted file mode 100644
index 57193d00c..000000000
--- a/internal/tui/components/dialog/complete.go
+++ /dev/null
@@ -1,263 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/textarea"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/status"
- 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"
- "github.com/sst/opencode/internal/tui/util"
-)
-
-type CompletionItem struct {
- title string
- Title string
- Value string
-}
-
-type CompletionItemI interface {
- utilComponents.SimpleListItem
- GetValue() string
- DisplayValue() string
-}
-
-func (ci *CompletionItem) Render(selected bool, width int) string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- itemStyle := baseStyle.
- Width(width).
- Padding(0, 1)
-
- if selected {
- itemStyle = itemStyle.
- Background(t.Background()).
- Foreground(t.Primary()).
- Bold(true)
- }
-
- title := itemStyle.Render(
- ci.GetValue(),
- )
-
- return title
-}
-
-func (ci *CompletionItem) DisplayValue() string {
- return ci.Title
-}
-
-func (ci *CompletionItem) GetValue() string {
- return ci.Value
-}
-
-func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
- return &completionItem
-}
-
-type CompletionProvider interface {
- GetId() string
- GetEntry() CompletionItemI
- GetChildEntries(query string) ([]CompletionItemI, error)
-}
-
-type CompletionSelectedMsg struct {
- SearchString string
- CompletionValue string
-}
-
-type CompletionDialogCompleteItemMsg struct {
- Value string
-}
-
-type CompletionDialogCloseMsg struct{}
-
-type CompletionDialog interface {
- tea.Model
- layout.Bindings
- SetWidth(width int)
-}
-
-type completionDialogCmp struct {
- query string
- completionProvider CompletionProvider
- width int
- height int
- pseudoSearchTextArea textarea.Model
- listView utilComponents.SimpleList[CompletionItemI]
-}
-
-type completionDialogKeyMap struct {
- Complete key.Binding
- Cancel key.Binding
-}
-
-var completionDialogKeys = completionDialogKeyMap{
- Complete: key.NewBinding(
- key.WithKeys("tab", "enter"),
- ),
- Cancel: key.NewBinding(
- key.WithKeys(" ", "esc", "backspace"),
- ),
-}
-
-func (c *completionDialogCmp) Init() tea.Cmd {
- return nil
-}
-
-func (c *completionDialogCmp) complete(item CompletionItemI) tea.Cmd {
- value := c.pseudoSearchTextArea.Value()
-
- if value == "" {
- return nil
- }
-
- return tea.Batch(
- util.CmdHandler(CompletionSelectedMsg{
- SearchString: value,
- CompletionValue: item.GetValue(),
- }),
- c.close(),
- )
-}
-
-func (c *completionDialogCmp) close() tea.Cmd {
- c.listView.SetItems([]CompletionItemI{})
- c.pseudoSearchTextArea.Reset()
- c.pseudoSearchTextArea.Blur()
-
- return util.CmdHandler(CompletionDialogCloseMsg{})
-}
-
-func (c *completionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case tea.KeyMsg:
- if c.pseudoSearchTextArea.Focused() {
-
- if !key.Matches(msg, completionDialogKeys.Complete) {
-
- var cmd tea.Cmd
- c.pseudoSearchTextArea, cmd = c.pseudoSearchTextArea.Update(msg)
- cmds = append(cmds, cmd)
-
- var query string
- query = c.pseudoSearchTextArea.Value()
- if query != "" {
- query = query[1:]
- }
-
- if query != c.query {
- items, err := c.completionProvider.GetChildEntries(query)
- if err != nil {
- status.Error(err.Error())
- }
-
- c.listView.SetItems(items)
- c.query = query
- }
-
- u, cmd := c.listView.Update(msg)
- c.listView = u.(utilComponents.SimpleList[CompletionItemI])
-
- cmds = append(cmds, cmd)
- }
-
- switch {
- case key.Matches(msg, completionDialogKeys.Complete):
- item, i := c.listView.GetSelectedItem()
- if i == -1 {
- return c, nil
- }
-
- cmd := c.complete(item)
-
- return c, cmd
- case key.Matches(msg, completionDialogKeys.Cancel):
- // Only close on backspace when there are no characters left
- if msg.String() != "backspace" || len(c.pseudoSearchTextArea.Value()) <= 0 {
- return c, c.close()
- }
- }
-
- return c, tea.Batch(cmds...)
- } else {
- items, err := c.completionProvider.GetChildEntries("")
- if err != nil {
- status.Error(err.Error())
- }
-
- c.listView.SetItems(items)
- c.pseudoSearchTextArea.SetValue(msg.String())
- return c, c.pseudoSearchTextArea.Focus()
- }
- case tea.WindowSizeMsg:
- c.width = msg.Width
- c.height = msg.Height
- }
-
- return c, tea.Batch(cmds...)
-}
-
-func (c *completionDialogCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- maxWidth := 40
-
- completions := c.listView.GetItems()
-
- for _, cmd := range completions {
- title := cmd.DisplayValue()
- if len(title) > maxWidth-4 {
- maxWidth = len(title) + 4
- }
- }
-
- c.listView.SetMaxWidth(maxWidth)
-
- return baseStyle.Padding(0, 0).
- Border(lipgloss.NormalBorder()).
- BorderBottom(false).
- BorderRight(false).
- BorderLeft(false).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(c.width).
- Render(c.listView.View())
-}
-
-func (c *completionDialogCmp) SetWidth(width int) {
- c.width = width
-}
-
-func (c *completionDialogCmp) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(completionDialogKeys)
-}
-
-func NewCompletionDialogCmp(completionProvider CompletionProvider) CompletionDialog {
- ti := textarea.New()
-
- items, err := completionProvider.GetChildEntries("")
- if err != nil {
- status.Error(err.Error())
- }
-
- li := utilComponents.NewSimpleList(
- items,
- 7,
- "No file matches found",
- false,
- )
-
- return &completionDialogCmp{
- query: "",
- completionProvider: completionProvider,
- pseudoSearchTextArea: ti,
- listView: li,
- }
-}
diff --git a/internal/tui/components/dialog/custom_commands.go b/internal/tui/components/dialog/custom_commands.go
deleted file mode 100644
index be6746feb..000000000
--- a/internal/tui/components/dialog/custom_commands.go
+++ /dev/null
@@ -1,186 +0,0 @@
-package dialog
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "regexp"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea"
- "github.com/sst/opencode/internal/config"
- "github.com/sst/opencode/internal/tui/util"
-)
-
-// Command prefix constants
-const (
- UserCommandPrefix = "user:"
- ProjectCommandPrefix = "project:"
-)
-
-// namedArgPattern is a regex pattern to find named arguments in the format $NAME
-var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
-
-// LoadCustomCommands loads custom commands from both XDG_CONFIG_HOME and project data directory
-func LoadCustomCommands() ([]Command, error) {
- cfg := config.Get()
- if cfg == nil {
- return nil, fmt.Errorf("config not loaded")
- }
-
- var commands []Command
-
- // Load user commands from XDG_CONFIG_HOME/opencode/commands
- xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
- if xdgConfigHome == "" {
- // Default to ~/.config if XDG_CONFIG_HOME is not set
- home, err := os.UserHomeDir()
- if err == nil {
- xdgConfigHome = filepath.Join(home, ".config")
- }
- }
-
- if xdgConfigHome != "" {
- userCommandsDir := filepath.Join(xdgConfigHome, "opencode", "commands")
- userCommands, err := loadCommandsFromDir(userCommandsDir, UserCommandPrefix)
- if err != nil {
- // Log error but continue - we'll still try to load other commands
- fmt.Printf("Warning: failed to load user commands from XDG_CONFIG_HOME: %v\n", err)
- } else {
- commands = append(commands, userCommands...)
- }
- }
-
- // Load commands from $HOME/.opencode/commands
- home, err := os.UserHomeDir()
- if err == nil {
- homeCommandsDir := filepath.Join(home, ".opencode", "commands")
- homeCommands, err := loadCommandsFromDir(homeCommandsDir, UserCommandPrefix)
- if err != nil {
- // Log error but continue - we'll still try to load other commands
- fmt.Printf("Warning: failed to load home commands: %v\n", err)
- } else {
- commands = append(commands, homeCommands...)
- }
- }
-
- // Load project commands from data directory
- projectCommandsDir := filepath.Join(cfg.Data.Directory, "commands")
- projectCommands, err := loadCommandsFromDir(projectCommandsDir, ProjectCommandPrefix)
- if err != nil {
- // Log error but return what we have so far
- fmt.Printf("Warning: failed to load project commands: %v\n", err)
- } else {
- commands = append(commands, projectCommands...)
- }
-
- return commands, nil
-}
-
-// loadCommandsFromDir loads commands from a specific directory with the given prefix
-func loadCommandsFromDir(commandsDir string, prefix string) ([]Command, error) {
- // Check if the commands directory exists
- if _, err := os.Stat(commandsDir); os.IsNotExist(err) {
- // Create the commands directory if it doesn't exist
- if err := os.MkdirAll(commandsDir, 0755); err != nil {
- return nil, fmt.Errorf("failed to create commands directory %s: %w", commandsDir, err)
- }
- // Return empty list since we just created the directory
- return []Command{}, nil
- }
-
- var commands []Command
-
- // Walk through the commands directory and load all .md files
- err := filepath.Walk(commandsDir, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
-
- // Skip directories
- if info.IsDir() {
- return nil
- }
-
- // Only process markdown files
- if !strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
- return nil
- }
-
- // Read the file content
- content, err := os.ReadFile(path)
- if err != nil {
- return fmt.Errorf("failed to read command file %s: %w", path, err)
- }
-
- // Get the command ID from the file name without the .md extension
- commandID := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
-
- // Get relative path from commands directory
- relPath, err := filepath.Rel(commandsDir, path)
- if err != nil {
- return fmt.Errorf("failed to get relative path for %s: %w", path, err)
- }
-
- // Create the command ID from the relative path
- // Replace directory separators with colons
- commandIDPath := strings.ReplaceAll(filepath.Dir(relPath), string(filepath.Separator), ":")
- if commandIDPath != "." {
- commandID = commandIDPath + ":" + commandID
- }
-
- // Create a command
- command := Command{
- ID: prefix + commandID,
- Title: prefix + commandID,
- Description: fmt.Sprintf("Custom command from %s", relPath),
- Handler: func(cmd Command) tea.Cmd {
- commandContent := string(content)
-
- // Check for named arguments
- matches := namedArgPattern.FindAllStringSubmatch(commandContent, -1)
- if len(matches) > 0 {
- // Extract unique argument names
- argNames := make([]string, 0)
- argMap := make(map[string]bool)
-
- for _, match := range matches {
- argName := match[1] // Group 1 is the name without $
- if !argMap[argName] {
- argMap[argName] = true
- argNames = append(argNames, argName)
- }
- }
-
- // Show multi-arguments dialog for all named arguments
- return util.CmdHandler(ShowMultiArgumentsDialogMsg{
- CommandID: cmd.ID,
- Content: commandContent,
- ArgNames: argNames,
- })
- }
-
- // No arguments needed, run command directly
- return util.CmdHandler(CommandRunCustomMsg{
- Content: commandContent,
- Args: nil, // No arguments
- })
- },
- }
-
- commands = append(commands, command)
- return nil
- })
-
- if err != nil {
- return nil, fmt.Errorf("failed to load custom commands from %s: %w", commandsDir, err)
- }
-
- return commands, nil
-}
-
-// CommandRunCustomMsg is sent when a custom command is executed
-type CommandRunCustomMsg struct {
- Content string
- Args map[string]string // Map of argument names to values
-}
diff --git a/internal/tui/components/dialog/custom_commands_test.go b/internal/tui/components/dialog/custom_commands_test.go
deleted file mode 100644
index 3468ac3b0..000000000
--- a/internal/tui/components/dialog/custom_commands_test.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package dialog
-
-import (
- "testing"
- "regexp"
-)
-
-func TestNamedArgPattern(t *testing.T) {
- testCases := []struct {
- input string
- expected []string
- }{
- {
- input: "This is a test with $ARGUMENTS placeholder",
- expected: []string{"ARGUMENTS"},
- },
- {
- input: "This is a test with $FOO and $BAR placeholders",
- expected: []string{"FOO", "BAR"},
- },
- {
- input: "This is a test with $FOO_BAR and $BAZ123 placeholders",
- expected: []string{"FOO_BAR", "BAZ123"},
- },
- {
- input: "This is a test with no placeholders",
- expected: []string{},
- },
- {
- input: "This is a test with $FOO appearing twice: $FOO",
- expected: []string{"FOO"},
- },
- {
- input: "This is a test with $1INVALID placeholder",
- expected: []string{},
- },
- }
-
- for _, tc := range testCases {
- matches := namedArgPattern.FindAllStringSubmatch(tc.input, -1)
-
- // Extract unique argument names
- argNames := make([]string, 0)
- argMap := make(map[string]bool)
-
- for _, match := range matches {
- argName := match[1] // Group 1 is the name without $
- if !argMap[argName] {
- argMap[argName] = true
- argNames = append(argNames, argName)
- }
- }
-
- // Check if we got the expected number of arguments
- if len(argNames) != len(tc.expected) {
- t.Errorf("Expected %d arguments, got %d for input: %s", len(tc.expected), len(argNames), tc.input)
- continue
- }
-
- // Check if we got the expected argument names
- for _, expectedArg := range tc.expected {
- found := false
- for _, actualArg := range argNames {
- if actualArg == expectedArg {
- found = true
- break
- }
- }
- if !found {
- t.Errorf("Expected argument %s not found in %v for input: %s", expectedArg, argNames, tc.input)
- }
- }
- }
-}
-
-func TestRegexPattern(t *testing.T) {
- pattern := regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
-
- validMatches := []string{
- "$FOO",
- "$BAR",
- "$FOO_BAR",
- "$BAZ123",
- "$ARGUMENTS",
- }
-
- invalidMatches := []string{
- "$foo",
- "$1BAR",
- "$_FOO",
- "FOO",
- "$",
- }
-
- for _, valid := range validMatches {
- if !pattern.MatchString(valid) {
- t.Errorf("Expected %s to match, but it didn't", valid)
- }
- }
-
- for _, invalid := range invalidMatches {
- if pattern.MatchString(invalid) {
- t.Errorf("Expected %s not to match, but it did", invalid)
- }
- }
-} \ No newline at end of file
diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go
deleted file mode 100644
index 088e205f2..000000000
--- a/internal/tui/components/dialog/filepicker.go
+++ /dev/null
@@ -1,485 +0,0 @@
-package dialog
-
-import (
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "log/slog"
-
- "github.com/atotto/clipboard"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/textinput"
- "github.com/charmbracelet/bubbles/viewport"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/status"
- "github.com/sst/opencode/internal/tui/app"
- "github.com/sst/opencode/internal/tui/image"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/internal/tui/util"
-)
-
-const (
- maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
- downArrow = "down"
- upArrow = "up"
-)
-
-type FilePrickerKeyMap struct {
- Enter key.Binding
- Down key.Binding
- Up key.Binding
- Forward key.Binding
- Backward key.Binding
- OpenFilePicker key.Binding
- Esc key.Binding
- InsertCWD key.Binding
- Paste key.Binding
-}
-
-var filePickerKeyMap = FilePrickerKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select file/enter directory"),
- ),
- Down: key.NewBinding(
- key.WithKeys("j", downArrow),
- key.WithHelp("↓/j", "down"),
- ),
- Up: key.NewBinding(
- key.WithKeys("k", upArrow),
- key.WithHelp("↑/k", "up"),
- ),
- Forward: key.NewBinding(
- key.WithKeys("l"),
- key.WithHelp("l", "enter directory"),
- ),
- Backward: key.NewBinding(
- key.WithKeys("h", "backspace"),
- key.WithHelp("h/backspace", "go back"),
- ),
- OpenFilePicker: key.NewBinding(
- key.WithKeys("ctrl+f"),
- key.WithHelp("ctrl+f", "open file picker"),
- ),
- Esc: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close/exit"),
- ),
- InsertCWD: key.NewBinding(
- key.WithKeys("i"),
- key.WithHelp("i", "manual path input"),
- ),
- Paste: key.NewBinding(
- key.WithKeys("ctrl+v"),
- key.WithHelp("ctrl+v", "paste file/directory path"),
- ),
-}
-
-type filepickerCmp struct {
- basePath string
- width int
- height int
- cursor int
- err error
- cursorChain stack
- viewport viewport.Model
- dirs []os.DirEntry
- cwdDetails *DirNode
- selectedFile string
- cwd textinput.Model
- ShowFilePicker bool
- app *app.App
-}
-
-type DirNode struct {
- parent *DirNode
- child *DirNode
- directory string
-}
-type stack []int
-
-func (s stack) Push(v int) stack {
- return append(s, v)
-}
-
-func (s stack) Pop() (stack, int) {
- l := len(s)
- return s[:l-1], s[l-1]
-}
-
-type AttachmentAddedMsg struct {
- Attachment app.Attachment
-}
-
-func (f *filepickerCmp) Init() tea.Cmd {
- return nil
-}
-
-func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmd tea.Cmd
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- f.width = 60
- f.height = 20
- f.viewport.Width = 80
- f.viewport.Height = 22
- f.cursor = 0
- f.getCurrentFileBelowCursor()
- case tea.KeyMsg:
- if f.cwd.Focused() {
- f.cwd, cmd = f.cwd.Update(msg)
- }
- switch {
- case key.Matches(msg, filePickerKeyMap.InsertCWD):
- f.cwd.Focus()
- return f, cmd
- case key.Matches(msg, filePickerKeyMap.Esc):
- if f.cwd.Focused() {
- f.cwd.Blur()
- }
- case key.Matches(msg, filePickerKeyMap.Down):
- if !f.cwd.Focused() || msg.String() == downArrow {
- if f.cursor < len(f.dirs)-1 {
- f.cursor++
- f.getCurrentFileBelowCursor()
- }
- }
- case key.Matches(msg, filePickerKeyMap.Up):
- if !f.cwd.Focused() || msg.String() == upArrow {
- if f.cursor > 0 {
- f.cursor--
- f.getCurrentFileBelowCursor()
- }
- }
- case key.Matches(msg, filePickerKeyMap.Enter):
- var path string
- var isPathDir bool
- if f.cwd.Focused() {
- path = f.cwd.Value()
- fileInfo, err := os.Stat(path)
- if err != nil {
- status.Error("Invalid path")
- return f, cmd
- }
- isPathDir = fileInfo.IsDir()
- } else {
- path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
- isPathDir = f.dirs[f.cursor].IsDir()
- }
- if isPathDir {
- newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
- f.cwdDetails.child = &newWorkingDir
- f.cwdDetails = f.cwdDetails.child
- f.cursorChain = f.cursorChain.Push(f.cursor)
- f.dirs = readDir(f.cwdDetails.directory, false)
- f.cursor = 0
- f.cwd.SetValue(f.cwdDetails.directory)
- f.getCurrentFileBelowCursor()
- } else {
- f.selectedFile = path
- return f.addAttachmentToMessage()
- }
- case key.Matches(msg, filePickerKeyMap.Esc):
- if !f.cwd.Focused() {
- f.cursorChain = make(stack, 0)
- f.cursor = 0
- } else {
- f.cwd.Blur()
- }
- case key.Matches(msg, filePickerKeyMap.Forward):
- if !f.cwd.Focused() {
- if f.dirs[f.cursor].IsDir() {
- path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
- newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
- f.cwdDetails.child = &newWorkingDir
- f.cwdDetails = f.cwdDetails.child
- f.cursorChain = f.cursorChain.Push(f.cursor)
- f.dirs = readDir(f.cwdDetails.directory, false)
- f.cursor = 0
- f.cwd.SetValue(f.cwdDetails.directory)
- f.getCurrentFileBelowCursor()
- }
- }
- case key.Matches(msg, filePickerKeyMap.Backward):
- if !f.cwd.Focused() {
- if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
- f.cursorChain, f.cursor = f.cursorChain.Pop()
- f.cwdDetails = f.cwdDetails.parent
- f.cwdDetails.child = nil
- f.dirs = readDir(f.cwdDetails.directory, false)
- f.cwd.SetValue(f.cwdDetails.directory)
- f.getCurrentFileBelowCursor()
- }
- }
- case key.Matches(msg, filePickerKeyMap.Paste):
- if f.cwd.Focused() {
- val, err := clipboard.ReadAll()
- if err != nil {
- slog.Error("failed to read clipboard")
- return f, cmd
- }
- f.cwd.SetValue(f.cwd.Value() + val)
- }
- case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
- f.dirs = readDir(f.cwdDetails.directory, false)
- f.cursor = 0
- f.getCurrentFileBelowCursor()
- }
- }
- return f, cmd
-}
-
-func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
- // modeInfo := GetSelectedModel(config.Get())
- // if !modeInfo.SupportsAttachments {
- // status.Error(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
- // return f, nil
- // }
-
- selectedFilePath := f.selectedFile
- if !isExtSupported(selectedFilePath) {
- status.Error("Unsupported file")
- return f, nil
- }
-
- isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
- if err != nil {
- status.Error("unable to read the image")
- return f, nil
- }
- if isFileLarge {
- status.Error("file too large, max 5MB")
- return f, nil
- }
-
- content, err := os.ReadFile(selectedFilePath)
- if err != nil {
- status.Error("Unable read selected file")
- return f, nil
- }
-
- mimeBufferSize := min(512, len(content))
- mimeType := http.DetectContentType(content[:mimeBufferSize])
- fileName := filepath.Base(selectedFilePath)
- attachment := app.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
- f.selectedFile = ""
- return f, util.CmdHandler(AttachmentAddedMsg{attachment})
-}
-
-func (f *filepickerCmp) View() string {
- t := theme.CurrentTheme()
- const maxVisibleDirs = 20
- const maxWidth = 80
-
- adjustedWidth := maxWidth
- for _, file := range f.dirs {
- if len(file.Name()) > adjustedWidth-4 { // Account for padding
- adjustedWidth = len(file.Name()) + 4
- }
- }
- adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
-
- files := make([]string, 0, maxVisibleDirs)
- startIdx := 0
-
- if len(f.dirs) > maxVisibleDirs {
- halfVisible := maxVisibleDirs / 2
- if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
- startIdx = f.cursor - halfVisible
- } else if f.cursor >= len(f.dirs)-halfVisible {
- startIdx = len(f.dirs) - maxVisibleDirs
- }
- }
-
- endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
-
- for i := startIdx; i < endIdx; i++ {
- file := f.dirs[i]
- itemStyle := styles.BaseStyle().Width(adjustedWidth)
-
- if i == f.cursor {
- itemStyle = itemStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
- Bold(true)
- }
- filename := file.Name()
-
- if len(filename) > adjustedWidth-4 {
- filename = filename[:adjustedWidth-7] + "..."
- }
- if file.IsDir() {
- filename = filename + "/"
- }
-
- files = append(files, itemStyle.Padding(0, 1).Render(filename))
- }
-
- // Pad to always show exactly 21 lines
- for len(files) < maxVisibleDirs {
- files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
- }
-
- currentPath := styles.BaseStyle().
- Height(1).
- Width(adjustedWidth).
- Render(f.cwd.View())
-
- viewportstyle := lipgloss.NewStyle().
- Width(f.viewport.Width).
- Background(t.Background()).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.TextMuted()).
- BorderBackground(t.Background()).
- Padding(2).
- Render(f.viewport.View())
- var insertExitText string
- if f.IsCWDFocused() {
- insertExitText = "Press esc to exit typing path"
- } else {
- insertExitText = "Press i to start typing path"
- }
-
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- currentPath,
- styles.BaseStyle().Width(adjustedWidth).Render(""),
- styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
- styles.BaseStyle().Width(adjustedWidth).Render(""),
- styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
- )
-
- f.cwd.SetValue(f.cwd.Value())
- contentStyle := styles.BaseStyle().Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(lipgloss.Width(content) + 4)
-
- return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
-}
-
-type FilepickerCmp interface {
- tea.Model
- ToggleFilepicker(showFilepicker bool)
- IsCWDFocused() bool
-}
-
-func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
- f.ShowFilePicker = showFilepicker
-}
-
-func (f *filepickerCmp) IsCWDFocused() bool {
- return f.cwd.Focused()
-}
-
-func NewFilepickerCmp(app *app.App) FilepickerCmp {
- homepath, err := os.UserHomeDir()
- if err != nil {
- slog.Error("error loading user files")
- return nil
- }
- baseDir := DirNode{parent: nil, directory: homepath}
- dirs := readDir(homepath, false)
- viewport := viewport.New(0, 0)
- currentDirectory := textinput.New()
- currentDirectory.CharLimit = 200
- currentDirectory.Width = 44
- currentDirectory.Cursor.Blink = true
- currentDirectory.SetValue(baseDir.directory)
- return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
-}
-
-func (f *filepickerCmp) getCurrentFileBelowCursor() {
- if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
- slog.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
- f.viewport.SetContent("Preview unavailable")
- return
- }
-
- dir := f.dirs[f.cursor]
- filename := dir.Name()
- if !dir.IsDir() && isExtSupported(filename) {
- fullPath := f.cwdDetails.directory + "/" + dir.Name()
-
- go func() {
- imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
- if err != nil {
- slog.Error(err.Error())
- f.viewport.SetContent("Preview unavailable")
- return
- }
-
- f.viewport.SetContent(imageString)
- }()
- } else {
- f.viewport.SetContent("Preview unavailable")
- }
-}
-
-func readDir(path string, showHidden bool) []os.DirEntry {
- slog.Info(fmt.Sprintf("Reading directory: %s", path))
-
- entriesChan := make(chan []os.DirEntry, 1)
- errChan := make(chan error, 1)
-
- go func() {
- dirEntries, err := os.ReadDir(path)
- if err != nil {
- status.Error(err.Error())
- errChan <- err
- return
- }
- entriesChan <- dirEntries
- }()
-
- select {
- case dirEntries := <-entriesChan:
- sort.Slice(dirEntries, func(i, j int) bool {
- if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
- return dirEntries[i].Name() < dirEntries[j].Name()
- }
- return dirEntries[i].IsDir()
- })
-
- if showHidden {
- return dirEntries
- }
-
- var sanitizedDirEntries []os.DirEntry
- for _, dirEntry := range dirEntries {
- isHidden, _ := IsHidden(dirEntry.Name())
- if !isHidden {
- if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
- sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
- }
- }
- }
-
- return sanitizedDirEntries
-
- case <-errChan:
- status.Error(fmt.Sprintf("Error reading directory %s", path))
- return []os.DirEntry{}
-
- case <-time.After(5 * time.Second):
- status.Error(fmt.Sprintf("Timeout reading directory %s", path))
- return []os.DirEntry{}
- }
-}
-
-func IsHidden(file string) (bool, error) {
- return strings.HasPrefix(file, "."), nil
-}
-
-func isExtSupported(path string) bool {
- ext := strings.ToLower(filepath.Ext(path))
- return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
-}
diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go
deleted file mode 100644
index 1f7f53e11..000000000
--- a/internal/tui/components/dialog/help.go
+++ /dev/null
@@ -1,200 +0,0 @@
-package dialog
-
-import (
- "strings"
-
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
-)
-
-type helpCmp struct {
- width int
- height int
- keys []key.Binding
-}
-
-func (h *helpCmp) Init() tea.Cmd {
- return nil
-}
-
-func (h *helpCmp) SetBindings(k []key.Binding) {
- h.keys = k
-}
-
-func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- h.width = 90
- h.height = msg.Height
- }
- return h, nil
-}
-
-func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
- seen := make(map[string]struct{})
- result := make([]key.Binding, 0, len(bindings))
-
- // Process bindings in reverse order
- for i := len(bindings) - 1; i >= 0; i-- {
- b := bindings[i]
- k := strings.Join(b.Keys(), " ")
- if _, ok := seen[k]; ok {
- // duplicate, skip
- continue
- }
- seen[k] = struct{}{}
- // Add to the beginning of result to maintain original order
- result = append([]key.Binding{b}, result...)
- }
-
- return result
-}
-
-func (h *helpCmp) render() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- helpKeyStyle := styles.Bold().
- Background(t.Background()).
- Foreground(t.Text()).
- Padding(0, 1, 0, 0)
-
- helpDescStyle := styles.Regular().
- Background(t.Background()).
- Foreground(t.TextMuted())
-
- // Compile list of bindings to render
- bindings := removeDuplicateBindings(h.keys)
-
- // Enumerate through each group of bindings, populating a series of
- // pairs of columns, one for keys, one for descriptions
- var (
- pairs []string
- width int
- rows = 12 - 2
- )
-
- for i := 0; i < len(bindings); i += rows {
- var (
- keys []string
- descs []string
- )
- for j := i; j < min(i+rows, len(bindings)); j++ {
- keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
- descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
- }
-
- // Render pair of columns; beyond the first pair, render a three space
- // left margin, in order to visually separate the pairs.
- var cols []string
- if len(pairs) > 0 {
- cols = []string{baseStyle.Render(" ")}
- }
-
- maxDescWidth := 0
- for _, desc := range descs {
- if maxDescWidth < lipgloss.Width(desc) {
- maxDescWidth = lipgloss.Width(desc)
- }
- }
- for i := range descs {
- remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
- if remainingWidth > 0 {
- descs[i] = descs[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
- }
- }
- maxKeyWidth := 0
- for _, key := range keys {
- if maxKeyWidth < lipgloss.Width(key) {
- maxKeyWidth = lipgloss.Width(key)
- }
- }
- for i := range keys {
- remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
- if remainingWidth > 0 {
- keys[i] = keys[i] + baseStyle.Render(strings.Repeat(" ", remainingWidth))
- }
- }
-
- cols = append(cols,
- strings.Join(keys, "\n"),
- strings.Join(descs, "\n"),
- )
-
- pair := baseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
- // check whether it exceeds the maximum width avail (the width of the
- // terminal, subtracting 2 for the borders).
- width += lipgloss.Width(pair)
- if width > h.width-2 {
- break
- }
- pairs = append(pairs, pair)
- }
-
- // https://github.com/charmbracelet/lipgloss/issues/209
- if len(pairs) > 1 {
- prefix := pairs[:len(pairs)-1]
- lastPair := pairs[len(pairs)-1]
- prefix = append(prefix, lipgloss.Place(
- lipgloss.Width(lastPair), // width
- lipgloss.Height(prefix[0]), // height
- lipgloss.Left, // x
- lipgloss.Top, // y
- lastPair, // content
- lipgloss.WithWhitespaceBackground(t.Background()),
- ))
- content := baseStyle.Width(h.width).Render(
- lipgloss.JoinHorizontal(
- lipgloss.Top,
- prefix...,
- ),
- )
- return content
- }
-
- // Join pairs of columns and enclose in a border
- content := baseStyle.Width(h.width).Render(
- lipgloss.JoinHorizontal(
- lipgloss.Top,
- pairs...,
- ),
- )
- return content
-}
-
-func (h *helpCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- content := h.render()
- header := baseStyle.
- Bold(true).
- Width(lipgloss.Width(content)).
- Foreground(t.Primary()).
- Render("Keyboard Shortcuts")
-
- return baseStyle.Padding(1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.TextMuted()).
- Width(h.width).
- BorderBackground(t.Background()).
- Render(
- lipgloss.JoinVertical(lipgloss.Center,
- header,
- baseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
- content,
- ),
- )
-}
-
-type HelpCmp interface {
- tea.Model
- SetBindings([]key.Binding)
-}
-
-func NewHelpCmp() HelpCmp {
- return &helpCmp{}
-}
diff --git a/internal/tui/components/dialog/init.go b/internal/tui/components/dialog/init.go
deleted file mode 100644
index 2ef8546f6..000000000
--- a/internal/tui/components/dialog/init.go
+++ /dev/null
@@ -1,189 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/internal/tui/util"
-)
-
-// InitDialogCmp is a component that asks the user if they want to initialize the project.
-type InitDialogCmp struct {
- width, height int
- selected int
- keys initDialogKeyMap
-}
-
-// NewInitDialogCmp creates a new InitDialogCmp.
-func NewInitDialogCmp() InitDialogCmp {
- return InitDialogCmp{
- selected: 0,
- keys: initDialogKeyMap{},
- }
-}
-
-type initDialogKeyMap struct {
- Tab key.Binding
- Left key.Binding
- Right key.Binding
- Enter key.Binding
- Escape key.Binding
- Y key.Binding
- N key.Binding
-}
-
-// ShortHelp implements key.Map.
-func (k initDialogKeyMap) ShortHelp() []key.Binding {
- return []key.Binding{
- key.NewBinding(
- key.WithKeys("tab", "left", "right"),
- key.WithHelp("tab/←/→", "toggle selection"),
- ),
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "confirm"),
- ),
- key.NewBinding(
- key.WithKeys("esc", "q"),
- key.WithHelp("esc/q", "cancel"),
- ),
- key.NewBinding(
- key.WithKeys("y", "n"),
- key.WithHelp("y/n", "yes/no"),
- ),
- }
-}
-
-// FullHelp implements key.Map.
-func (k initDialogKeyMap) FullHelp() [][]key.Binding {
- return [][]key.Binding{k.ShortHelp()}
-}
-
-// Init implements tea.Model.
-func (m InitDialogCmp) Init() tea.Cmd {
- return nil
-}
-
-// Update implements tea.Model.
-func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
- return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
- case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
- m.selected = (m.selected + 1) % 2
- return m, nil
- case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
- return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
- case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
- return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
- case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
- return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
- }
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.height = msg.Height
- }
- return m, nil
-}
-
-// View implements tea.Model.
-func (m InitDialogCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- // Calculate width needed for content
- maxWidth := 60 // Width for explanation text
-
- title := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Width(maxWidth).
- Padding(0, 1).
- Render("Initialize Project")
-
- explanation := baseStyle.
- Foreground(t.Text()).
- Width(maxWidth).
- Padding(0, 1).
- Render("Initialization generates a new CONTEXT.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
-
- question := baseStyle.
- Foreground(t.Text()).
- Width(maxWidth).
- Padding(1, 1).
- Render("Would you like to initialize this project?")
-
- maxWidth = min(maxWidth, m.width-10)
- yesStyle := baseStyle
- noStyle := baseStyle
-
- if m.selected == 0 {
- yesStyle = yesStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
- Bold(true)
- noStyle = noStyle.
- Background(t.Background()).
- Foreground(t.Primary())
- } else {
- noStyle = noStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
- Bold(true)
- yesStyle = yesStyle.
- Background(t.Background()).
- Foreground(t.Primary())
- }
-
- yes := yesStyle.Padding(0, 3).Render("Yes")
- no := noStyle.Padding(0, 3).Render("No")
-
- buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no)
- buttons = baseStyle.
- Width(maxWidth).
- Padding(1, 0).
- Render(buttons)
-
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- title,
- baseStyle.Width(maxWidth).Render(""),
- explanation,
- question,
- buttons,
- baseStyle.Width(maxWidth).Render(""),
- )
-
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(lipgloss.Width(content) + 4).
- Render(content)
-}
-
-// SetSize sets the size of the component.
-func (m *InitDialogCmp) SetSize(width, height int) {
- m.width = width
- m.height = height
-}
-
-// Bindings implements layout.Bindings.
-func (m InitDialogCmp) Bindings() []key.Binding {
- return m.keys.ShortHelp()
-}
-
-// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
-type CloseInitDialogMsg struct {
- Initialize bool
-}
-
-// ShowInitDialogMsg is a message that is sent to show the init dialog.
-type ShowInitDialogMsg struct {
- Show bool
-}
diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go
deleted file mode 100644
index 2dd1e2fea..000000000
--- a/internal/tui/components/dialog/models.go
+++ /dev/null
@@ -1,327 +0,0 @@
-package dialog
-
-import (
- "context"
- "fmt"
-
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/tui/app"
- "github.com/sst/opencode/internal/tui/layout"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/internal/tui/util"
- "github.com/sst/opencode/pkg/client"
-)
-
-const (
- numVisibleModels = 10
- maxDialogWidth = 40
-)
-
-// CloseModelDialogMsg is sent when a model is selected
-type CloseModelDialogMsg struct {
- Provider *client.ProviderInfo
- Model *client.ProviderModel
-}
-
-// ModelDialog interface for the model selection dialog
-type ModelDialog interface {
- tea.Model
- layout.Bindings
-
- SetProviders(providers []client.ProviderInfo)
-}
-
-type modelDialogCmp struct {
- app *app.App
- availableProviders []client.ProviderInfo
- provider client.ProviderInfo
- model *client.ProviderModel
-
- selectedIdx int
- width int
- height int
- scrollOffset int
- hScrollOffset int
- hScrollPossible bool
-}
-
-type modelKeyMap struct {
- Up key.Binding
- Down key.Binding
- Left key.Binding
- Right key.Binding
- Enter key.Binding
- Escape key.Binding
- J key.Binding
- K key.Binding
- H key.Binding
- L key.Binding
-}
-
-var modelKeys = modelKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up"),
- key.WithHelp("↑", "previous model"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down"),
- key.WithHelp("↓", "next model"),
- ),
- Left: key.NewBinding(
- key.WithKeys("left"),
- key.WithHelp("←", "scroll left"),
- ),
- Right: key.NewBinding(
- key.WithKeys("right"),
- key.WithHelp("→", "scroll right"),
- ),
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select model"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
- J: key.NewBinding(
- key.WithKeys("j"),
- key.WithHelp("j", "next model"),
- ),
- K: key.NewBinding(
- key.WithKeys("k"),
- key.WithHelp("k", "previous model"),
- ),
- H: key.NewBinding(
- key.WithKeys("h"),
- key.WithHelp("h", "scroll left"),
- ),
- L: key.NewBinding(
- key.WithKeys("l"),
- key.WithHelp("l", "scroll right"),
- ),
-}
-
-func (m *modelDialogCmp) Init() tea.Cmd {
- // cfg := config.Get()
- // modelInfo := GetSelectedModel(cfg)
- // m.availableProviders = getEnabledProviders(cfg)
- // m.hScrollPossible = len(m.availableProviders) > 1
-
- // m.provider = modelInfo.Provider
- // m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
-
- // m.setupModelsForProvider(m.provider)
-
- m.availableProviders, _ = m.app.ListProviders(context.Background())
- m.hScrollOffset = 0
- m.hScrollPossible = len(m.availableProviders) > 1
- m.provider = m.availableProviders[m.hScrollOffset]
-
- return nil
-}
-
-func (m *modelDialogCmp) SetProviders(providers []client.ProviderInfo) {
- m.availableProviders = providers
-}
-
-func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
- m.moveSelectionUp()
- case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J):
- m.moveSelectionDown()
- case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H):
- if m.hScrollPossible {
- m.switchProvider(-1)
- }
- case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L):
- if m.hScrollPossible {
- m.switchProvider(1)
- }
- case key.Matches(msg, modelKeys.Enter):
- return m, util.CmdHandler(CloseModelDialogMsg{Provider: &m.provider, Model: &m.provider.Models[m.selectedIdx]})
- case key.Matches(msg, modelKeys.Escape):
- return m, util.CmdHandler(CloseModelDialogMsg{})
- }
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.height = msg.Height
- }
-
- return m, nil
-}
-
-// moveSelectionUp moves the selection up or wraps to bottom
-func (m *modelDialogCmp) moveSelectionUp() {
- if m.selectedIdx > 0 {
- m.selectedIdx--
- } else {
- m.selectedIdx = len(m.provider.Models) - 1
- m.scrollOffset = max(0, len(m.provider.Models)-numVisibleModels)
- }
-
- // Keep selection visible
- if m.selectedIdx < m.scrollOffset {
- m.scrollOffset = m.selectedIdx
- }
-}
-
-// moveSelectionDown moves the selection down or wraps to top
-func (m *modelDialogCmp) moveSelectionDown() {
- if m.selectedIdx < len(m.provider.Models)-1 {
- m.selectedIdx++
- } else {
- m.selectedIdx = 0
- m.scrollOffset = 0
- }
-
- // Keep selection visible
- if m.selectedIdx >= m.scrollOffset+numVisibleModels {
- m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
- }
-}
-
-func (m *modelDialogCmp) switchProvider(offset int) {
- newOffset := m.hScrollOffset + offset
-
- // Ensure we stay within bounds
- if newOffset < 0 {
- newOffset = len(m.availableProviders) - 1
- }
- if newOffset >= len(m.availableProviders) {
- newOffset = 0
- }
-
- m.hScrollOffset = newOffset
- m.provider = m.availableProviders[m.hScrollOffset]
- m.setupModelsForProvider(m.provider.Id)
-}
-
-func (m *modelDialogCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- // Capitalize first letter of provider name
- title := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Width(maxDialogWidth).
- Padding(0, 0, 1).
- Render(fmt.Sprintf("Select %s Model", m.provider.Name))
-
- // Render visible models
- endIdx := min(m.scrollOffset+numVisibleModels, len(m.provider.Models))
- modelItems := make([]string, 0, endIdx-m.scrollOffset)
-
- for i := m.scrollOffset; i < endIdx; i++ {
- itemStyle := baseStyle.Width(maxDialogWidth)
- if i == m.selectedIdx {
- itemStyle = itemStyle.Background(t.Primary()).
- Foreground(t.Background()).Bold(true)
- }
- modelItems = append(modelItems, itemStyle.Render(*m.provider.Models[i].Name))
- }
-
- scrollIndicator := m.getScrollIndicators(maxDialogWidth)
-
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- title,
- baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
- scrollIndicator,
- )
-
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(lipgloss.Width(content) + 4).
- Render(content)
-}
-
-func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
- var indicator string
-
- if len(m.provider.Models) > numVisibleModels {
- if m.scrollOffset > 0 {
- indicator += "↑ "
- }
- if m.scrollOffset+numVisibleModels < len(m.provider.Models) {
- indicator += "↓ "
- }
- }
-
- if m.hScrollPossible {
- if m.hScrollOffset > 0 {
- indicator = "← " + indicator
- }
- if m.hScrollOffset < len(m.availableProviders)-1 {
- indicator += "→"
- }
- }
-
- if indicator == "" {
- return ""
- }
-
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- return baseStyle.
- Foreground(t.Primary()).
- Width(maxWidth).
- Align(lipgloss.Right).
- Bold(true).
- Render(indicator)
-}
-
-func (m *modelDialogCmp) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(modelKeys)
-}
-
-// findProviderIndex returns the index of the provider in the list, or -1 if not found
-// func findProviderIndex(providers []string, provider string) int {
-// for i, p := range providers {
-// if p == provider {
-// return i
-// }
-// }
-// return -1
-// }
-
-func (m *modelDialogCmp) setupModelsForProvider(_ string) {
- m.selectedIdx = 0
- m.scrollOffset = 0
-
- // cfg := config.Get()
- // agentCfg := cfg.Agents[config.AgentPrimary]
- // selectedModelId := agentCfg.Model
-
- // m.provider = provider
- // m.models = getModelsForProvider(provider)
-
- // Try to select the current model if it belongs to this provider
- // if provider == models.SupportedModels[selectedModelId].Provider {
- // for i, model := range m.models {
- // if model.ID == selectedModelId {
- // m.selectedIdx = i
- // // Adjust scroll position to keep selected model visible
- // if m.selectedIdx >= numVisibleModels {
- // m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
- // }
- // break
- // }
- // }
- // }
-}
-
-func NewModelDialogCmp(app *app.App) ModelDialog {
- return &modelDialogCmp{
- app: app,
- }
-}
diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
deleted file mode 100644
index 0e5afdeab..000000000
--- a/internal/tui/components/dialog/permission.go
+++ /dev/null
@@ -1,502 +0,0 @@
-package dialog
-
-import (
- "fmt"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/viewport"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/tui/layout"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/internal/tui/util"
- "strings"
-)
-
-type PermissionAction string
-
-// Permission responses
-const (
- PermissionAllow PermissionAction = "allow"
- PermissionAllowForSession PermissionAction = "allow_session"
- PermissionDeny PermissionAction = "deny"
-)
-
-// PermissionResponseMsg represents the user's response to a permission request
-type PermissionResponseMsg struct {
- // Permission permission.PermissionRequest
- Action PermissionAction
-}
-
-// PermissionDialogCmp interface for permission dialog component
-type PermissionDialogCmp interface {
- tea.Model
- layout.Bindings
- // SetPermissions(permission permission.PermissionRequest) tea.Cmd
-}
-
-type permissionsMapping struct {
- Left key.Binding
- Right key.Binding
- EnterSpace key.Binding
- Allow key.Binding
- AllowSession key.Binding
- Deny key.Binding
- Tab key.Binding
-}
-
-var permissionsKeys = permissionsMapping{
- Left: key.NewBinding(
- key.WithKeys("left"),
- key.WithHelp("←", "switch options"),
- ),
- Right: key.NewBinding(
- key.WithKeys("right"),
- key.WithHelp("→", "switch options"),
- ),
- EnterSpace: key.NewBinding(
- key.WithKeys("enter", " "),
- key.WithHelp("enter/space", "confirm"),
- ),
- Allow: key.NewBinding(
- key.WithKeys("a"),
- key.WithHelp("a", "allow"),
- ),
- AllowSession: key.NewBinding(
- key.WithKeys("s"),
- key.WithHelp("s", "allow for session"),
- ),
- Deny: key.NewBinding(
- key.WithKeys("d"),
- key.WithHelp("d", "deny"),
- ),
- Tab: key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "switch options"),
- ),
-}
-
-// permissionDialogCmp is the implementation of PermissionDialog
-type permissionDialogCmp struct {
- width int
- height int
- // permission permission.PermissionRequest
- windowSize tea.WindowSizeMsg
- contentViewPort viewport.Model
- selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
-
- diffCache map[string]string
- markdownCache map[string]string
-}
-
-func (p *permissionDialogCmp) Init() tea.Cmd {
- return p.contentViewPort.Init()
-}
-
-func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- p.windowSize = msg
- cmd := p.SetSize()
- cmds = append(cmds, cmd)
- p.markdownCache = make(map[string]string)
- p.diffCache = make(map[string]string)
- // case tea.KeyMsg:
- // switch {
- // case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
- // p.selectedOption = (p.selectedOption + 1) % 3
- // return p, nil
- // case key.Matches(msg, permissionsKeys.Left):
- // p.selectedOption = (p.selectedOption + 2) % 3
- // case key.Matches(msg, permissionsKeys.EnterSpace):
- // return p, p.selectCurrentOption()
- // case key.Matches(msg, permissionsKeys.Allow):
- // return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
- // case key.Matches(msg, permissionsKeys.AllowSession):
- // return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
- // case key.Matches(msg, permissionsKeys.Deny):
- // return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
- // default:
- // // Pass other keys to viewport
- // viewPort, cmd := p.contentViewPort.Update(msg)
- // p.contentViewPort = viewPort
- // cmds = append(cmds, cmd)
- // }
- }
-
- return p, tea.Batch(cmds...)
-}
-
-func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
- var action PermissionAction
-
- switch p.selectedOption {
- case 0:
- action = PermissionAllow
- case 1:
- action = PermissionAllowForSession
- case 2:
- action = PermissionDeny
- }
-
- return util.CmdHandler(PermissionResponseMsg{Action: action}) // , Permission: p.permission})
-}
-
-func (p *permissionDialogCmp) renderButtons() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- allowStyle := baseStyle
- allowSessionStyle := baseStyle
- denyStyle := baseStyle
- spacerStyle := baseStyle.Background(t.Background())
-
- // Style the selected button
- switch p.selectedOption {
- case 0:
- allowStyle = allowStyle.Background(t.Primary()).Foreground(t.Background())
- allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
- denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
- case 1:
- allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
- allowSessionStyle = allowSessionStyle.Background(t.Primary()).Foreground(t.Background())
- denyStyle = denyStyle.Background(t.Background()).Foreground(t.Primary())
- case 2:
- allowStyle = allowStyle.Background(t.Background()).Foreground(t.Primary())
- allowSessionStyle = allowSessionStyle.Background(t.Background()).Foreground(t.Primary())
- denyStyle = denyStyle.Background(t.Primary()).Foreground(t.Background())
- }
-
- allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
- allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (s)")
- denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
-
- content := lipgloss.JoinHorizontal(
- lipgloss.Left,
- allowButton,
- spacerStyle.Render(" "),
- allowSessionButton,
- spacerStyle.Render(" "),
- denyButton,
- spacerStyle.Render(" "),
- )
-
- remainingWidth := p.width - lipgloss.Width(content)
- if remainingWidth > 0 {
- content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
- }
- return content
-}
-
-func (p *permissionDialogCmp) renderHeader() string {
- return "NOT IMPLEMENTED"
- // t := theme.CurrentTheme()
- // baseStyle := styles.BaseStyle()
- //
- // toolKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Tool")
- // toolValue := baseStyle.
- // Foreground(t.Text()).
- // Width(p.width - lipgloss.Width(toolKey)).
- // Render(fmt.Sprintf(": %s", p.permission.ToolName))
- //
- // pathKey := baseStyle.Foreground(t.TextMuted()).Bold(true).Render("Path")
- //
- // // Get the current working directory to display relative path
- // relativePath := p.permission.Path
- // if filepath.IsAbs(relativePath) {
- // if cwd, err := filepath.Rel(config.WorkingDirectory(), relativePath); err == nil {
- // relativePath = cwd
- // }
- // }
- //
- // pathValue := baseStyle.
- // Foreground(t.Text()).
- // Width(p.width - lipgloss.Width(pathKey)).
- // Render(fmt.Sprintf(": %s", relativePath))
- //
- // headerParts := []string{
- // lipgloss.JoinHorizontal(
- // lipgloss.Left,
- // toolKey,
- // toolValue,
- // ),
- // baseStyle.Render(strings.Repeat(" ", p.width)),
- // lipgloss.JoinHorizontal(
- // lipgloss.Left,
- // pathKey,
- // pathValue,
- // ),
- // baseStyle.Render(strings.Repeat(" ", p.width)),
- // }
- //
- // // Add tool-specific header information
- // switch p.permission.ToolName {
- // case "bash":
- // headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Command"))
- // case "edit":
- // headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
- // case "write":
- // headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("Diff"))
- // case "fetch":
- // headerParts = append(headerParts, baseStyle.Foreground(t.TextMuted()).Width(p.width).Bold(true).Render("URL"))
- // }
- //
- // return lipgloss.NewStyle().Background(t.Background()).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-}
-
-func (p *permissionDialogCmp) renderBashContent() string {
- // t := theme.CurrentTheme()
- // baseStyle := styles.BaseStyle()
- //
- // if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
- // content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
- //
- // // Use the cache for markdown rendering
- // renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
- // r := styles.GetMarkdownRenderer(p.width - 10)
- // s, err := r.Render(content)
- // return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
- // })
- //
- // finalContent := baseStyle.
- // Width(p.contentViewPort.Width).
- // Render(renderedContent)
- // p.contentViewPort.SetContent(finalContent)
- // return p.styleViewport()
- // }
- return ""
-}
-
-func (p *permissionDialogCmp) renderEditContent() string {
- // if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
- // diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
- // return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
- // })
- //
- // p.contentViewPort.SetContent(diff)
- // return p.styleViewport()
- // }
- return ""
-}
-
-func (p *permissionDialogCmp) renderPatchContent() string {
- // if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
- // diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
- // return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
- // })
- //
- // p.contentViewPort.SetContent(diff)
- // return p.styleViewport()
- // }
- return ""
-}
-
-func (p *permissionDialogCmp) renderWriteContent() string {
- // if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
- // // Use the cache for diff rendering
- // diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
- // return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
- // })
- //
- // p.contentViewPort.SetContent(diff)
- // return p.styleViewport()
- // }
- return ""
-}
-
-func (p *permissionDialogCmp) renderFetchContent() string {
- // t := theme.CurrentTheme()
- // baseStyle := styles.BaseStyle()
- //
- // if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
- // content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
- //
- // // Use the cache for markdown rendering
- // renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
- // r := styles.GetMarkdownRenderer(p.width - 10)
- // s, err := r.Render(content)
- // return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
- // })
- //
- // finalContent := baseStyle.
- // Width(p.contentViewPort.Width).
- // Render(renderedContent)
- // p.contentViewPort.SetContent(finalContent)
- // return p.styleViewport()
- // }
- return ""
-}
-
-func (p *permissionDialogCmp) renderDefaultContent() string {
- // t := theme.CurrentTheme()
- // baseStyle := styles.BaseStyle()
- //
- // content := p.permission.Description
- //
- // // Use the cache for markdown rendering
- // renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
- // r := styles.GetMarkdownRenderer(p.width - 10)
- // s, err := r.Render(content)
- // return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
- // })
- //
- // finalContent := baseStyle.
- // Width(p.contentViewPort.Width).
- // Render(renderedContent)
- // p.contentViewPort.SetContent(finalContent)
- //
- // if renderedContent == "" {
- // return ""
- // }
- //
- return p.styleViewport()
-}
-
-func (p *permissionDialogCmp) styleViewport() string {
- t := theme.CurrentTheme()
- contentStyle := lipgloss.NewStyle().
- Background(t.Background())
-
- return contentStyle.Render(p.contentViewPort.View())
-}
-
-func (p *permissionDialogCmp) render() string {
- return "NOT IMPLEMENTED"
- // t := theme.CurrentTheme()
- // baseStyle := styles.BaseStyle()
- //
- // title := baseStyle.
- // Bold(true).
- // Width(p.width - 4).
- // Foreground(t.Primary()).
- // Render("Permission Required")
- // // Render header
- // headerContent := p.renderHeader()
- // // Render buttons
- // buttons := p.renderButtons()
- //
- // // Calculate content height dynamically based on window size
- // p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
- // p.contentViewPort.Width = p.width - 4
- //
- // // Render content based on tool type
- // var contentFinal string
- // switch p.permission.ToolName {
- // case "bash":
- // contentFinal = p.renderBashContent()
- // case "edit":
- // contentFinal = p.renderEditContent()
- // case "patch":
- // contentFinal = p.renderPatchContent()
- // case "write":
- // contentFinal = p.renderWriteContent()
- // case "fetch":
- // contentFinal = p.renderFetchContent()
- // default:
- // contentFinal = p.renderDefaultContent()
- // }
- //
- // content := lipgloss.JoinVertical(
- // lipgloss.Top,
- // title,
- // baseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
- // headerContent,
- // contentFinal,
- // buttons,
- // baseStyle.Render(strings.Repeat(" ", p.width-4)),
- // )
- //
- // return baseStyle.
- // Padding(1, 0, 0, 1).
- // Border(lipgloss.RoundedBorder()).
- // BorderBackground(t.Background()).
- // BorderForeground(t.TextMuted()).
- // Width(p.width).
- // Height(p.height).
- // Render(
- // content,
- // )
-}
-
-func (p *permissionDialogCmp) View() string {
- return p.render()
-}
-
-func (p *permissionDialogCmp) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(permissionsKeys)
-}
-
-func (p *permissionDialogCmp) SetSize() tea.Cmd {
- // if p.permission.ID == "" {
- // return nil
- // }
- // switch p.permission.ToolName {
- // case "bash":
- // p.width = int(float64(p.windowSize.Width) * 0.4)
- // p.height = int(float64(p.windowSize.Height) * 0.3)
- // case "edit":
- // p.width = int(float64(p.windowSize.Width) * 0.8)
- // p.height = int(float64(p.windowSize.Height) * 0.8)
- // case "write":
- // p.width = int(float64(p.windowSize.Width) * 0.8)
- // p.height = int(float64(p.windowSize.Height) * 0.8)
- // case "fetch":
- // p.width = int(float64(p.windowSize.Width) * 0.4)
- // p.height = int(float64(p.windowSize.Height) * 0.3)
- // default:
- // p.width = int(float64(p.windowSize.Width) * 0.7)
- // p.height = int(float64(p.windowSize.Height) * 0.5)
- // }
- return nil
-}
-
-// func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
-// p.permission = permission
-// return p.SetSize()
-// }
-
-// Helper to get or set cached diff content
-func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
- if cached, ok := c.diffCache[key]; ok {
- return cached
- }
-
- content, err := generator()
- if err != nil {
- return fmt.Sprintf("Error formatting diff: %v", err)
- }
-
- c.diffCache[key] = content
-
- return content
-}
-
-// Helper to get or set cached markdown content
-func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
- if cached, ok := c.markdownCache[key]; ok {
- return cached
- }
-
- content, err := generator()
- if err != nil {
- return fmt.Sprintf("Error rendering markdown: %v", err)
- }
-
- c.markdownCache[key] = content
-
- return content
-}
-
-func NewPermissionDialogCmp() PermissionDialogCmp {
- // Create viewport for content
- contentViewport := viewport.New(0, 0)
-
- return &permissionDialogCmp{
- contentViewPort: contentViewport,
- selectedOption: 0, // Default to "Allow"
- diffCache: make(map[string]string),
- markdownCache: make(map[string]string),
- }
-}
diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialog/quit.go
deleted file mode 100644
index 3fd2ea920..000000000
--- a/internal/tui/components/dialog/quit.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package dialog
-
-import (
- "strings"
-
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/tui/layout"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/internal/tui/util"
-)
-
-const question = "Are you sure you want to quit?"
-
-type CloseQuitMsg struct{}
-
-type QuitDialog interface {
- tea.Model
- layout.Bindings
-}
-
-type quitDialogCmp struct {
- selectedNo bool
-}
-
-type helpMapping struct {
- LeftRight key.Binding
- EnterSpace key.Binding
- Yes key.Binding
- No key.Binding
- Tab key.Binding
-}
-
-var helpKeys = helpMapping{
- LeftRight: key.NewBinding(
- key.WithKeys("left", "right"),
- key.WithHelp("←/→", "switch options"),
- ),
- EnterSpace: key.NewBinding(
- key.WithKeys("enter", " "),
- key.WithHelp("enter/space", "confirm"),
- ),
- Yes: key.NewBinding(
- key.WithKeys("y", "Y"),
- key.WithHelp("y/Y", "yes"),
- ),
- No: key.NewBinding(
- key.WithKeys("n", "N"),
- key.WithHelp("n/N", "no"),
- ),
- Tab: key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "switch options"),
- ),
-}
-
-func (q *quitDialogCmp) Init() tea.Cmd {
- return nil
-}
-
-func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
- q.selectedNo = !q.selectedNo
- return q, nil
- case key.Matches(msg, helpKeys.EnterSpace):
- if !q.selectedNo {
- return q, tea.Quit
- }
- return q, util.CmdHandler(CloseQuitMsg{})
- case key.Matches(msg, helpKeys.Yes):
- return q, tea.Quit
- case key.Matches(msg, helpKeys.No):
- return q, util.CmdHandler(CloseQuitMsg{})
- }
- }
- return q, nil
-}
-
-func (q *quitDialogCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- yesStyle := baseStyle
- noStyle := baseStyle
- spacerStyle := baseStyle.Background(t.Background())
-
- if q.selectedNo {
- noStyle = noStyle.Background(t.Primary()).Foreground(t.Background())
- yesStyle = yesStyle.Background(t.Background()).Foreground(t.Primary())
- } else {
- yesStyle = yesStyle.Background(t.Primary()).Foreground(t.Background())
- noStyle = noStyle.Background(t.Background()).Foreground(t.Primary())
- }
-
- yesButton := yesStyle.Padding(0, 1).Render("Yes")
- noButton := noStyle.Padding(0, 1).Render("No")
-
- buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
-
- width := lipgloss.Width(question)
- remainingWidth := width - lipgloss.Width(buttons)
- if remainingWidth > 0 {
- buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
- }
-
- content := baseStyle.Render(
- lipgloss.JoinVertical(
- lipgloss.Center,
- question,
- "",
- buttons,
- ),
- )
-
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(lipgloss.Width(content) + 4).
- Render(content)
-}
-
-func (q *quitDialogCmp) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(helpKeys)
-}
-
-func NewQuitCmp() QuitDialog {
- return &quitDialogCmp{
- selectedNo: true,
- }
-}
diff --git a/internal/tui/components/dialog/session.go b/internal/tui/components/dialog/session.go
deleted file mode 100644
index 99aa41515..000000000
--- a/internal/tui/components/dialog/session.go
+++ /dev/null
@@ -1,230 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/tui/layout"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/internal/tui/util"
- "github.com/sst/opencode/pkg/client"
-)
-
-// CloseSessionDialogMsg is sent when the session dialog is closed
-type CloseSessionDialogMsg struct {
- Session *client.SessionInfo
-}
-
-// SessionDialog interface for the session switching dialog
-type SessionDialog interface {
- tea.Model
- layout.Bindings
- SetSessions(sessions []client.SessionInfo)
- SetSelectedSession(sessionID string)
-}
-
-type sessionDialogCmp struct {
- sessions []client.SessionInfo
- selectedIdx int
- width int
- height int
- selectedSessionID string
-}
-
-type sessionKeyMap struct {
- Up key.Binding
- Down key.Binding
- Enter key.Binding
- Escape key.Binding
- J key.Binding
- K key.Binding
-}
-
-var sessionKeys = sessionKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up"),
- key.WithHelp("↑", "previous session"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down"),
- key.WithHelp("↓", "next session"),
- ),
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select session"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
- J: key.NewBinding(
- key.WithKeys("j"),
- key.WithHelp("j", "next session"),
- ),
- K: key.NewBinding(
- key.WithKeys("k"),
- key.WithHelp("k", "previous session"),
- ),
-}
-
-func (s *sessionDialogCmp) Init() tea.Cmd {
- return nil
-}
-
-func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- s.width = msg.Width
- s.height = msg.Height
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
- if s.selectedIdx > 0 {
- s.selectedIdx--
- }
- return s, nil
- case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
- if s.selectedIdx < len(s.sessions)-1 {
- s.selectedIdx++
- }
- return s, nil
- case key.Matches(msg, sessionKeys.Enter):
- if len(s.sessions) > 0 {
- selectedSession := s.sessions[s.selectedIdx]
- s.selectedSessionID = selectedSession.Id
-
- return s, util.CmdHandler(CloseSessionDialogMsg{
- Session: &selectedSession,
- })
- }
- case key.Matches(msg, sessionKeys.Escape):
- return s, util.CmdHandler(CloseSessionDialogMsg{})
- }
- }
- return s, nil
-}
-
-func (s *sessionDialogCmp) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- if len(s.sessions) == 0 {
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(40).
- Render("No sessions available")
- }
-
- // Calculate max width needed for session titles
- maxWidth := 40 // Minimum width
- for _, sess := range s.sessions {
- if len(sess.Title) > maxWidth-4 { // Account for padding
- maxWidth = len(sess.Title) + 4
- }
- }
-
- maxWidth = max(30, min(maxWidth, s.width-15)) // Limit width to avoid overflow
-
- // Limit height to avoid taking up too much screen space
- maxVisibleSessions := min(10, len(s.sessions))
-
- // Build the session list
- sessionItems := make([]string, 0, maxVisibleSessions)
- startIdx := 0
-
- // If we have more sessions than can be displayed, adjust the start index
- if len(s.sessions) > maxVisibleSessions {
- // Center the selected item when possible
- halfVisible := maxVisibleSessions / 2
- if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
- startIdx = s.selectedIdx - halfVisible
- } else if s.selectedIdx >= len(s.sessions)-halfVisible {
- startIdx = len(s.sessions) - maxVisibleSessions
- }
- }
-
- endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
-
- for i := startIdx; i < endIdx; i++ {
- sess := s.sessions[i]
- itemStyle := baseStyle.Width(maxWidth)
-
- if i == s.selectedIdx {
- itemStyle = itemStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
- Bold(true)
- }
-
- sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
- }
-
- title := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Width(maxWidth).
- Padding(0, 1).
- Render("Switch Session")
-
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- title,
- baseStyle.Width(maxWidth).Render(""),
- baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
- baseStyle.Width(maxWidth).Render(""),
- )
-
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(lipgloss.Width(content) + 4).
- Render(content)
-}
-
-func (s *sessionDialogCmp) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(sessionKeys)
-}
-
-func (s *sessionDialogCmp) SetSessions(sessions []client.SessionInfo) {
- s.sessions = sessions
-
- // If we have a selected session ID, find its index
- if s.selectedSessionID != "" {
- for i, sess := range sessions {
- if sess.Id == s.selectedSessionID {
- s.selectedIdx = i
- return
- }
- }
- }
-
- // Default to first session if selected not found
- s.selectedIdx = 0
-}
-
-func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
- s.selectedSessionID = sessionID
-
- // Update the selected index if sessions are already loaded
- if len(s.sessions) > 0 {
- for i, sess := range s.sessions {
- if sess.Id == sessionID {
- s.selectedIdx = i
- return
- }
- }
- }
-}
-
-// NewSessionDialogCmp creates a new session switching dialog
-func NewSessionDialogCmp() SessionDialog {
- return &sessionDialogCmp{
- sessions: []client.SessionInfo{},
- selectedIdx: 0,
- selectedSessionID: "",
- }
-}
diff --git a/internal/tui/components/dialog/theme.go b/internal/tui/components/dialog/theme.go
deleted file mode 100644
index 54856e8a9..000000000
--- a/internal/tui/components/dialog/theme.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package dialog
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/status"
- "github.com/sst/opencode/internal/tui/layout"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
- "github.com/sst/opencode/internal/tui/util"
-)
-
-// ThemeChangedMsg is sent when the theme is changed
-type ThemeChangedMsg struct {
- ThemeName string
-}
-
-// CloseThemeDialogMsg is sent when the theme dialog is closed
-type CloseThemeDialogMsg struct{}
-
-// ThemeDialog interface for the theme switching dialog
-type ThemeDialog interface {
- tea.Model
- layout.Bindings
-}
-
-type themeDialogCmp struct {
- themes []string
- selectedIdx int
- width int
- height int
- currentTheme string
-}
-
-type themeKeyMap struct {
- Up key.Binding
- Down key.Binding
- Enter key.Binding
- Escape key.Binding
- J key.Binding
- K key.Binding
-}
-
-var themeKeys = themeKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up"),
- key.WithHelp("↑", "previous theme"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down"),
- key.WithHelp("↓", "next theme"),
- ),
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select theme"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
- J: key.NewBinding(
- key.WithKeys("j"),
- key.WithHelp("j", "next theme"),
- ),
- K: key.NewBinding(
- key.WithKeys("k"),
- key.WithHelp("k", "previous theme"),
- ),
-}
-
-func (t *themeDialogCmp) Init() tea.Cmd {
- // Load available themes and update selectedIdx based on current theme
- t.themes = theme.AvailableThemes()
- t.currentTheme = theme.CurrentThemeName()
-
- // Find the current theme in the list
- for i, name := range t.themes {
- if name == t.currentTheme {
- t.selectedIdx = i
- break
- }
- }
-
- return nil
-}
-
-func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K):
- if t.selectedIdx > 0 {
- t.selectedIdx--
- }
- return t, nil
- case key.Matches(msg, themeKeys.Down) || key.Matches(msg, themeKeys.J):
- if t.selectedIdx < len(t.themes)-1 {
- t.selectedIdx++
- }
- return t, nil
- case key.Matches(msg, themeKeys.Enter):
- if len(t.themes) > 0 {
- previousTheme := theme.CurrentThemeName()
- selectedTheme := t.themes[t.selectedIdx]
- if previousTheme == selectedTheme {
- return t, util.CmdHandler(CloseThemeDialogMsg{})
- }
- if err := theme.SetTheme(selectedTheme); err != nil {
- status.Error(err.Error())
- return t, nil
- }
- return t, util.CmdHandler(ThemeChangedMsg{
- ThemeName: selectedTheme,
- })
- }
- case key.Matches(msg, themeKeys.Escape):
- return t, util.CmdHandler(CloseThemeDialogMsg{})
- }
- case tea.WindowSizeMsg:
- t.width = msg.Width
- t.height = msg.Height
- }
- return t, nil
-}
-
-func (t *themeDialogCmp) View() string {
- currentTheme := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- if len(t.themes) == 0 {
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(currentTheme.Background()).
- BorderForeground(currentTheme.TextMuted()).
- Width(40).
- Render("No themes available")
- }
-
- // Calculate max width needed for theme names
- maxWidth := 40 // Minimum width
- for _, themeName := range t.themes {
- if len(themeName) > maxWidth-4 { // Account for padding
- maxWidth = len(themeName) + 4
- }
- }
-
- maxWidth = max(30, min(maxWidth, t.width-15)) // Limit width to avoid overflow
-
- // Build the theme list
- themeItems := make([]string, 0, len(t.themes))
- for i, themeName := range t.themes {
- itemStyle := baseStyle.Width(maxWidth)
-
- if i == t.selectedIdx {
- itemStyle = itemStyle.
- Background(currentTheme.Primary()).
- Foreground(currentTheme.Background()).
- Bold(true)
- }
-
- themeItems = append(themeItems, itemStyle.Padding(0, 1).Render(themeName))
- }
-
- title := baseStyle.
- Foreground(currentTheme.Primary()).
- Bold(true).
- Width(maxWidth).
- Padding(0, 1).
- Render("Select Theme")
-
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- title,
- baseStyle.Width(maxWidth).Render(""),
- baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, themeItems...)),
- baseStyle.Width(maxWidth).Render(""),
- )
-
- return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(currentTheme.Background()).
- BorderForeground(currentTheme.TextMuted()).
- Width(lipgloss.Width(content) + 4).
- Render(content)
-}
-
-func (t *themeDialogCmp) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(themeKeys)
-}
-
-// NewThemeDialogCmp creates a new theme switching dialog
-func NewThemeDialogCmp() ThemeDialog {
- return &themeDialogCmp{
- themes: []string{},
- selectedIdx: 0,
- currentTheme: "",
- }
-}
diff --git a/internal/tui/components/dialog/tools.go b/internal/tui/components/dialog/tools.go
deleted file mode 100644
index 76e6ff227..000000000
--- a/internal/tui/components/dialog/tools.go
+++ /dev/null
@@ -1,178 +0,0 @@
-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/components/qr/qr.go b/internal/tui/components/qr/qr.go
deleted file mode 100644
index 42a60bb5e..000000000
--- a/internal/tui/components/qr/qr.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package qr
-
-import (
- "strings"
-
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/tui/theme"
- "rsc.io/qr"
-)
-
-var tops_bottoms = []rune{' ', '▀', '▄', '█'}
-
-// Generate a text string to a QR code, which you can write to a terminal or file.
-func Generate(text string) (string, int, error) {
- code, err := qr.Encode(text, qr.Level(0))
- if err != nil {
- return "", 0, err
- }
-
- t := theme.CurrentTheme()
- if t == nil {
- return "", 0, err
- }
-
- // Create lipgloss style for QR code with theme colors
- qrStyle := lipgloss.NewStyle().
- Foreground(t.Text()).
- Background(t.Background())
-
- var result strings.Builder
-
- // content
- for y := 0; y < code.Size-1; y += 2 {
- var line strings.Builder
- for x := 0; x < code.Size; x += 1 {
- var num int8
- if code.Black(x, y) {
- num += 1
- }
- if code.Black(x, y+1) {
- num += 2
- }
- line.WriteRune(tops_bottoms[num])
- }
- result.WriteString(qrStyle.Render(line.String()) + "\n")
- }
-
- // add lower border when required (only required when QR size is odd)
- if code.Size%2 == 1 {
- var borderLine strings.Builder
- for range code.Size {
- borderLine.WriteRune('▀')
- }
- result.WriteString(qrStyle.Render(borderLine.String()) + "\n")
- }
-
- return result.String(), code.Size, nil
-}
diff --git a/internal/tui/components/spinner/spinner.go b/internal/tui/components/spinner/spinner.go
deleted file mode 100644
index 5e1af8771..000000000
--- a/internal/tui/components/spinner/spinner.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package spinner
-
-import (
- "context"
- "fmt"
- "os"
-
- "github.com/charmbracelet/bubbles/spinner"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-// Spinner wraps the bubbles spinner for both interactive and non-interactive mode
-type Spinner struct {
- model spinner.Model
- done chan struct{}
- prog *tea.Program
- ctx context.Context
- cancel context.CancelFunc
-}
-
-// spinnerModel is the tea.Model for the spinner
-type spinnerModel struct {
- spinner spinner.Model
- message string
- quitting bool
-}
-
-func (m spinnerModel) Init() tea.Cmd {
- return m.spinner.Tick
-}
-
-func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- m.quitting = true
- return m, tea.Quit
- case spinner.TickMsg:
- var cmd tea.Cmd
- m.spinner, cmd = m.spinner.Update(msg)
- return m, cmd
- case quitMsg:
- m.quitting = true
- return m, tea.Quit
- default:
- return m, nil
- }
-}
-
-func (m spinnerModel) View() string {
- if m.quitting {
- return ""
- }
- return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
-}
-
-// quitMsg is sent when we want to quit the spinner
-type quitMsg struct{}
-
-// NewSpinner creates a new spinner with the given message
-func NewSpinner(message string) *Spinner {
- s := spinner.New()
- s.Spinner = spinner.Dot
- s.Style = s.Style.Foreground(s.Style.GetForeground())
-
- ctx, cancel := context.WithCancel(context.Background())
-
- model := spinnerModel{
- spinner: s,
- message: message,
- }
-
- prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
-
- return &Spinner{
- model: s,
- done: make(chan struct{}),
- prog: prog,
- ctx: ctx,
- cancel: cancel,
- }
-}
-
-// NewThemedSpinner creates a new spinner with the given message and color
-func NewThemedSpinner(message string, color lipgloss.AdaptiveColor) *Spinner {
- s := spinner.New()
- s.Spinner = spinner.Dot
- s.Style = s.Style.Foreground(color)
-
- ctx, cancel := context.WithCancel(context.Background())
-
- model := spinnerModel{
- spinner: s,
- message: message,
- }
-
- prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
-
- return &Spinner{
- model: s,
- done: make(chan struct{}),
- prog: prog,
- ctx: ctx,
- cancel: cancel,
- }
-}
-
-// Start begins the spinner animation
-func (s *Spinner) Start() {
- go func() {
- defer close(s.done)
- go func() {
- <-s.ctx.Done()
- s.prog.Send(quitMsg{})
- }()
- _, err := s.prog.Run()
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
- }
- }()
-}
-
-// Stop ends the spinner animation
-func (s *Spinner) Stop() {
- s.cancel()
- <-s.done
-} \ No newline at end of file
diff --git a/internal/tui/components/spinner/spinner_test.go b/internal/tui/components/spinner/spinner_test.go
deleted file mode 100644
index 065726e91..000000000
--- a/internal/tui/components/spinner/spinner_test.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package spinner
-
-import (
- "testing"
- "time"
-)
-
-func TestSpinner(t *testing.T) {
- t.Parallel()
-
- // Create a spinner
- s := NewSpinner("Test spinner")
-
- // Start the spinner
- s.Start()
-
- // Wait a bit to let it run
- time.Sleep(100 * time.Millisecond)
-
- // Stop the spinner
- s.Stop()
-
- // If we got here without panicking, the test passes
-} \ No newline at end of file
diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go
deleted file mode 100644
index 7b8c4b1cb..000000000
--- a/internal/tui/components/util/simple-list.go
+++ /dev/null
@@ -1,159 +0,0 @@
-package utilComponents
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/tui/layout"
- "github.com/sst/opencode/internal/tui/styles"
- "github.com/sst/opencode/internal/tui/theme"
-)
-
-type SimpleListItem interface {
- Render(selected bool, width int) string
-}
-
-type SimpleList[T SimpleListItem] interface {
- tea.Model
- layout.Bindings
- SetMaxWidth(maxWidth int)
- GetSelectedItem() (item T, idx int)
- SetItems(items []T)
- GetItems() []T
-}
-
-type simpleListCmp[T SimpleListItem] struct {
- fallbackMsg string
- items []T
- selectedIdx int
- maxWidth int
- maxVisibleItems int
- useAlphaNumericKeys bool
- width int
- height int
-}
-
-type simpleListKeyMap struct {
- Up key.Binding
- Down key.Binding
- UpAlpha key.Binding
- DownAlpha key.Binding
-}
-
-var simpleListKeys = simpleListKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up"),
- key.WithHelp("↑", "previous list item"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down"),
- key.WithHelp("↓", "next list item"),
- ),
- UpAlpha: key.NewBinding(
- key.WithKeys("k"),
- key.WithHelp("k", "previous list item"),
- ),
- DownAlpha: key.NewBinding(
- key.WithKeys("j"),
- key.WithHelp("j", "next list item"),
- ),
-}
-
-func (c *simpleListCmp[T]) Init() tea.Cmd {
- return nil
-}
-
-func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
- if c.selectedIdx > 0 {
- c.selectedIdx--
- }
- return c, nil
- case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
- if c.selectedIdx < len(c.items)-1 {
- c.selectedIdx++
- }
- return c, nil
- }
- }
-
- return c, nil
-}
-
-func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(simpleListKeys)
-}
-
-func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
- if len(c.items) > 0 {
- return c.items[c.selectedIdx], c.selectedIdx
- }
-
- var zero T
- return zero, -1
-}
-
-func (c *simpleListCmp[T]) SetItems(items []T) {
- c.selectedIdx = 0
- c.items = items
-}
-
-func (c *simpleListCmp[T]) GetItems() []T {
- return c.items
-}
-
-func (c *simpleListCmp[T]) SetMaxWidth(width int) {
- c.maxWidth = width
-}
-
-func (c *simpleListCmp[T]) View() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- items := c.items
- maxWidth := c.maxWidth
- maxVisibleItems := min(c.maxVisibleItems, len(items))
- startIdx := 0
-
- if len(items) <= 0 {
- return baseStyle.
- Background(t.Background()).
- Padding(0, 1).
- Width(maxWidth).
- Render(c.fallbackMsg)
- }
-
- if len(items) > maxVisibleItems {
- halfVisible := maxVisibleItems / 2
- if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
- startIdx = c.selectedIdx - halfVisible
- } else if c.selectedIdx >= len(items)-halfVisible {
- startIdx = len(items) - maxVisibleItems
- }
- }
-
- endIdx := min(startIdx+maxVisibleItems, len(items))
-
- listItems := make([]string, 0, maxVisibleItems)
-
- for i := startIdx; i < endIdx; i++ {
- item := items[i]
- title := item.Render(i == c.selectedIdx, maxWidth)
- listItems = append(listItems, title)
- }
-
- return lipgloss.JoinVertical(lipgloss.Left, listItems...)
-}
-
-func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
- return &simpleListCmp[T]{
- fallbackMsg: fallbackMsg,
- items: items,
- maxVisibleItems: maxVisibleItems,
- useAlphaNumericKeys: useAlphaNumericKeys,
- selectedIdx: 0,
- }
-}