From 67023bb00710b6a40836800da2eb5cdacc1ee9c1 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:56:30 -0500 Subject: wip: refactoring tui --- packages/tui/internal/completions/commands.go | 71 +++++++++ packages/tui/internal/completions/files-folders.go | 20 ++- packages/tui/internal/completions/manager.go | 29 ++++ packages/tui/internal/components/chat/editor.go | 38 +++-- packages/tui/internal/components/chat/message.go | 114 +++++++------- packages/tui/internal/components/chat/messages.go | 3 +- .../tui/internal/components/dialog/complete.go | 57 ++++--- packages/tui/internal/components/dialog/session.go | 8 +- packages/tui/internal/components/dialog/theme.go | 8 +- packages/tui/internal/components/list/list.go | 157 ++++++++++++++++++++ .../tui/internal/components/util/simple-list.go | 163 --------------------- packages/tui/internal/layout/container.go | 6 + packages/tui/internal/page/chat.go | 28 +++- packages/tui/internal/tui/tui.go | 30 +++- 14 files changed, 456 insertions(+), 276 deletions(-) create mode 100644 packages/tui/internal/completions/commands.go create mode 100644 packages/tui/internal/completions/manager.go create mode 100644 packages/tui/internal/components/list/list.go delete mode 100644 packages/tui/internal/components/util/simple-list.go diff --git a/packages/tui/internal/completions/commands.go b/packages/tui/internal/completions/commands.go new file mode 100644 index 000000000..3bb28ac9b --- /dev/null +++ b/packages/tui/internal/completions/commands.go @@ -0,0 +1,71 @@ +package completions + +import ( + "sort" + + "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/components/dialog" +) + +type CommandCompletionProvider struct { + app *app.App +} + +func NewCommandCompletionProvider(app *app.App) dialog.CompletionProvider { + return &CommandCompletionProvider{app: app} +} + +func (c *CommandCompletionProvider) GetId() string { + return "commands" +} + +func (c *CommandCompletionProvider) GetEntry() dialog.CompletionItemI { + return dialog.NewCompletionItem(dialog.CompletionItem{ + Title: "Commands", + Value: "commands", + }) +} + +func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { + if query == "" { + // If no query, return all commands + items := []dialog.CompletionItemI{} + for _, cmd := range c.app.Commands { + items = append(items, dialog.NewCompletionItem(dialog.CompletionItem{ + Title: " /" + cmd.Name, + Value: "/" + cmd.Name, + })) + } + return items, nil + } + + // Use fuzzy matching for commands + var commandNames []string + commandMap := make(map[string]dialog.CompletionItemI) + + for _, cmd := range c.app.Commands { + commandNames = append(commandNames, cmd.Name) + commandMap[cmd.Name] = dialog.NewCompletionItem(dialog.CompletionItem{ + Title: " /" + cmd.Name, + Value: "/" + cmd.Name, + }) + } + + // Find fuzzy matches + matches := fuzzy.RankFind(query, commandNames) + + // Sort by score (best matches first) + sort.Sort(matches) + + // Convert matches to completion items + items := []dialog.CompletionItemI{} + for _, match := range matches { + if item, ok := commandMap[match.Target]; ok { + items = append(items, item) + } + } + + return items, nil +} + diff --git a/packages/tui/internal/completions/files-folders.go b/packages/tui/internal/completions/files-folders.go index a0c5260de..0e6a4e4b6 100644 --- a/packages/tui/internal/completions/files-folders.go +++ b/packages/tui/internal/completions/files-folders.go @@ -1,10 +1,15 @@ package completions import ( + "context" + + "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/pkg/client" ) type filesAndFoldersContextGroup struct { + app *app.App prefix string } @@ -20,7 +25,17 @@ func (cg *filesAndFoldersContextGroup) GetEntry() dialog.CompletionItemI { } func (cg *filesAndFoldersContextGroup) getFiles(query string) ([]string, error) { - return []string{}, nil + response, err := cg.app.Client.PostFileSearchWithResponse(context.Background(), client.PostFileSearchJSONRequestBody{ + Query: query, + }) + if err != nil { + return []string{}, err + } + if response.JSON200 == nil { + return []string{}, nil + } + + return *response.JSON200, nil } func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.CompletionItemI, error) { @@ -41,8 +56,9 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(query string) ([]dialog.C return items, nil } -func NewFileAndFolderContextGroup() dialog.CompletionProvider { +func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider { return &filesAndFoldersContextGroup{ + app: app, prefix: "file", } } diff --git a/packages/tui/internal/completions/manager.go b/packages/tui/internal/completions/manager.go new file mode 100644 index 000000000..19e532f45 --- /dev/null +++ b/packages/tui/internal/completions/manager.go @@ -0,0 +1,29 @@ +package completions + +import ( + "strings" + + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/components/dialog" +) + +type CompletionManager struct { + providers map[string]dialog.CompletionProvider +} + +func NewCompletionManager(app *app.App) *CompletionManager { + return &CompletionManager{ + providers: map[string]dialog.CompletionProvider{ + "files": NewFileAndFolderContextGroup(app), + "commands": NewCommandCompletionProvider(app), + }, + } +} + +func (m *CompletionManager) GetProvider(input string) dialog.CompletionProvider { + if strings.HasPrefix(input, "/") { + return m.providers["commands"] + } + return m.providers["files"] +} + diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 2e89a7db4..95ca3e14b 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -44,11 +44,6 @@ type EditorKeyMaps struct { HistoryDown key.Binding } -type bluredEditorKeyMaps struct { - Send key.Binding - Focus key.Binding - OpenEditor key.Binding -} type DeleteAttachmentKeyMaps struct { AttachmentDeleteMode key.Binding Escape key.Binding @@ -108,10 +103,18 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 + if msg.IsCommand { + // Execute the command directly + commandName := strings.TrimPrefix(msg.CompletionValue, "/") + m.textarea.Reset() + return m, util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName}) + } else { + // For files, replace the text in the editor + existingValue := m.textarea.Value() + modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1) + m.textarea.SetValue(modifiedValue) + return m, nil + } case tea.KeyMsg: switch msg.String() { case "ctrl+c": @@ -378,12 +381,13 @@ func (m *editorComponent) send() tea.Cmd { } // Check for slash command - if strings.HasPrefix(value, "/") { - commandName := strings.TrimPrefix(value, "/") - if _, ok := m.app.Commands[commandName]; ok { - return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName}) - } - } + // if strings.HasPrefix(value, "/") { + // commandName := strings.TrimPrefix(value, "/") + // if _, ok := m.app.Commands[commandName]; ok { + // return util.CmdHandler(commands.ExecuteCommandMsg{Name: commandName}) + // } + // } + slog.Info("Send message", "value", value) return tea.Batch( util.CmdHandler(SendMsg{ @@ -452,6 +456,10 @@ func createTextArea(existing *textarea.Model) textarea.Model { return ta } +func (m *editorComponent) GetValue() string { + return m.textarea.Value() +} + func NewEditorComponent(app *app.App) layout.ModelWithView { s := spinner.New(spinner.WithSpinner(spinner.Ellipsis), spinner.WithStyle(styles.Muted().Width(3))) ta := createTextArea(nil) diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index 44aceea93..3315d4ba3 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -216,7 +216,7 @@ func renderText(message client.MessageInfo, text string, author string) string { align = lipgloss.Left } - textWidth := lipgloss.Width(text) + textWidth := max(lipgloss.Width(text), lipgloss.Width(info)) markdownWidth := min(textWidth, width-padding-4) // -4 for the border and padding content := toMarkdown(text, markdownWidth, t.BackgroundSubtle()) content = lipgloss.JoinVertical(align, content, info) @@ -299,13 +299,9 @@ func renderToolInvocation( body := "" error := "" finished := result != nil && *result != "" - if finished { - body = *result - } if e, ok := metadata.Get("error"); ok && e.(bool) == true { if m, ok := metadata.Get("message"); ok { - body = "" // don't show the body if there's an error style = style.BorderLeftForeground(t.Error()) error = styles.BaseStyle(). Background(t.BackgroundSubtle()). @@ -336,58 +332,61 @@ func renderToolInvocation( case "opencode_read": toolArgs = renderArgs(&toolArgsMap, "filePath") title = fmt.Sprintf("Read: %s %s", toolArgs, elapsed) - body = "" if preview, ok := metadata.Get("preview"); ok && toolArgsMap["filePath"] != nil { filename := toolArgsMap["filePath"].(string) body = preview.(string) body = renderFile(filename, body, WithTruncate(6)) } case "opencode_edit": - filename := toolArgsMap["filePath"].(string) - title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed) - if d, ok := metadata.Get("diff"); ok { - patch := d.(string) - var formattedDiff string - if layout.Current.Viewport.Width < 80 { - formattedDiff, _ = diff.FormatUnifiedDiff( - filename, - patch, - diff.WithWidth(layout.Current.Container.Width-2), + if filename, ok := toolArgsMap["filePath"].(string); ok { + title = fmt.Sprintf("Edit: %s %s", relative(filename), elapsed) + if d, ok := metadata.Get("diff"); ok { + patch := d.(string) + var formattedDiff string + if layout.Current.Viewport.Width < 80 { + formattedDiff, _ = diff.FormatUnifiedDiff( + filename, + patch, + diff.WithWidth(layout.Current.Container.Width-2), + ) + } else { + diffWidth := min(layout.Current.Viewport.Width-2, 120) + formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth)) + } + formattedDiff = strings.TrimSpace(formattedDiff) + formattedDiff = lipgloss.NewStyle(). + BorderStyle(lipgloss.ThickBorder()). + BorderForeground(t.BackgroundSubtle()). + BorderLeft(true). + BorderRight(true). + Render(formattedDiff) + + if showResult { + style = style.Width(lipgloss.Width(formattedDiff)) + title += "\n" + } + + body = strings.TrimSpace(formattedDiff) + body = lipgloss.Place( + layout.Current.Viewport.Width, + lipgloss.Height(body)+1, + lipgloss.Center, + lipgloss.Top, + body, ) - } else { - diffWidth := min(layout.Current.Viewport.Width-2, 120) - formattedDiff, _ = diff.FormatDiff(filename, patch, diff.WithTotalWidth(diffWidth)) - } - formattedDiff = strings.TrimSpace(formattedDiff) - formattedDiff = lipgloss.NewStyle(). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(t.BackgroundSubtle()). - BorderLeft(true). - BorderRight(true). - Render(formattedDiff) - - if showResult { - style = style.Width(lipgloss.Width(formattedDiff)) - title += "\n" } - - body = strings.TrimSpace(formattedDiff) - body = lipgloss.Place( - layout.Current.Viewport.Width, - lipgloss.Height(body)+1, - lipgloss.Center, - lipgloss.Top, - body, - ) } case "opencode_write": - filename := toolArgsMap["filePath"].(string) - title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed) - content := toolArgsMap["content"].(string) - body = renderFile(filename, content) + if filename, ok := toolArgsMap["filePath"].(string); ok { + title = fmt.Sprintf("Write: %s %s", relative(filename), elapsed) + if content, ok := toolArgsMap["content"].(string); ok { + body = renderFile(filename, content) + } + } case "opencode_bash": - description := toolArgsMap["description"].(string) - title = fmt.Sprintf("Shell: %s %s", description, elapsed) + if description, ok := toolArgsMap["description"].(string); ok { + title = fmt.Sprintf("Shell: %s %s", description, elapsed) + } if stdout, ok := metadata.Get("stdout"); ok { command := toolArgsMap["command"].(string) stdout := stdout.(string) @@ -396,18 +395,20 @@ func renderToolInvocation( body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) } case "opencode_webfetch": + toolArgs = renderArgs(&toolArgsMap, "url") title = fmt.Sprintf("Fetching: %s %s", toolArgs, elapsed) - format := toolArgsMap["format"].(string) - body = truncateHeight(body, 10) - if format == "html" || format == "markdown" { - body = toMarkdown(body, innerWidth, t.BackgroundSubtle()) + if format, ok := toolArgsMap["format"].(string); ok { + body = *result + body = truncateHeight(body, 10) + if format == "html" || format == "markdown" { + body = toMarkdown(body, innerWidth, t.BackgroundSubtle()) + } + body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) } - body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) case "opencode_todowrite": title = fmt.Sprintf("Planning %s", elapsed) if to, ok := metadata.Get("todos"); ok && finished { - body = "" todos := to.([]any) for _, todo := range todos { t := todo.(map[string]any) @@ -416,7 +417,7 @@ func renderToolInvocation( case "completed": body += fmt.Sprintf("- [x] %s\n", content) // case "in-progress": - // body += fmt.Sprintf("- [ ] _%s_\n", content) + // body += fmt.Sprintf("- [ ] %s\n", content) default: body += fmt.Sprintf("- [ ] %s\n", content) } @@ -427,6 +428,13 @@ func renderToolInvocation( default: toolName := renderToolName(toolCall.ToolName) title = fmt.Sprintf("%s: %s %s", toolName, toolArgs, elapsed) + body = *result + body = truncateHeight(body, 10) + body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) + } + + if body == "" && error == "" { + body = *result body = truncateHeight(body, 10) body = renderContentBlock(body, WithFullWidth(), WithMarginBottom(1)) } diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 8ab11a54a..83c3898a7 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -245,7 +245,7 @@ func (m *messagesComponent) header() string { base := styles.BaseStyle().Render muted := styles.Muted().Render headerLines := []string{} - headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-4, t.Background())) + headerLines = append(headerLines, toMarkdown("# "+m.app.Session.Title, width-6, t.Background())) if m.app.Session.Share != nil && m.app.Session.Share.Url != "" { headerLines = append(headerLines, muted(m.app.Session.Share.Url)) } else { @@ -256,6 +256,7 @@ func (m *messagesComponent) header() string { header = styles.BaseStyle(). Width(width). PaddingLeft(2). + PaddingRight(2). // Background(t.BackgroundElement()). BorderLeft(true). BorderRight(true). diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index 8819184ac..fbf5c79f1 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -5,7 +5,7 @@ import ( "github.com/charmbracelet/bubbles/v2/textarea" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" - utilComponents "github.com/sst/opencode/internal/components/util" + "github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/styles" @@ -20,7 +20,7 @@ type CompletionItem struct { } type CompletionItemI interface { - utilComponents.ListItem + list.ListItem GetValue() string DisplayValue() string } @@ -30,18 +30,18 @@ func (ci *CompletionItem) Render(selected bool, width int) string { baseStyle := styles.BaseStyle() itemStyle := baseStyle. + Background(t.BackgroundElement()). Width(width). Padding(0, 1) if selected { itemStyle = itemStyle. - Background(t.Background()). Foreground(t.Primary()). Bold(true) } title := itemStyle.Render( - ci.GetValue(), + ci.DisplayValue(), ) return title @@ -68,6 +68,7 @@ type CompletionProvider interface { type CompletionSelectedMsg struct { SearchString string CompletionValue string + IsCommand bool } type CompletionDialogCompleteItemMsg struct { @@ -80,6 +81,7 @@ type CompletionDialog interface { layout.ModelWithView SetWidth(width int) IsEmpty() bool + SetProvider(provider CompletionProvider) } type completionDialogComponent struct { @@ -88,7 +90,7 @@ type completionDialogComponent struct { width int height int pseudoSearchTextArea textarea.Model - list utilComponents.List[CompletionItemI] + list list.List[CompletionItemI] } type completionDialogKeyMap struct { @@ -116,10 +118,14 @@ func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd { return nil } + // Check if this is a command completion + isCommand := c.completionProvider.GetId() == "commands" + return tea.Batch( util.CmdHandler(CompletionSelectedMsg{ SearchString: value, CompletionValue: item.GetValue(), + IsCommand: isCommand, }), c.close(), ) @@ -160,7 +166,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } u, cmd := c.list.Update(msg) - c.list = u.(utilComponents.List[CompletionItemI]) + c.list = u.(list.List[CompletionItemI]) cmds = append(cmds, cmd) } @@ -171,8 +177,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if i == -1 { return c, nil } - cmd := c.complete(item) - return c, cmd + return c, c.complete(item) 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 { @@ -203,21 +208,20 @@ func (c *completionDialogComponent) View() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() - // maxWidth := 40 - // - // completions := c.list.GetItems() + maxWidth := 40 + completions := c.list.GetItems() - // for _, cmd := range completions { - // title := cmd.DisplayValue() - // if len(title) > maxWidth-4 { - // maxWidth = len(title) + 4 - // } - // } + for _, cmd := range completions { + title := cmd.DisplayValue() + if len(title) > maxWidth-4 { + maxWidth = len(title) + 4 + } + } - // c.list.SetMaxWidth(maxWidth) + c.list.SetMaxWidth(maxWidth) return baseStyle.Padding(0, 0). - Background(t.BackgroundSubtle()). + Background(t.BackgroundElement()). Border(lipgloss.ThickBorder()). BorderTop(false). BorderBottom(false). @@ -236,6 +240,17 @@ func (c *completionDialogComponent) IsEmpty() bool { return c.list.IsEmpty() } +func (c *completionDialogComponent) SetProvider(provider CompletionProvider) { + if c.completionProvider.GetId() != provider.GetId() { + c.completionProvider = provider + items, err := provider.GetChildEntries("") + if err != nil { + status.Error(err.Error()) + } + c.list.SetItems(items) + } +} + func NewCompletionDialogComponent(completionProvider CompletionProvider) CompletionDialog { ti := textarea.New() @@ -244,10 +259,10 @@ func NewCompletionDialogComponent(completionProvider CompletionProvider) Complet status.Error(err.Error()) } - li := utilComponents.NewListComponent( + li := list.NewListComponent( items, 7, - "No matching files", + "No matches", false, ) diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go index 4a41c71f3..48278d85e 100644 --- a/packages/tui/internal/components/dialog/session.go +++ b/packages/tui/internal/components/dialog/session.go @@ -5,8 +5,8 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/modal" - components "github.com/sst/opencode/internal/components/util" "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/state" "github.com/sst/opencode/internal/styles" @@ -48,7 +48,7 @@ type sessionDialog struct { height int modal *modal.Modal selectedSessionID string - list components.List[sessionItem] + list list.List[sessionItem] } func (s *sessionDialog) Init() tea.Cmd { @@ -77,7 +77,7 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd listModel, cmd := s.list.Update(msg) - s.list = listModel.(components.List[sessionItem]) + s.list = listModel.(list.List[sessionItem]) return s, cmd } @@ -98,7 +98,7 @@ func NewSessionDialog(app *app.App) SessionDialog { sessionItems = append(sessionItems, sessionItem{session: sess}) } - list := components.NewListComponent( + list := list.NewListComponent( sessionItems, 10, // maxVisibleSessions "No sessions available", diff --git a/packages/tui/internal/components/dialog/theme.go b/packages/tui/internal/components/dialog/theme.go index 2febc1fe0..f045b1b09 100644 --- a/packages/tui/internal/components/dialog/theme.go +++ b/packages/tui/internal/components/dialog/theme.go @@ -2,8 +2,8 @@ package dialog import ( tea "github.com/charmbracelet/bubbletea/v2" + list "github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/modal" - components "github.com/sst/opencode/internal/components/util" "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/styles" @@ -49,7 +49,7 @@ type themeDialog struct { height int modal *modal.Modal - list components.List[themeItem] + list list.List[themeItem] } func (t *themeDialog) Init() tea.Cmd { @@ -84,7 +84,7 @@ func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd listModel, cmd := t.list.Update(msg) - t.list = listModel.(components.List[themeItem]) + t.list = listModel.(list.List[themeItem]) return t, cmd } @@ -110,7 +110,7 @@ func NewThemeDialog() ThemeDialog { } } - list := components.NewListComponent( + list := list.NewListComponent( themeItems, 10, // maxVisibleThemes "No themes available", diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go new file mode 100644 index 000000000..8be3c7b01 --- /dev/null +++ b/packages/tui/internal/components/list/list.go @@ -0,0 +1,157 @@ +package list + +import ( + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/sst/opencode/internal/layout" +) + +type ListItem interface { + Render(selected bool, width int) string +} + +type List[T ListItem] interface { + layout.ModelWithView + SetMaxWidth(maxWidth int) + GetSelectedItem() (item T, idx int) + SetItems(items []T) + GetItems() []T + SetSelectedIndex(idx int) + IsEmpty() bool +} + +type listComponent[T ListItem] struct { + fallbackMsg string + items []T + selectedIdx int + maxWidth int + maxVisibleItems int + useAlphaNumericKeys bool + width int + height int +} + +type listKeyMap struct { + Up key.Binding + Down key.Binding + UpAlpha key.Binding + DownAlpha key.Binding +} + +var simpleListKeys = listKeyMap{ + 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 *listComponent[T]) Init() tea.Cmd { + return nil +} + +func (c *listComponent[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 *listComponent[T]) GetSelectedItem() (T, int) { + if len(c.items) > 0 { + return c.items[c.selectedIdx], c.selectedIdx + } + + var zero T + return zero, -1 +} + +func (c *listComponent[T]) SetItems(items []T) { + c.selectedIdx = 0 + c.items = items +} + +func (c *listComponent[T]) GetItems() []T { + return c.items +} + +func (c *listComponent[T]) IsEmpty() bool { + return len(c.items) == 0 +} + +func (c *listComponent[T]) SetMaxWidth(width int) { + c.maxWidth = width +} + +func (c *listComponent[T]) SetSelectedIndex(idx int) { + if idx >= 0 && idx < len(c.items) { + c.selectedIdx = idx + } +} + +func (c *listComponent[T]) View() string { + items := c.items + maxWidth := c.maxWidth + maxVisibleItems := min(c.maxVisibleItems, len(items)) + startIdx := 0 + + if len(items) <= 0 { + return 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 NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] { + return &listComponent[T]{ + fallbackMsg: fallbackMsg, + items: items, + maxVisibleItems: maxVisibleItems, + useAlphaNumericKeys: useAlphaNumericKeys, + selectedIdx: 0, + } +} diff --git a/packages/tui/internal/components/util/simple-list.go b/packages/tui/internal/components/util/simple-list.go deleted file mode 100644 index 6df4e313e..000000000 --- a/packages/tui/internal/components/util/simple-list.go +++ /dev/null @@ -1,163 +0,0 @@ -package utilComponents - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/sst/opencode/internal/layout" - "github.com/sst/opencode/internal/styles" -) - -type ListItem interface { - Render(selected bool, width int) string -} - -type List[T ListItem] interface { - layout.ModelWithView - SetMaxWidth(maxWidth int) - GetSelectedItem() (item T, idx int) - SetItems(items []T) - GetItems() []T - SetSelectedIndex(idx int) - IsEmpty() bool -} - -type listComponent[T ListItem] struct { - fallbackMsg string - items []T - selectedIdx int - maxWidth int - maxVisibleItems int - useAlphaNumericKeys bool - width int - height int -} - -type listKeyMap struct { - Up key.Binding - Down key.Binding - UpAlpha key.Binding - DownAlpha key.Binding -} - -var simpleListKeys = listKeyMap{ - 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 *listComponent[T]) Init() tea.Cmd { - return nil -} - -func (c *listComponent[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 *listComponent[T]) GetSelectedItem() (T, int) { - if len(c.items) > 0 { - return c.items[c.selectedIdx], c.selectedIdx - } - - var zero T - return zero, -1 -} - -func (c *listComponent[T]) SetItems(items []T) { - c.selectedIdx = 0 - c.items = items -} - -func (c *listComponent[T]) GetItems() []T { - return c.items -} - -func (c *listComponent[T]) IsEmpty() bool { - return len(c.items) == 0 -} - -func (c *listComponent[T]) SetMaxWidth(width int) { - c.maxWidth = width -} - -func (c *listComponent[T]) SetSelectedIndex(idx int) { - if idx >= 0 && idx < len(c.items) { - c.selectedIdx = idx - } -} - -func (c *listComponent[T]) View() string { - baseStyle := styles.BaseStyle() - - items := c.items - maxWidth := c.maxWidth - maxVisibleItems := min(c.maxVisibleItems, len(items)) - startIdx := 0 - - if len(items) <= 0 { - return baseStyle. - 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 NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] { - return &listComponent[T]{ - fallbackMsg: fallbackMsg, - items: items, - maxVisibleItems: maxVisibleItems, - useAlphaNumericKeys: useAlphaNumericKeys, - selectedIdx: 0, - } -} diff --git a/packages/tui/internal/layout/container.go b/packages/tui/internal/layout/container.go index c57b7bd7e..b1310f498 100644 --- a/packages/tui/internal/layout/container.go +++ b/packages/tui/internal/layout/container.go @@ -19,6 +19,7 @@ type Container interface { MaxWidth() int Alignment() lipgloss.Position GetPosition() (x, y int) + GetContent() ModelWithView } type container struct { @@ -177,6 +178,11 @@ func (c *container) GetPosition() (x, y int) { return c.x, c.y } +// GetContent returns the content of the container +func (c *container) GetContent() ModelWithView { + return c.content +} + type ContainerOption func(*container) func NewContainer(content ModelWithView, options ...ContainerOption) Container { diff --git a/packages/tui/internal/page/chat.go b/packages/tui/internal/page/chat.go index a117e80f5..d97171e16 100644 --- a/packages/tui/internal/page/chat.go +++ b/packages/tui/internal/page/chat.go @@ -22,6 +22,7 @@ type chatPage struct { messages layout.Container layout layout.FlexLayout completionDialog dialog.CompletionDialog + completionManager *completions.CompletionManager showCompletionDialog bool } @@ -94,13 +95,20 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if p.showCompletionDialog { + // Get the current text from the editor to determine which provider to use + editorModel := p.editor.GetContent().(interface{ GetValue() string }) + currentInput := editorModel.GetValue() + + provider := p.completionManager.GetProvider(currentInput) + p.completionDialog.SetProvider(provider) + context, contextCmd := p.completionDialog.Update(msg) p.completionDialog = context.(dialog.CompletionDialog) cmds = append(cmds, contextCmd) - // Doesn't forward event if enter key is pressed + // Doesn't forward event if enter key is pressed and there are completions if keyMsg, ok := msg.(tea.KeyMsg); ok { - if keyMsg.String() == "enter" && !p.completionDialog.IsEmpty() { + if keyMsg.String() == "enter" { // && !p.completionDialog.IsEmpty() { return p, tea.Batch(cmds...) } } @@ -149,8 +157,10 @@ func (p *chatPage) View() string { } func NewChatPage(app *app.App) layout.ModelWithView { - cg := completions.NewFileAndFolderContextGroup() - completionDialog := dialog.NewCompletionDialogComponent(cg) + completionManager := completions.NewCompletionManager(app) + initialProvider := completionManager.GetProvider("") + completionDialog := dialog.NewCompletionDialogComponent(initialProvider) + messagesContainer := layout.NewContainer( chat.NewMessagesComponent(app), ) @@ -159,11 +169,13 @@ func NewChatPage(app *app.App) layout.ModelWithView { layout.WithMaxWidth(layout.Current.Container.Width), layout.WithAlignCenter(), ) + return &chatPage{ - app: app, - editor: editorContainer, - messages: messagesContainer, - completionDialog: completionDialog, + app: app, + editor: editorContainer, + messages: messagesContainer, + completionDialog: completionDialog, + completionManager: completionManager, layout: layout.NewFlexLayout( layout.WithPanes(messagesContainer, editorContainer), layout.WithDirection(layout.FlexDirectionVertical), diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 70cbf3b47..b678d553b 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -2,6 +2,7 @@ package tui import ( "context" + "log/slog" "github.com/charmbracelet/bubbles/v2/cursor" "github.com/charmbracelet/bubbles/v2/key" @@ -78,33 +79,52 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd if a.modal != nil { - isModalTrigger := false + bypassModal := false + if _, ok := msg.(modal.CloseModalMsg); ok { a.modal = nil return a, nil } + if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { case "esc": a.modal = nil return a, nil case "ctrl+c": - if _, ok := a.modal.(dialog.QuitDialog); !ok { + if _, ok := a.modal.(dialog.QuitDialog); ok { + return a, tea.Quit + } else { quitDialog := dialog.NewQuitDialog() a.modal = quitDialog return a, nil } } + // don't send commands to the modal for _, cmdDef := range a.app.Commands { if key.Matches(msg, cmdDef.KeyBinding) { - isModalTrigger = true + bypassModal = true break } } } - if !isModalTrigger { + // thanks i hate this + switch msg.(type) { + case tea.WindowSizeMsg: + bypassModal = true + case client.EventSessionUpdated: + bypassModal = true + case client.EventMessageUpdated: + bypassModal = true + case cursor.BlinkMsg: + bypassModal = true + case spinner.TickMsg: + bypassModal = true + } + + if !bypassModal { updatedModal, cmd := a.modal.Update(msg) a.modal = updatedModal.(layout.Modal) return a, cmd @@ -112,7 +132,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { - case commands.ExecuteCommandMsg: switch msg.Name { case "quit": @@ -143,6 +162,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { helpDialog := dialog.NewHelpDialog(helpBindings...) a.modal = helpDialog } + slog.Info("Execute command", "cmds", cmds) return a, tea.Batch(cmds...) case tea.BackgroundColorMsg: -- cgit v1.2.3