openapi
This commit is contained in:
263
pkg/tui/page/chat.go
Normal file
263
pkg/tui/page/chat.go
Normal file
@@ -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),
|
||||
),
|
||||
}
|
||||
}
|
||||
224
pkg/tui/page/logs.go
Normal file
224
pkg/tui/page/logs.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
8
pkg/tui/page/page.go
Normal file
8
pkg/tui/page/page.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package page
|
||||
|
||||
type PageID string
|
||||
|
||||
// PageChangeMsg is used to change the current page
|
||||
type PageChangeMsg struct {
|
||||
ID PageID
|
||||
}
|
||||
Reference in New Issue
Block a user