diff options
| author | Dax Raad <[email protected]> | 2025-05-18 22:30:41 -0400 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2025-05-26 12:40:17 -0400 |
| commit | 99af6146d5def31c59993636d60eb75a483a283b (patch) | |
| tree | eb57ed227c15cf9be54bc9f231c83895d812f881 /pkg/tui/page | |
| parent | 020e0ca039287b73fa33041fbd1bb214e6ccb396 (diff) | |
| download | opencode-99af6146d5def31c59993636d60eb75a483a283b.tar.gz opencode-99af6146d5def31c59993636d60eb75a483a283b.zip | |
openapi
Diffstat (limited to 'pkg/tui/page')
| -rw-r--r-- | pkg/tui/page/chat.go | 263 | ||||
| -rw-r--r-- | pkg/tui/page/logs.go | 224 | ||||
| -rw-r--r-- | pkg/tui/page/page.go | 8 |
3 files changed, 495 insertions, 0 deletions
diff --git a/pkg/tui/page/chat.go b/pkg/tui/page/chat.go new file mode 100644 index 000000000..1b31c838c --- /dev/null +++ b/pkg/tui/page/chat.go @@ -0,0 +1,263 @@ +package page + +import ( + "context" + "fmt" + "strings" + + "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" + "github.com/sst/opencode/internal/tui/components/chat" + "github.com/sst/opencode/internal/tui/components/dialog" + "github.com/sst/opencode/internal/tui/layout" + "github.com/sst/opencode/internal/tui/state" + "github.com/sst/opencode/internal/tui/util" +) + +var ChatPage PageID = "chat" + +type chatPage struct { + 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 + ShowCompletionDialog key.Binding +} + +var keyMap = ChatKeyMap{ + NewSession: key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new session"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + ToggleTools: key.NewBinding( + 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...) +} + +func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := p.layout.SetSize(msg.Width, msg.Height) + cmds = append(cmds, cmd) + case chat.SendMsg: + cmd := p.sendMessage(msg.Text, msg.Attachments) + if cmd != nil { + return p, cmd + } + case dialog.CommandRunCustomMsg: + // Check if the agent is busy before executing custom commands + if p.app.PrimaryAgent.IsBusy() { + status.Warn("Agent is busy, please wait before executing a command...") + return p, nil + } + + // Process the command content with arguments if any + content := msg.Content + if msg.Args != nil { + // Replace all named arguments with their values + for name, value := range msg.Args { + placeholder := "$" + name + content = strings.ReplaceAll(content, placeholder, value) + } + } + + // Handle custom command execution + cmd := p.sendMessage(content, nil) + if cmd != nil { + return p, cmd + } + case state.SessionSelectedMsg: + cmd := p.setSidebar() + cmds = append(cmds, cmd) + case state.SessionClearedMsg: + cmd := p.setSidebar() + cmds = append(cmds, cmd) + case state.CompactSessionMsg: + if p.app.CurrentSession.ID == "" { + status.Warn("No active session to compact.") + return p, nil + } + + // Run compaction in background + go func(sessionID string) { + err := p.app.PrimaryAgent.CompactSession(context.Background(), sessionID, false) + if err != nil { + status.Error(fmt.Sprintf("Compaction failed: %v", err)) + } else { + status.Info("Conversation compacted successfully.") + } + }(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( + p.clearSidebar(), + util.CmdHandler(state.SessionClearedMsg{}), + ) + case key.Matches(msg, keyMap.Cancel): + if p.app.CurrentSession.ID != "" { + // Cancel the current session's generation process + // This allows users to interrupt long-running operations + p.app.PrimaryAgent.Cancel(p.app.CurrentSession.ID) + return p, nil + } + case key.Matches(msg, keyMap.ToggleTools): + 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) + return p, tea.Batch(cmds...) +} + +func (p *chatPage) setSidebar() tea.Cmd { + sidebarContainer := layout.NewContainer( + chat.NewSidebarCmp(p.app), + layout.WithPadding(1, 1, 1, 1), + ) + return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init()) +} + +func (p *chatPage) clearSidebar() tea.Cmd { + return p.layout.ClearRightPanel() +} + +func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd { + var cmds []tea.Cmd + if p.app.CurrentSession.ID == "" { + newSession, err := p.app.Sessions.Create(context.Background(), "New Session") + if err != nil { + status.Error(err.Error()) + return nil + } + + p.app.CurrentSession = &newSession + + cmd := p.setSidebar() + if cmd != nil { + cmds = append(cmds, cmd) + } + cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(&newSession))) + } + + _, err := p.app.PrimaryAgent.Run(context.Background(), p.app.CurrentSession.ID, text, attachments...) + if err != nil { + status.Error(err.Error()) + return nil + } + return tea.Batch(cmds...) +} + +func (p *chatPage) SetSize(width, height int) tea.Cmd { + return p.layout.SetSize(width, height) +} + +func (p *chatPage) GetSize() (int, int) { + return p.layout.GetSize() +} + +func (p *chatPage) View() string { + 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 { + bindings := layout.KeyMapToSlice(keyMap) + bindings = append(bindings, p.messages.BindingKeys()...) + bindings = append(bindings, p.editor.BindingKeys()...) + return bindings +} + +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), + ) + editorContainer := layout.NewContainer( + chat.NewEditorCmp(app), + layout.WithBorder(true, false, false, false), + ) + return &chatPage{ + app: app, + editor: editorContainer, + messages: messagesContainer, + completionDialog: completionDialog, + layout: layout.NewSplitPane( + layout.WithLeftPanel(messagesContainer), + layout.WithBottomPanel(editorContainer), + ), + } +} diff --git a/pkg/tui/page/logs.go b/pkg/tui/page/logs.go new file mode 100644 index 000000000..df5fb2c1b --- /dev/null +++ b/pkg/tui/page/logs.go @@ -0,0 +1,224 @@ +package page + +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/tui/components/logs" + "github.com/sst/opencode/internal/tui/layout" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" +) + +var LogsPage PageID = "logs" + +type LogPage interface { + tea.Model + layout.Sizeable + layout.Bindings +} + +// Custom keybindings for logs page +type logsKeyMap struct { + Left key.Binding + Right key.Binding + Tab key.Binding +} + +var logsKeys = logsKeyMap{ + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "left pane"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "right pane"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch panes"), + ), +} + +type logsPage struct { + width, height int + table layout.Container + details layout.Container + activePane int // 0 = table, 1 = details + keyMap logsKeyMap +} + +// Message to switch active pane +type switchPaneMsg struct { + pane int // 0 = table, 1 = details +} + +func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + p.width = msg.Width + p.height = msg.Height + return p, p.SetSize(msg.Width, msg.Height) + case switchPaneMsg: + p.activePane = msg.pane + if p.activePane == 0 { + p.table.Focus() + p.details.Blur() + } else { + p.table.Blur() + p.details.Focus() + } + return p, nil + case tea.KeyMsg: + // Handle navigation keys + switch { + case key.Matches(msg, p.keyMap.Left): + return p, func() tea.Msg { + return switchPaneMsg{pane: 0} + } + case key.Matches(msg, p.keyMap.Right): + return p, func() tea.Msg { + return switchPaneMsg{pane: 1} + } + case key.Matches(msg, p.keyMap.Tab): + return p, func() tea.Msg { + return switchPaneMsg{pane: (p.activePane + 1) % 2} + } + } + } + + // Update the active pane first to handle keyboard input + if p.activePane == 0 { + table, cmd := p.table.Update(msg) + cmds = append(cmds, cmd) + p.table = table.(layout.Container) + + // Update details pane without focus + details, cmd := p.details.Update(msg) + cmds = append(cmds, cmd) + p.details = details.(layout.Container) + } else { + details, cmd := p.details.Update(msg) + cmds = append(cmds, cmd) + p.details = details.(layout.Container) + + // Update table pane without focus + table, cmd := p.table.Update(msg) + cmds = append(cmds, cmd) + p.table = table.(layout.Container) + } + + return p, tea.Batch(cmds...) +} + +func (p *logsPage) View() string { + t := theme.CurrentTheme() + + // Add padding to the right of the table view + tableView := lipgloss.NewStyle().PaddingRight(3).Render(p.table.View()) + + // Add border to the active pane + tableStyle := lipgloss.NewStyle() + detailsStyle := lipgloss.NewStyle() + + if p.activePane == 0 { + tableStyle = tableStyle.BorderForeground(t.Primary()) + } else { + detailsStyle = detailsStyle.BorderForeground(t.Primary()) + } + + tableView = tableStyle.Render(tableView) + detailsView := detailsStyle.Render(p.details.View()) + + return styles.ForceReplaceBackgroundWithLipgloss( + lipgloss.JoinVertical( + lipgloss.Left, + styles.Bold().Render(" esc")+styles.Muted().Render(" to go back")+ + " "+styles.Bold().Render(" tab/←→/h/l")+styles.Muted().Render(" to switch panes"), + "", + lipgloss.JoinHorizontal(lipgloss.Top, + tableView, + detailsView, + ), + "", + ), + t.Background(), + ) +} + +func (p *logsPage) BindingKeys() []key.Binding { + // Add our custom keybindings + bindings := []key.Binding{ + p.keyMap.Left, + p.keyMap.Right, + p.keyMap.Tab, + } + + // Add the active pane's keybindings + if p.activePane == 0 { + bindings = append(bindings, p.table.BindingKeys()...) + } else { + bindings = append(bindings, p.details.BindingKeys()...) + } + + return bindings +} + +// GetSize implements LogPage. +func (p *logsPage) GetSize() (int, int) { + return p.width, p.height +} + +// SetSize implements LogPage. +func (p *logsPage) SetSize(width int, height int) tea.Cmd { + p.width = width + p.height = height + + // Account for padding between panes (3 characters) + const padding = 3 + leftPaneWidth := (width - padding) / 2 + rightPaneWidth := width - leftPaneWidth - padding + + return tea.Batch( + p.table.SetSize(leftPaneWidth, height-3), + p.details.SetSize(rightPaneWidth, height-3), + ) +} + +func (p *logsPage) Init() tea.Cmd { + // Start with table pane active + p.activePane = 0 + p.table.Focus() + p.details.Blur() + + // Force an initial selection to update the details pane + var cmds []tea.Cmd + cmds = append(cmds, p.table.Init()) + cmds = append(cmds, p.details.Init()) + + // Send a key down and then key up to select the first row + // This ensures the details pane is populated when returning to the logs page + cmds = append(cmds, func() tea.Msg { + return tea.KeyMsg{Type: tea.KeyDown} + }) + cmds = append(cmds, func() tea.Msg { + return tea.KeyMsg{Type: tea.KeyUp} + }) + + return tea.Batch(cmds...) +} + +func NewLogsPage(app *app.App) tea.Model { + // Create containers with borders to visually indicate active pane + tableContainer := layout.NewContainer(logs.NewLogsTable(app), layout.WithBorderHorizontal()) + detailsContainer := layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderHorizontal()) + + return &logsPage{ + table: tableContainer, + details: detailsContainer, + activePane: 0, // Start with table pane active + keyMap: logsKeys, + } +} diff --git a/pkg/tui/page/page.go b/pkg/tui/page/page.go new file mode 100644 index 000000000..482df5fd7 --- /dev/null +++ b/pkg/tui/page/page.go @@ -0,0 +1,8 @@ +package page + +type PageID string + +// PageChangeMsg is used to change the current page +type PageChangeMsg struct { + ID PageID +} |
