summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/components
diff options
context:
space:
mode:
authorKujtim Hoxha <[email protected]>2025-04-21 19:48:36 +0200
committerKujtim Hoxha <[email protected]>2025-04-21 19:53:55 +0200
commit3a6a26981a8074b6ab0eaadb520db986e04799ff (patch)
tree4fe2c022305f13775f2cab3cdd80cd808259765b /internal/tui/components
parentd7569d79c6da1437fe46343ed13810df6c8cae1f (diff)
downloadopencode-3a6a26981a8074b6ab0eaadb520db986e04799ff.tar.gz
opencode-3a6a26981a8074b6ab0eaadb520db986e04799ff.zip
init command
Diffstat (limited to 'internal/tui/components')
-rw-r--r--internal/tui/components/dialog/commands.go247
-rw-r--r--internal/tui/components/dialog/init.go191
2 files changed, 438 insertions, 0 deletions
diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go
new file mode 100644
index 000000000..7b25caeb0
--- /dev/null
+++ b/internal/tui/components/dialog/commands.go
@@ -0,0 +1,247 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/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
+}
+
+// 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)
+ SetSelectedCommand(commandID string)
+}
+
+type commandDialogCmp struct {
+ commands []Command
+ selectedIdx int
+ width int
+ height int
+ selectedCommandID string
+}
+
+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"),
+ ),
+ Escape: key.NewBinding(
+ 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
+}
+
+func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, 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 {
+ return c, util.CmdHandler(CommandSelectedMsg{
+ Command: c.commands[c.selectedIdx],
+ })
+ }
+ case key.Matches(msg, commandKeys.Escape):
+ return c, util.CmdHandler(CloseCommandDialogMsg{})
+ }
+ case tea.WindowSizeMsg:
+ c.width = msg.Width
+ c.height = msg.Height
+ }
+ return c, nil
+}
+
+func (c *commandDialogCmp) View() string {
+ if len(c.commands) == 0 {
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(40).
+ Render("No commands available")
+ }
+
+ // 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
+ 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 := styles.BaseStyle.Width(maxWidth)
+ descStyle := styles.BaseStyle.Width(maxWidth).Foreground(styles.ForgroundDim)
+
+ if i == c.selectedIdx {
+ itemStyle = itemStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ descStyle = descStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.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)
+ }
+ }
+
+ title := styles.BaseStyle.
+ Foreground(styles.PrimaryColor).
+ Bold(true).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Commands")
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ title,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ styles.BaseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ styles.BaseStyle.Width(maxWidth).Padding(0, 1).Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"),
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
+}
+
+func (c *commandDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(commandKeys)
+}
+
+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
+ }
+ }
+ }
+}
+
+// NewCommandDialogCmp creates a new command selection dialog
+func NewCommandDialogCmp() CommandDialog {
+ return &commandDialogCmp{
+ commands: []Command{},
+ selectedIdx: 0,
+ selectedCommandID: "",
+ }
+}
+
diff --git a/internal/tui/components/dialog/init.go b/internal/tui/components/dialog/init.go
new file mode 100644
index 000000000..6098ca755
--- /dev/null
+++ b/internal/tui/components/dialog/init.go
@@ -0,0 +1,191 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/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"),
+ key.WithHelp("esc", "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 {
+ // Calculate width needed for content
+ maxWidth := 60 // Width for explanation text
+
+ title := styles.BaseStyle.
+ Foreground(styles.PrimaryColor).
+ Bold(true).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Initialize Project")
+
+ explanation := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Initialization generates a new OpenCode.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 := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(maxWidth).
+ Padding(1, 1).
+ Render("Would you like to initialize this project?")
+
+ yesStyle := styles.BaseStyle
+ noStyle := styles.BaseStyle
+
+ if m.selected == 0 {
+ yesStyle = yesStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ noStyle = noStyle.
+ Background(styles.Background).
+ Foreground(styles.PrimaryColor)
+ } else {
+ noStyle = noStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ yesStyle = yesStyle.
+ Background(styles.Background).
+ Foreground(styles.PrimaryColor)
+ }
+
+ yes := yesStyle.Padding(0, 3).Render("Yes")
+ no := noStyle.Padding(0, 3).Render("No")
+
+ buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, styles.BaseStyle.Render(" "), no)
+ buttons = styles.BaseStyle.
+ Width(maxWidth).
+ Padding(1, 0).
+ Render(buttons)
+
+ help := styles.BaseStyle.
+ Width(maxWidth).
+ Padding(0, 1).
+ Foreground(styles.ForgroundDim).
+ Render("tab/←/→: toggle y/n: yes/no enter: confirm esc: cancel")
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ title,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ explanation,
+ question,
+ buttons,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ help,
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ 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
+}