summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/tui/internal/components/modal/modal.go3
-rw-r--r--packages/tui/internal/components/toast/toast.go245
-rw-r--r--packages/tui/internal/tui/tui.go22
3 files changed, 267 insertions, 3 deletions
diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go
index e57de0afe..b2b59a4b2 100644
--- a/packages/tui/internal/components/modal/modal.go
+++ b/packages/tui/internal/components/modal/modal.go
@@ -100,8 +100,7 @@ func (m *Modal) Render(contentView string, background string) string {
if m.title != "" {
titleStyle := baseStyle.
Foreground(t.Primary()).
- Bold(true).
- Padding(0, 1)
+ Bold(true)
// titleView := titleStyle.Render(m.title)
escStyle := baseStyle.Foreground(t.TextMuted()).Bold(false)
diff --git a/packages/tui/internal/components/toast/toast.go b/packages/tui/internal/components/toast/toast.go
new file mode 100644
index 000000000..9c13f4d9c
--- /dev/null
+++ b/packages/tui/internal/components/toast/toast.go
@@ -0,0 +1,245 @@
+package toast
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/lipgloss/v2/compat"
+ "github.com/sst/opencode/internal/layout"
+ "github.com/sst/opencode/internal/styles"
+ "github.com/sst/opencode/internal/theme"
+)
+
+// ShowToastMsg is a message to display a toast notification
+type ShowToastMsg struct {
+ Message string
+ Title *string
+ Color compat.AdaptiveColor
+ Duration time.Duration
+}
+
+// DismissToastMsg is a message to dismiss a specific toast
+type DismissToastMsg struct {
+ ID string
+}
+
+// Toast represents a single toast notification
+type Toast struct {
+ ID string
+ Message string
+ Title *string
+ Color compat.AdaptiveColor
+ CreatedAt time.Time
+ Duration time.Duration
+}
+
+// ToastManager manages multiple toast notifications
+type ToastManager struct {
+ toasts []Toast
+}
+
+// NewToastManager creates a new toast manager
+func NewToastManager() *ToastManager {
+ return &ToastManager{
+ toasts: []Toast{},
+ }
+}
+
+// Init initializes the toast manager
+func (tm *ToastManager) Init() tea.Cmd {
+ return nil
+}
+
+// Update handles messages for the toast manager
+func (tm *ToastManager) Update(msg tea.Msg) (*ToastManager, tea.Cmd) {
+ switch msg := msg.(type) {
+ case ShowToastMsg:
+ toast := Toast{
+ ID: fmt.Sprintf("toast-%d", time.Now().UnixNano()),
+ Title: msg.Title,
+ Message: msg.Message,
+ Color: msg.Color,
+ CreatedAt: time.Now(),
+ Duration: msg.Duration,
+ }
+
+ tm.toasts = append(tm.toasts, toast)
+
+ // Return command to dismiss after duration
+ return tm, tea.Tick(toast.Duration, func(t time.Time) tea.Msg {
+ return DismissToastMsg{ID: toast.ID}
+ })
+
+ case DismissToastMsg:
+ var newToasts []Toast
+ for _, t := range tm.toasts {
+ if t.ID != msg.ID {
+ newToasts = append(newToasts, t)
+ }
+ }
+ tm.toasts = newToasts
+ }
+
+ return tm, nil
+}
+
+// View renders all active toasts
+func (tm *ToastManager) View() string {
+ if len(tm.toasts) == 0 {
+ return ""
+ }
+
+ t := theme.CurrentTheme()
+
+ var toastViews []string
+ for _, toast := range tm.toasts {
+ baseStyle := styles.BaseStyle().
+ Background(t.BackgroundElement()).
+ Foreground(t.Text()).
+ Padding(1, 2).
+ BorderStyle(lipgloss.ThickBorder()).
+ BorderBackground(t.Background()).
+ BorderForeground(toast.Color).
+ BorderLeft(true).
+ BorderRight(true)
+
+ maxWidth := max(40, layout.Current.Viewport.Width/3)
+ contentMaxWidth := max(maxWidth-6, 20)
+
+ // Build content with wrapping
+ var content strings.Builder
+ if toast.Title != nil {
+ titleStyle := lipgloss.NewStyle().
+ Foreground(toast.Color).
+ Bold(true)
+ content.WriteString(titleStyle.Render(*toast.Title))
+ content.WriteString("\n")
+ }
+
+ // Wrap message text
+ messageStyle := lipgloss.NewStyle().Width(contentMaxWidth)
+ content.WriteString(messageStyle.Render(toast.Message))
+
+ // Render toast with max width
+ toastView := baseStyle.MaxWidth(maxWidth).Render(content.String())
+ toastViews = append(toastViews, toastView)
+ }
+
+ // Stack toasts vertically with small gap
+ return strings.Join(toastViews, "\n\n")
+}
+
+// RenderOverlay renders the toasts as an overlay on the given background
+func (tm *ToastManager) RenderOverlay(background string) string {
+ toastView := tm.View()
+ if toastView == "" {
+ return background
+ }
+
+ // Calculate position (bottom right with padding)
+ bgWidth := lipgloss.Width(background)
+ bgHeight := lipgloss.Height(background)
+ toastWidth := lipgloss.Width(toastView)
+ toastHeight := lipgloss.Height(toastView)
+
+ // Position with 2 character padding from edges
+ x := bgWidth - toastWidth - 2
+ y := bgHeight - toastHeight - 2
+
+ // Ensure we don't go negative
+ if x < 0 {
+ x = 0
+ }
+ if y < 0 {
+ y = 0
+ }
+ return layout.PlaceOverlay(x, y, toastView, background)
+}
+
+type ToastOptions struct {
+ Title string
+ Duration time.Duration
+}
+
+type toastOptions struct {
+ title *string
+ duration *time.Duration
+ color *compat.AdaptiveColor
+}
+
+type ToastOption func(*toastOptions)
+
+func WithTitle(title string) ToastOption {
+ return func(t *toastOptions) {
+ t.title = &title
+ }
+}
+func WithDuration(duration time.Duration) ToastOption {
+ return func(t *toastOptions) {
+ t.duration = &duration
+ }
+}
+
+func WithColor(color compat.AdaptiveColor) ToastOption {
+ return func(t *toastOptions) {
+ t.color = &color
+ }
+}
+
+func NewToast(message string, options ...ToastOption) tea.Cmd {
+ t := theme.CurrentTheme()
+ duration := 5 * time.Second
+ color := t.Primary()
+
+ opts := toastOptions{
+ duration: &duration,
+ color: &color,
+ }
+ for _, option := range options {
+ option(&opts)
+ }
+
+ return func() tea.Msg {
+ return ShowToastMsg{
+ Message: message,
+ Title: opts.title,
+ Duration: *opts.duration,
+ Color: *opts.color,
+ }
+ }
+}
+
+func NewInfoToast(message string, options ...ToastOption) tea.Cmd {
+ options = append(options, WithColor(theme.CurrentTheme().Info()))
+ return NewToast(
+ message,
+ options...,
+ )
+}
+
+func NewSuccessToast(message string, options ...ToastOption) tea.Cmd {
+ options = append(options, WithColor(theme.CurrentTheme().Success()))
+ return NewToast(
+ message,
+ options...,
+ )
+}
+
+func NewWarningToast(message string, options ...ToastOption) tea.Cmd {
+ options = append(options, WithColor(theme.CurrentTheme().Warning()))
+ return NewToast(
+ message,
+ options...,
+ )
+}
+
+func NewErrorToast(message string, options ...ToastOption) tea.Cmd {
+ options = append(options, WithColor(theme.CurrentTheme().Error()))
+ return NewToast(
+ message,
+ options...,
+ )
+}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 6e052efff..282322799 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -18,6 +18,7 @@ import (
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
+ "github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/util"
@@ -38,6 +39,7 @@ type appModel struct {
showCompletionDialog bool
leaderBinding *key.Binding
isLeaderSequence bool
+ toastManager *toast.ToastManager
}
func (a appModel) Init() tea.Cmd {
@@ -48,6 +50,7 @@ func (a appModel) Init() tea.Cmd {
cmds = append(cmds, a.messages.Init())
cmds = append(cmds, a.status.Init())
cmds = append(cmds, a.completions.Init())
+ cmds = append(cmds, a.toastManager.Init())
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
@@ -255,6 +258,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
a.app.SaveState()
+ case toast.ShowToastMsg:
+ tm, cmd := a.toastManager.Update(msg)
+ a.toastManager = tm
+ cmds = append(cmds, cmd)
+ case toast.DismissToastMsg:
+ tm, cmd := a.toastManager.Update(msg)
+ a.toastManager = tm
+ cmds = append(cmds, cmd)
}
// update status bar
@@ -319,10 +330,13 @@ func (a appModel) View() string {
a.status.View(),
}
appView := lipgloss.JoinVertical(lipgloss.Top, components...)
+
if a.modal != nil {
appView = a.modal.Render(appView)
}
+ appView = a.toastManager.RenderOverlay(appView)
+
return appView
}
@@ -398,15 +412,20 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
if a.app.Session.Id == "" {
return a, nil
}
- response, _ := a.app.Client.PostSessionShareWithResponse(
+ response, err := a.app.Client.PostSessionShareWithResponse(
context.Background(),
client.PostSessionShareJSONRequestBody{
SessionID: a.app.Session.Id,
},
)
+ if err != nil {
+ slog.Error("Failed to share session", "error", err)
+ return a, toast.NewErrorToast("Failed to share session")
+ }
if response.JSON200 != nil && response.JSON200.Share != nil {
shareUrl := response.JSON200.Share.Url
cmds = append(cmds, tea.SetClipboard(shareUrl))
+ cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
}
case commands.SessionInterruptCommand:
if a.app.Session.Id == "" {
@@ -537,6 +556,7 @@ func NewModel(app *app.App) tea.Model {
isLeaderSequence: false,
showCompletionDialog: false,
editorContainer: editorContainer,
+ toastManager: toast.NewToastManager(),
layout: layout.NewFlexLayout(
[]tea.ViewModel{messagesContainer, editorContainer},
layout.WithDirection(layout.FlexDirectionVertical),