adamdottv 8 месяцев назад
Родитель
Сommit
e1f12f93eb

+ 1 - 2
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)

+ 245 - 0
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...,
+	)
+}

+ 21 - 1
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),