summaryrefslogtreecommitdiffhomepage
path: root/pkg/tui/page
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-05-18 22:30:41 -0400
committerDax Raad <[email protected]>2025-05-26 12:40:17 -0400
commit99af6146d5def31c59993636d60eb75a483a283b (patch)
treeeb57ed227c15cf9be54bc9f231c83895d812f881 /pkg/tui/page
parent020e0ca039287b73fa33041fbd1bb214e6ccb396 (diff)
downloadopencode-99af6146d5def31c59993636d60eb75a483a283b.tar.gz
opencode-99af6146d5def31c59993636d60eb75a483a283b.zip
openapi
Diffstat (limited to 'pkg/tui/page')
-rw-r--r--pkg/tui/page/chat.go263
-rw-r--r--pkg/tui/page/logs.go224
-rw-r--r--pkg/tui/page/page.go8
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
+}