summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-06-12 09:35:19 -0500
committeradamdottv <[email protected]>2025-06-12 16:00:25 -0500
commitca0ea3f94dc1929071efc93bfcf708c8cf054be8 (patch)
tree294ec6778af8a712ae50ab5442c13ae95b51d337
parent98bd5109c20f7e83c2fd2c5ef0dc28796fc47315 (diff)
downloadopencode-ca0ea3f94dc1929071efc93bfcf708c8cf054be8.tar.gz
opencode-ca0ea3f94dc1929071efc93bfcf708c8cf054be8.zip
wip: refactoring tui
-rw-r--r--packages/tui/internal/components/chat/message.go12
-rw-r--r--packages/tui/internal/components/chat/messages.go14
-rw-r--r--packages/tui/internal/components/dialog/permission.go6
-rw-r--r--packages/tui/internal/components/dialog/session.go103
-rw-r--r--packages/tui/internal/components/modal/modal.go198
-rw-r--r--packages/tui/internal/tui/tui.go13
6 files changed, 275 insertions, 71 deletions
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
index 199b4a070..f3fbca475 100644
--- a/packages/tui/internal/components/chat/message.go
+++ b/packages/tui/internal/components/chat/message.go
@@ -258,16 +258,18 @@ func renderToolInvocation(
}
body := ""
+ error := ""
finished := result != nil && *result != ""
if finished {
body = *result
}
if metadata["error"] != nil && metadata["message"] != nil {
- body = styles.BaseStyle().
- Width(outerWidth).
+ body = ""
+ error = styles.BaseStyle().
Foreground(t.Error()).
Render(metadata["message"].(string))
+ error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
}
elapsed := ""
@@ -364,10 +366,12 @@ func renderToolInvocation(
content := style.Render(title)
content = lipgloss.PlaceHorizontal(layout.Current.Viewport.Width, lipgloss.Center, content)
- // content = styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
- if showResult && body != "" {
+ if showResult && body != "" && error == "" {
content += "\n" + body
}
+ if showResult && error != "" {
+ content += "\n" + error
+ }
return content
}
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index 0036d0669..47a9b8362 100644
--- a/packages/tui/internal/components/chat/messages.go
+++ b/packages/tui/internal/components/chat/messages.go
@@ -122,6 +122,7 @@ const (
userTextBlock
assistantTextBlock
toolInvocationBlock
+ errorBlock
)
func (m *messagesComponent) renderView() {
@@ -129,6 +130,7 @@ func (m *messagesComponent) renderView() {
return
}
+ t := theme.CurrentTheme()
blocks := make([]string, 0)
previousBlockType := none
for _, message := range m.app.Messages {
@@ -211,9 +213,19 @@ func (m *messagesComponent) renderView() {
previousBlockType = toolInvocationBlock
}
}
+
+ error := ""
+ errorValue, _ := message.Metadata.Error.ValueByDiscriminator()
+ switch errorValue.(type) {
+ case client.UnknownError:
+ clientError := errorValue.(client.UnknownError)
+ error = clientError.Data.Message
+ error = renderContentBlock(error, WithBorderColor(t.Error()), WithFullWidth(), WithPaddingTop(1), WithPaddingBottom(1))
+ blocks = append(blocks, error)
+ previousBlockType = errorBlock
+ }
}
- t := theme.CurrentTheme()
centered := []string{}
for _, block := range blocks {
centered = append(centered, lipgloss.PlaceHorizontal(
diff --git a/packages/tui/internal/components/dialog/permission.go b/packages/tui/internal/components/dialog/permission.go
index 526e03483..12df79884 100644
--- a/packages/tui/internal/components/dialog/permission.go
+++ b/packages/tui/internal/components/dialog/permission.go
@@ -257,7 +257,7 @@ func (p *permissionDialogComponent) renderBashContent() string {
// renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
// r := styles.GetMarkdownRenderer(p.width - 10)
// s, err := r.Render(content)
- // return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
+ // return s
// })
//
// finalContent := baseStyle.
@@ -317,7 +317,7 @@ func (p *permissionDialogComponent) renderFetchContent() string {
// renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
// r := styles.GetMarkdownRenderer(p.width - 10)
// s, err := r.Render(content)
- // return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
+ // return s
// })
//
// finalContent := baseStyle.
@@ -339,7 +339,7 @@ func (p *permissionDialogComponent) renderDefaultContent() string {
// renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
// r := styles.GetMarkdownRenderer(p.width - 10)
// s, err := r.Render(content)
- // return styles.ForceReplaceBackgroundWithLipgloss(s, t.Background()), err
+ // return s
// })
//
// finalContent := baseStyle.
diff --git a/packages/tui/internal/components/dialog/session.go b/packages/tui/internal/components/dialog/session.go
index dc58d2c9a..6c4db997f 100644
--- a/packages/tui/internal/components/dialog/session.go
+++ b/packages/tui/internal/components/dialog/session.go
@@ -3,7 +3,7 @@ package dialog
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
+ "github.com/sst/opencode/internal/components/modal"
utilComponents "github.com/sst/opencode/internal/components/util"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
@@ -19,10 +19,11 @@ type CloseSessionDialogMsg struct {
// SessionDialog interface for the session switching dialog
type SessionDialog interface {
- layout.ModelWithView
+ tea.Model
layout.Bindings
SetSessions(sessions []client.SessionInfo)
SetSelectedSession(sessionID string)
+ Render(background string) string
}
type sessionItem struct {
@@ -48,7 +49,8 @@ func (s sessionItem) Render(selected bool, width int) string {
return baseStyle.Padding(0, 1).Render(s.session.Title)
}
-type sessionDialogComponent struct {
+// sessionDialogContent is the inner content of the session dialog
+type sessionDialogContent struct {
sessions []client.SessionInfo
width int
height int
@@ -72,11 +74,11 @@ var sessionKeys = sessionKeyMap{
),
}
-func (s *sessionDialogComponent) Init() tea.Cmd {
+func (s *sessionDialogContent) Init() tea.Cmd {
return nil
}
-func (s *sessionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *sessionDialogContent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.width = msg.Width
@@ -110,17 +112,14 @@ func (s *sessionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, cmd
}
-func (s *sessionDialogComponent) View() string {
+func (s *sessionDialogContent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle().Background(t.BackgroundElement())
- outerWidth := layout.Current.Container.Width - 8
- width := outerWidth - 4
+ width := layout.Current.Container.Width - 12
if len(s.sessions) == 0 {
return baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
+ Foreground(t.TextMuted()).
Width(width).
Render("No sessions available")
}
@@ -128,50 +127,46 @@ func (s *sessionDialogComponent) View() string {
// Set the max width for the list
s.list.SetMaxWidth(width)
- title := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Width(width).
- Padding(0, 1).
- Render("Switch Session")
-
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- title,
- s.list.View(),
- )
-
- style := styles.BaseStyle().
- PaddingTop(1).
- PaddingBottom(1).
- PaddingLeft(2).
- PaddingRight(2).
- Background(t.BackgroundElement()).
- Foreground(t.TextMuted()).
- BorderStyle(lipgloss.ThickBorder())
-
- style = style.
- BorderLeft(true).
- BorderRight(true).
- BorderLeftForeground(t.BackgroundSubtle()).
- BorderLeftBackground(t.Background()).
- BorderRightForeground(t.BackgroundSubtle()).
- BorderRightBackground(t.Background())
-
- return style.
- Width(outerWidth).
- Render(content)
+ return s.list.View()
}
-func (s *sessionDialogComponent) BindingKeys() []key.Binding {
+func (s *sessionDialogContent) BindingKeys() []key.Binding {
// Combine session dialog keys with list keys
dialogKeys := layout.KeyMapToSlice(sessionKeys)
listKeys := s.list.BindingKeys()
return append(dialogKeys, listKeys...)
}
+// sessionDialogComponent wraps the content with a modal
+type sessionDialogComponent struct {
+ content *sessionDialogContent
+ modal *modal.Modal
+}
+
+func (s *sessionDialogComponent) Init() tea.Cmd {
+ return s.modal.Init()
+}
+
+func (s *sessionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ m, cmd := s.modal.Update(msg)
+ s.modal = m.(*modal.Modal)
+ return s, cmd
+}
+
+func (s *sessionDialogComponent) View() string {
+ return s.modal.View()
+}
+
+func (s *sessionDialogComponent) Render(background string) string {
+ return s.modal.Render(background)
+}
+
+func (s *sessionDialogComponent) BindingKeys() []key.Binding {
+ return s.modal.BindingKeys()
+}
+
func (s *sessionDialogComponent) SetSessions(sessions []client.SessionInfo) {
- s.sessions = sessions
+ s.content.sessions = sessions
// Convert sessions to sessionItems
var sessionItems []sessionItem
@@ -180,16 +175,16 @@ func (s *sessionDialogComponent) SetSessions(sessions []client.SessionInfo) {
sessionItems = append(sessionItems, sessionItem{session: sess})
}
- s.list.SetItems(sessionItems)
+ s.content.list.SetItems(sessionItems)
}
func (s *sessionDialogComponent) SetSelectedSession(sessionID string) {
- s.selectedSessionID = sessionID
+ s.content.selectedSessionID = sessionID
// Update the selected index if sessions are already loaded
- if len(s.sessions) > 0 {
+ if len(s.content.sessions) > 0 {
// Re-set the sessions to update the selection
- s.SetSessions(s.sessions)
+ s.SetSessions(s.content.sessions)
}
}
@@ -202,9 +197,15 @@ func NewSessionDialogCmp() SessionDialog {
true, // useAlphaNumericKeys
)
- return &sessionDialogComponent{
+ content := &sessionDialogContent{
sessions: []client.SessionInfo{},
selectedSessionID: "",
list: list,
}
+
+ return &sessionDialogComponent{
+ content: content,
+ modal: modal.New(content, modal.WithTitle("Switch Session")),
+ }
}
+
diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go
new file mode 100644
index 000000000..9477aee29
--- /dev/null
+++ b/packages/tui/internal/components/modal/modal.go
@@ -0,0 +1,198 @@
+package modal
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/sst/opencode/internal/layout"
+ "github.com/sst/opencode/internal/styles"
+ "github.com/sst/opencode/internal/theme"
+)
+
+// Modal is a reusable modal component that handles frame rendering and overlay placement
+type Modal struct {
+ content tea.Model
+ width int
+ height int
+ title string
+ showBorder bool
+ borderStyle lipgloss.Border
+ maxWidth int
+ maxHeight int
+ centerContent bool
+}
+
+// ModalOption is a function that configures a Modal
+type ModalOption func(*Modal)
+
+// WithTitle sets the modal title
+func WithTitle(title string) ModalOption {
+ return func(m *Modal) {
+ m.title = title
+ }
+}
+
+// WithBorder enables/disables the border
+func WithBorder(show bool) ModalOption {
+ return func(m *Modal) {
+ m.showBorder = show
+ }
+}
+
+// WithBorderStyle sets the border style
+func WithBorderStyle(style lipgloss.Border) ModalOption {
+ return func(m *Modal) {
+ m.borderStyle = style
+ }
+}
+
+// WithMaxWidth sets the maximum width
+func WithMaxWidth(width int) ModalOption {
+ return func(m *Modal) {
+ m.maxWidth = width
+ }
+}
+
+// WithMaxHeight sets the maximum height
+func WithMaxHeight(height int) ModalOption {
+ return func(m *Modal) {
+ m.maxHeight = height
+ }
+}
+
+// WithCenterContent centers the content within the modal
+func WithCenterContent(center bool) ModalOption {
+ return func(m *Modal) {
+ m.centerContent = center
+ }
+}
+
+// New creates a new Modal with the given content and options
+func New(content tea.Model, opts ...ModalOption) *Modal {
+ m := &Modal{
+ content: content,
+ showBorder: true,
+ borderStyle: lipgloss.ThickBorder(),
+ maxWidth: 0,
+ maxHeight: 0,
+ centerContent: false,
+ }
+
+ for _, opt := range opts {
+ opt(m)
+ }
+
+ return m
+}
+
+func (m *Modal) Init() tea.Cmd {
+ return m.content.Init()
+}
+
+func (m *Modal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ }
+
+ // Pass all messages to the content
+ var cmd tea.Cmd
+ m.content, cmd = m.content.Update(msg)
+ return m, cmd
+}
+
+func (m *Modal) View() string {
+ t := theme.CurrentTheme()
+
+ // Get the content view
+ contentView := ""
+ if v, ok := m.content.(layout.ModelWithView); ok {
+ contentView = v.View()
+ }
+
+ // Calculate dimensions
+ outerWidth := layout.Current.Container.Width - 8
+ if m.maxWidth > 0 && outerWidth > m.maxWidth {
+ outerWidth = m.maxWidth
+ }
+
+ innerWidth := outerWidth - 4
+
+ // Base style for the modal
+ baseStyle := styles.BaseStyle().
+ Background(t.BackgroundElement()).
+ Foreground(t.TextMuted())
+
+ // Add title if provided
+ var finalContent string
+ if m.title != "" {
+ titleStyle := baseStyle.
+ Foreground(t.Primary()).
+ Bold(true).
+ Width(innerWidth).
+ Padding(0, 1)
+
+ titleView := titleStyle.Render(m.title)
+ finalContent = lipgloss.JoinVertical(
+ lipgloss.Left,
+ titleView,
+ contentView,
+ )
+ } else {
+ finalContent = contentView
+ }
+
+ // Apply modal styling
+ modalStyle := baseStyle.
+ PaddingTop(1).
+ PaddingBottom(1).
+ PaddingLeft(2).
+ PaddingRight(2)
+
+ if m.showBorder {
+ modalStyle = modalStyle.
+ BorderStyle(m.borderStyle).
+ BorderLeft(true).
+ BorderRight(true).
+ BorderLeftForeground(t.BackgroundSubtle()).
+ BorderLeftBackground(t.Background()).
+ BorderRightForeground(t.BackgroundSubtle()).
+ BorderRightBackground(t.Background())
+ }
+
+ return modalStyle.
+ Width(outerWidth).
+ Render(finalContent)
+}
+
+// Render renders the modal centered on the screen
+func (m *Modal) Render(background string) string {
+ modalView := m.View()
+
+ // Calculate position for centering
+ bgHeight := lipgloss.Height(background)
+ bgWidth := lipgloss.Width(background)
+ modalHeight := lipgloss.Height(modalView)
+ modalWidth := lipgloss.Width(modalView)
+
+ row := (bgHeight - modalHeight) / 2
+ col := (bgWidth - modalWidth) / 2
+
+ // Use PlaceOverlay to render the modal on top of the background
+ return layout.PlaceOverlay(
+ col,
+ row,
+ modalView,
+ background,
+ true, // shadow
+ )
+}
+
+// BindingKeys returns the key bindings from the content if it implements layout.Bindings
+func (m *Modal) BindingKeys() []key.Binding {
+ if b, ok := m.content.(layout.Bindings); ok {
+ return b.BindingKeys()
+ }
+ return []key.Binding{}
+} \ No newline at end of file
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index e1a1b6abb..f3af83e77 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -836,18 +836,7 @@ func (a appModel) View() string {
}
if a.showSessionDialog {
- overlay := a.sessionDialog.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,
- )
+ appView = a.sessionDialog.Render(appView)
}
if a.showModelDialog {