mirror of
https://github.com/aljazceru/opencode.git
synced 2026-01-10 11:24:59 +01:00
feat(complete-module): add completions logic, dialog and providers
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
@@ -148,6 +149,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, 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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -18,6 +19,33 @@ type Command struct {
|
||||
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
|
||||
@@ -31,35 +59,20 @@ type CommandDialog interface {
|
||||
tea.Model
|
||||
layout.Bindings
|
||||
SetCommands(commands []Command)
|
||||
SetSelectedCommand(commandID string)
|
||||
}
|
||||
|
||||
type commandDialogCmp struct {
|
||||
commands []Command
|
||||
selectedIdx int
|
||||
width int
|
||||
height int
|
||||
selectedCommandID string
|
||||
listView utilComponents.SimpleList[Command]
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
type commandKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
J key.Binding
|
||||
K key.Binding
|
||||
}
|
||||
|
||||
var commandKeys = commandKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "previous command"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("↓", "next command"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select command"),
|
||||
@@ -68,38 +81,22 @@ var commandKeys = commandKeyMap{
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "close"),
|
||||
),
|
||||
J: key.NewBinding(
|
||||
key.WithKeys("j"),
|
||||
key.WithHelp("j", "next command"),
|
||||
),
|
||||
K: key.NewBinding(
|
||||
key.WithKeys("k"),
|
||||
key.WithHelp("k", "previous command"),
|
||||
),
|
||||
}
|
||||
|
||||
func (c *commandDialogCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
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.Up) || key.Matches(msg, commandKeys.K):
|
||||
if c.selectedIdx > 0 {
|
||||
c.selectedIdx--
|
||||
}
|
||||
return c, nil
|
||||
case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
|
||||
if c.selectedIdx < len(c.commands)-1 {
|
||||
c.selectedIdx++
|
||||
}
|
||||
return c, nil
|
||||
case key.Matches(msg, commandKeys.Enter):
|
||||
if len(c.commands) > 0 {
|
||||
selectedItem, idx := c.listView.GetSelectedItem()
|
||||
if idx != -1 {
|
||||
return c, util.CmdHandler(CommandSelectedMsg{
|
||||
Command: c.commands[c.selectedIdx],
|
||||
Command: selectedItem,
|
||||
})
|
||||
}
|
||||
case key.Matches(msg, commandKeys.Escape):
|
||||
@@ -109,78 +106,35 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
c.width = msg.Width
|
||||
c.height = msg.Height
|
||||
}
|
||||
return c, nil
|
||||
|
||||
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()
|
||||
|
||||
if len(c.commands) == 0 {
|
||||
return baseStyle.Padding(1, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.TextMuted()).
|
||||
Width(40).
|
||||
Render("No commands available")
|
||||
}
|
||||
maxWidth := 40
|
||||
|
||||
// Calculate max width needed for command titles
|
||||
maxWidth := 40 // Minimum width
|
||||
for _, cmd := range c.commands {
|
||||
if len(cmd.Title) > maxWidth-4 { // Account for padding
|
||||
commands := c.listView.GetItems()
|
||||
|
||||
for _, cmd := range commands {
|
||||
if len(cmd.Title) > maxWidth-4 {
|
||||
maxWidth = len(cmd.Title) + 4
|
||||
}
|
||||
if len(cmd.Description) > maxWidth-4 {
|
||||
maxWidth = len(cmd.Description) + 4
|
||||
}
|
||||
}
|
||||
|
||||
// Limit height to avoid taking up too much screen space
|
||||
maxVisibleCommands := min(10, len(c.commands))
|
||||
|
||||
// Build the command list
|
||||
commandItems := make([]string, 0, maxVisibleCommands)
|
||||
startIdx := 0
|
||||
|
||||
// If we have more commands than can be displayed, adjust the start index
|
||||
if len(c.commands) > maxVisibleCommands {
|
||||
// Center the selected item when possible
|
||||
halfVisible := maxVisibleCommands / 2
|
||||
if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
|
||||
startIdx = c.selectedIdx - halfVisible
|
||||
} else if c.selectedIdx >= len(c.commands)-halfVisible {
|
||||
startIdx = len(c.commands) - maxVisibleCommands
|
||||
}
|
||||
}
|
||||
|
||||
endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
|
||||
|
||||
for i := startIdx; i < endIdx; i++ {
|
||||
cmd := c.commands[i]
|
||||
itemStyle := baseStyle.Width(maxWidth)
|
||||
descStyle := baseStyle.Width(maxWidth).Foreground(t.TextMuted())
|
||||
|
||||
if i == c.selectedIdx {
|
||||
itemStyle = itemStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.Background()).
|
||||
Bold(true)
|
||||
descStyle = descStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.Background())
|
||||
}
|
||||
|
||||
title := itemStyle.Padding(0, 1).Render(cmd.Title)
|
||||
description := ""
|
||||
if cmd.Description != "" {
|
||||
description = descStyle.Padding(0, 1).Render(cmd.Description)
|
||||
commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
|
||||
} else {
|
||||
commandItems = append(commandItems, title)
|
||||
if len(cmd.Description) > maxWidth-4 {
|
||||
maxWidth = len(cmd.Description) + 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.listView.SetMaxWidth(maxWidth)
|
||||
|
||||
title := baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
Bold(true).
|
||||
@@ -192,7 +146,7 @@ func (c *commandDialogCmp) View() string {
|
||||
lipgloss.Left,
|
||||
title,
|
||||
baseStyle.Width(maxWidth).Render(""),
|
||||
baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
|
||||
baseStyle.Width(maxWidth).Render(c.listView.View()),
|
||||
baseStyle.Width(maxWidth).Render(""),
|
||||
)
|
||||
|
||||
@@ -209,41 +163,18 @@ func (c *commandDialogCmp) BindingKeys() []key.Binding {
|
||||
}
|
||||
|
||||
func (c *commandDialogCmp) SetCommands(commands []Command) {
|
||||
c.commands = commands
|
||||
|
||||
// If we have a selected command ID, find its index
|
||||
if c.selectedCommandID != "" {
|
||||
for i, cmd := range commands {
|
||||
if cmd.ID == c.selectedCommandID {
|
||||
c.selectedIdx = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to first command if selected not found
|
||||
c.selectedIdx = 0
|
||||
}
|
||||
|
||||
func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
|
||||
c.selectedCommandID = commandID
|
||||
|
||||
// Update the selected index if commands are already loaded
|
||||
if len(c.commands) > 0 {
|
||||
for i, cmd := range c.commands {
|
||||
if cmd.ID == commandID {
|
||||
c.selectedIdx = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
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{
|
||||
commands: []Command{},
|
||||
selectedIdx: 0,
|
||||
selectedCommandID: "",
|
||||
listView: listView,
|
||||
}
|
||||
}
|
||||
|
||||
265
internal/tui/components/dialog/complete.go
Normal file
265
internal/tui/components/dialog/complete.go
Normal file
@@ -0,0 +1,265 @@
|
||||
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/logging"
|
||||
"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 {
|
||||
logging.Info("Query", query)
|
||||
items, err := c.completionProvider.GetChildEntries(query)
|
||||
if err != nil {
|
||||
logging.Error("Failed to get child entries", err)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
159
internal/tui/components/util/simple-list.go
Normal file
159
internal/tui/components/util/simple-list.go
Normal file
@@ -0,0 +1,159 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/session"
|
||||
"github.com/sst/opencode/internal/status"
|
||||
@@ -20,16 +22,19 @@ import (
|
||||
var ChatPage PageID = "chat"
|
||||
|
||||
type chatPage struct {
|
||||
app *app.App
|
||||
editor layout.Container
|
||||
messages layout.Container
|
||||
layout layout.SplitPaneLayout
|
||||
app *app.App
|
||||
editor layout.Container
|
||||
messages layout.Container
|
||||
layout layout.SplitPaneLayout
|
||||
completionDialog dialog.CompletionDialog
|
||||
showCompletionDialog bool
|
||||
}
|
||||
|
||||
type ChatKeyMap struct {
|
||||
NewSession key.Binding
|
||||
Cancel key.Binding
|
||||
ToggleTools key.Binding
|
||||
NewSession key.Binding
|
||||
Cancel key.Binding
|
||||
ToggleTools key.Binding
|
||||
ShowCompletionDialog key.Binding
|
||||
}
|
||||
|
||||
var keyMap = ChatKeyMap{
|
||||
@@ -45,12 +50,17 @@ var keyMap = ChatKeyMap{
|
||||
key.WithKeys("ctrl+h"),
|
||||
key.WithHelp("ctrl+h", "toggle tools"),
|
||||
),
|
||||
ShowCompletionDialog: key.NewBinding(
|
||||
key.WithKeys("@"),
|
||||
key.WithHelp("@", "Complete"),
|
||||
),
|
||||
}
|
||||
|
||||
func (p *chatPage) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{
|
||||
p.layout.Init(),
|
||||
}
|
||||
cmds = append(cmds, p.completionDialog.Init())
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
@@ -99,8 +109,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}(p.app.CurrentSession.ID)
|
||||
|
||||
return p, nil
|
||||
case dialog.CompletionDialogCloseMsg:
|
||||
p.showCompletionDialog = false
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, keyMap.ShowCompletionDialog):
|
||||
p.showCompletionDialog = true
|
||||
// Continue sending keys to layout->chat
|
||||
case key.Matches(msg, keyMap.NewSession):
|
||||
p.app.CurrentSession = &session.Session{}
|
||||
return p, tea.Batch(
|
||||
@@ -118,6 +133,19 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
|
||||
}
|
||||
}
|
||||
if p.showCompletionDialog {
|
||||
context, contextCmd := p.completionDialog.Update(msg)
|
||||
p.completionDialog = context.(dialog.CompletionDialog)
|
||||
cmds = append(cmds, contextCmd)
|
||||
|
||||
// Doesn't forward event if enter key is pressed
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.String() == "enter" {
|
||||
return p, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u, cmd := p.layout.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
p.layout = u.(layout.SplitPaneLayout)
|
||||
@@ -171,7 +199,25 @@ func (p *chatPage) GetSize() (int, int) {
|
||||
}
|
||||
|
||||
func (p *chatPage) View() string {
|
||||
return p.layout.View()
|
||||
layoutView := p.layout.View()
|
||||
|
||||
if p.showCompletionDialog {
|
||||
_, layoutHeight := p.layout.GetSize()
|
||||
editorWidth, editorHeight := p.editor.GetSize()
|
||||
|
||||
p.completionDialog.SetWidth(editorWidth)
|
||||
overlay := p.completionDialog.View()
|
||||
|
||||
layoutView = layout.PlaceOverlay(
|
||||
0,
|
||||
layoutHeight-editorHeight-lipgloss.Height(overlay),
|
||||
overlay,
|
||||
layoutView,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
return layoutView
|
||||
}
|
||||
|
||||
func (p *chatPage) BindingKeys() []key.Binding {
|
||||
@@ -182,6 +228,8 @@ func (p *chatPage) BindingKeys() []key.Binding {
|
||||
}
|
||||
|
||||
func NewChatPage(app *app.App) tea.Model {
|
||||
cg := completions.NewFileAndFolderContextGroup()
|
||||
completionDialog := dialog.NewCompletionDialogCmp(cg)
|
||||
messagesContainer := layout.NewContainer(
|
||||
chat.NewMessagesCmp(app),
|
||||
layout.WithPadding(1, 1, 0, 1),
|
||||
@@ -191,9 +239,10 @@ func NewChatPage(app *app.App) tea.Model {
|
||||
layout.WithBorder(true, false, false, false),
|
||||
)
|
||||
return &chatPage{
|
||||
app: app,
|
||||
editor: editorContainer,
|
||||
messages: messagesContainer,
|
||||
app: app,
|
||||
editor: editorContainer,
|
||||
messages: messagesContainer,
|
||||
completionDialog: completionDialog,
|
||||
layout: layout.NewSplitPane(
|
||||
layout.WithLeftPanel(messagesContainer),
|
||||
layout.WithBottomPanel(editorContainer),
|
||||
|
||||
Reference in New Issue
Block a user