diff options
| author | Kujtim Hoxha <[email protected]> | 2025-04-21 19:48:36 +0200 |
|---|---|---|
| committer | Kujtim Hoxha <[email protected]> | 2025-04-21 19:53:55 +0200 |
| commit | 3a6a26981a8074b6ab0eaadb520db986e04799ff (patch) | |
| tree | 4fe2c022305f13775f2cab3cdd80cd808259765b /internal/tui | |
| parent | d7569d79c6da1437fe46343ed13810df6c8cae1f (diff) | |
| download | opencode-3a6a26981a8074b6ab0eaadb520db986e04799ff.tar.gz opencode-3a6a26981a8074b6ab0eaadb520db986e04799ff.zip | |
init command
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/components/dialog/commands.go | 247 | ||||
| -rw-r--r-- | internal/tui/components/dialog/init.go | 191 | ||||
| -rw-r--r-- | internal/tui/tui.go | 175 |
3 files changed, 611 insertions, 2 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 +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 392b9ec41..4a723d40d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/kujtimiihoxha/opencode/internal/app" + "github.com/kujtimiihoxha/opencode/internal/config" "github.com/kujtimiihoxha/opencode/internal/logging" "github.com/kujtimiihoxha/opencode/internal/permission" "github.com/kujtimiihoxha/opencode/internal/pubsub" @@ -23,6 +24,7 @@ type keyMap struct { Quit key.Binding Help key.Binding SwitchSession key.Binding + Commands key.Binding } var keys = keyMap{ @@ -44,6 +46,11 @@ var keys = keyMap{ key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "switch session"), ), + + Commands: key.NewBinding( + key.WithKeys("ctrl+k"), + key.WithHelp("ctrl+K", "commands"), + ), } var helpEsc = key.NewBinding( @@ -82,6 +89,13 @@ type appModel struct { showSessionDialog bool sessionDialog dialog.SessionDialog + showCommandDialog bool + commandDialog dialog.CommandDialog + commands []dialog.Command + + showInitDialog bool + initDialog dialog.InitDialogCmp + editingMode bool } @@ -98,6 +112,23 @@ func (a appModel) Init() tea.Cmd { cmds = append(cmds, cmd) cmd = a.sessionDialog.Init() cmds = append(cmds, cmd) + cmd = a.commandDialog.Init() + cmds = append(cmds, cmd) + cmd = a.initDialog.Init() + cmds = append(cmds, cmd) + + // Check if we should show the init dialog + cmds = append(cmds, func() tea.Msg { + shouldShow, err := config.ShouldShowInitDialog() + if err != nil { + return util.InfoMsg{ + Type: util.InfoTypeError, + Msg: "Failed to check init status: " + err.Error(), + } + } + return dialog.ShowInitDialogMsg{Show: shouldShow} + }) + return tea.Batch(cmds...) } @@ -126,6 +157,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.sessionDialog = session.(dialog.SessionDialog) cmds = append(cmds, sessionCmd) + command, commandCmd := a.commandDialog.Update(msg) + a.commandDialog = command.(dialog.CommandDialog) + cmds = append(cmds, commandCmd) + + a.initDialog.SetSize(msg.Width, msg.Height) + return a, tea.Batch(cmds...) case chat.EditorFocusMsg: a.editingMode = bool(msg) @@ -207,6 +244,35 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showSessionDialog = false return a, nil + case dialog.CloseCommandDialogMsg: + a.showCommandDialog = false + return a, nil + + case dialog.ShowInitDialogMsg: + a.showInitDialog = msg.Show + return a, nil + + case dialog.CloseInitDialogMsg: + a.showInitDialog = false + if msg.Initialize { + // Run the initialization command + for _, cmd := range a.commands { + if cmd.ID == "init" { + // Mark the project as initialized + if err := config.MarkProjectInitialized(); err != nil { + return a, util.ReportError(err) + } + return a, cmd.Handler(cmd) + } + } + } else { + // Mark the project as initialized without running the command + if err := config.MarkProjectInitialized(); err != nil { + return a, util.ReportError(err) + } + } + return a, nil + case chat.SessionSelectedMsg: a.sessionDialog.SetSelectedSession(msg.ID) case dialog.SessionSelectedMsg: @@ -216,6 +282,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case dialog.CommandSelectedMsg: + a.showCommandDialog = false + // Execute the command handler if available + if msg.Command.Handler != nil { + return a, msg.Command.Handler(msg.Command) + } + return a, util.ReportInfo("Command selected: " + msg.Command.Title) + case tea.KeyMsg: switch { case key.Matches(msg, keys.Quit): @@ -226,9 +300,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showSessionDialog { a.showSessionDialog = false } + if a.showCommandDialog { + a.showCommandDialog = false + } return a, nil case key.Matches(msg, keys.SwitchSession): - if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions { + if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog { // Load sessions and show the dialog sessions, err := a.app.Sessions.List(context.Background()) if err != nil { @@ -242,6 +319,17 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } return a, nil + case key.Matches(msg, keys.Commands): + if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog { + // Show commands dialog + if len(a.commands) == 0 { + return a, util.ReportWarn("No commands available") + } + a.commandDialog.SetCommands(a.commands) + a.showCommandDialog = true + return a, nil + } + return a, nil case key.Matches(msg, logsKeyReturnKey): if a.currentPage == page.LogsPage { return a, a.moveToPage(page.ChatPage) @@ -255,6 +343,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showHelp = !a.showHelp return a, nil } + if a.showInitDialog { + a.showInitDialog = false + // Mark the project as initialized without running the command + if err := config.MarkProjectInitialized(); err != nil { + return a, util.ReportError(err) + } + return a, nil + } case key.Matches(msg, keys.Logs): return a, a.moveToPage(page.LogsPage) case key.Matches(msg, keys.Help): @@ -304,6 +400,26 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + if a.showCommandDialog { + d, commandCmd := a.commandDialog.Update(msg) + a.commandDialog = d.(dialog.CommandDialog) + cmds = append(cmds, commandCmd) + // Only block key messages send all other messages down + if _, ok := msg.(tea.KeyMsg); ok { + return a, tea.Batch(cmds...) + } + } + + if a.showInitDialog { + d, initCmd := a.initDialog.Update(msg) + a.initDialog = d.(dialog.InitDialogCmp) + cmds = append(cmds, initCmd) + // Only block key messages send all other messages down + if _, ok := msg.(tea.KeyMsg); ok { + return a, tea.Batch(cmds...) + } + } + s, _ := a.status.Update(msg) a.status = s.(core.StatusCmp) a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) @@ -311,6 +427,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } +// RegisterCommand adds a command to the command dialog +func (a *appModel) RegisterCommand(cmd dialog.Command) { + a.commands = append(a.commands, cmd) +} + func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { if a.app.CoderAgent.IsBusy() { // For now we don't move to any page if the agent is busy @@ -422,24 +543,74 @@ func (a appModel) View() string { ) } + if a.showCommandDialog { + overlay := a.commandDialog.View() + row := lipgloss.Height(appView) / 2 + row -= lipgloss.Height(overlay) / 2 + col := lipgloss.Width(appView) / 2 + col -= lipgloss.Width(overlay) / 2 + appView = layout.PlaceOverlay( + col, + row, + overlay, + appView, + true, + ) + } + + if a.showInitDialog { + overlay := a.initDialog.View() + appView = layout.PlaceOverlay( + a.width/2-lipgloss.Width(overlay)/2, + a.height/2-lipgloss.Height(overlay)/2, + overlay, + appView, + true, + ) + } + return appView } func New(app *app.App) tea.Model { startPage := page.ChatPage - return &appModel{ + model := &appModel{ currentPage: startPage, loadedPages: make(map[page.PageID]bool), status: core.NewStatusCmp(app.LSPClients), help: dialog.NewHelpCmp(), quit: dialog.NewQuitCmp(), sessionDialog: dialog.NewSessionDialogCmp(), + commandDialog: dialog.NewCommandDialogCmp(), permissions: dialog.NewPermissionDialogCmp(), + initDialog: dialog.NewInitDialogCmp(), app: app, editingMode: true, + commands: []dialog.Command{}, pages: map[page.PageID]tea.Model{ page.ChatPage: page.NewChatPage(app), page.LogsPage: page.NewLogsPage(), }, } + + model.RegisterCommand(dialog.Command{ + ID: "init", + Title: "Initialize Project", + Description: "Create/Update the OpenCode.md memory file", + Handler: func(cmd dialog.Command) tea.Cmd { + prompt := `Please analyze this codebase and create a OpenCode.md file containing: +1. Build/lint/test commands - especially for running a single test +2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. + +The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long. +If there's already a opencode.md, improve it. +If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.` + return tea.Batch( + util.CmdHandler(chat.SendMsg{ + Text: prompt, + }), + ) + }, + }) + return model } |
