diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index bdf7990cf8a8aff509ed39d1167213b45ff92615..3f4e8daddbd4de34e788bce59a9573c00d940252 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -112,7 +112,7 @@ func testEnv(t *testing.T) fakeEnv { require.NoError(t, err) q := db.New(conn) - sessions := session.NewService(q) + sessions := session.NewService(q, conn) messages := message.NewService(q) permissions := permission.NewPermissionService(workingDir, true, []string{}) diff --git a/internal/app/app.go b/internal/app/app.go index b6cb9c8dfb95d79eccec07145f1246e6b8910713..b186c1aeb4f7d0adbc3d0fd443b660952a4def52 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -73,7 +73,7 @@ type App struct { // New initializes a new application instance. func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { q := db.New(conn) - sessions := session.NewService(q) + sessions := session.NewService(q, conn) messages := message.NewService(q) files := history.NewService(q, conn) skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests diff --git a/internal/session/session.go b/internal/session/session.go index 3792cc1d576cdd7ebd0dbf0b64670c746718da9c..905ee1cf1417b148019d9688985c1f5200209d69 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -61,7 +61,8 @@ type Service interface { type service struct { *pubsub.Broker[Session] - q db.Querier + db *sql.DB + q *db.Queries } func (s *service) Create(ctx context.Context, title string) (Session, error) { @@ -107,14 +108,32 @@ func (s *service) CreateTitleSession(ctx context.Context, parentSessionID string } func (s *service) Delete(ctx context.Context, id string) error { - session, err := s.Get(ctx, id) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return err + return fmt.Errorf("beginning transaction: %w", err) } - err = s.q.DeleteSession(ctx, session.ID) + defer tx.Rollback() //nolint:errcheck + + qtx := s.q.WithTx(tx) + + dbSession, err := qtx.GetSessionByID(ctx, id) if err != nil { return err } + if err = qtx.DeleteSessionMessages(ctx, dbSession.ID); err != nil { + return fmt.Errorf("deleting session messages: %w", err) + } + if err = qtx.DeleteSessionFiles(ctx, dbSession.ID); err != nil { + return fmt.Errorf("deleting session files: %w", err) + } + if err = qtx.DeleteSession(ctx, dbSession.ID); err != nil { + return fmt.Errorf("deleting session: %w", err) + } + if err = tx.Commit(); err != nil { + return fmt.Errorf("committing transaction: %w", err) + } + + session := s.fromDBItem(dbSession) s.Publish(pubsub.DeletedEvent, session) event.SessionDeleted() return nil @@ -223,11 +242,12 @@ func unmarshalTodos(data string) ([]Todo, error) { return todos, nil } -func NewService(q db.Querier) Service { +func NewService(q *db.Queries, conn *sql.DB) Service { broker := pubsub.NewBroker[Session]() return &service{ - broker, - q, + Broker: broker, + db: conn, + q: q, } } diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index ccb7f7cdb2677980ddac4a55e153354c9f220962..16fe528f736c8b40a16d664b47d1ea1e1f1ecb93 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -177,13 +177,13 @@ func Section(t *styles.Styles, text string, width int, info ...string) string { // DialogTitle renders a dialog title with a decorative line filling the // remaining width. -func DialogTitle(t *styles.Styles, title string, width int) string { +func DialogTitle(t *styles.Styles, title string, width int, fromColor, toColor color.Color) string { char := "╱" length := lipgloss.Width(title) + 1 remainingWidth := width - length if remainingWidth > 0 { lines := strings.Repeat(char, remainingWidth) - lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary) + lines = styles.ApplyForegroundGrad(t, lines, fromColor, toColor) title = title + " " + lines } return title diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 01d5e41a1d9d7e4a3fa25db91caa12fb12daea1f..65fe5cfb9cb14eb60f4399b0477d6cd071315750 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -207,7 +207,7 @@ func (m *APIKeyInput) headerView() string { return textStyle.Render(m.dialogTitle()) } headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() - return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset) + return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset, m.com.Styles.Primary, m.com.Styles.Secondary) } func (m *APIKeyInput) dialogTitle() string { diff --git a/internal/ui/dialog/arguments.go b/internal/ui/dialog/arguments.go index c016b7de6ec77e6e333d2b0f18ae5930ba0912fc..172c44eba0e015ee5562507fe92254cb047d4632 100644 --- a/internal/ui/dialog/arguments.go +++ b/internal/ui/dialog/arguments.go @@ -316,7 +316,7 @@ func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { titleText = "Arguments" } - header := common.DialogTitle(s, titleText, width) + header := common.DialogTitle(s, titleText, width, s.Primary, s.Secondary) // Add description if available. var description string diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 0bf8b52d04cba248b3b19e412d981d92b4ab5a08..444492c9f71241bf812f0a96ac18d2118919e33d 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -387,7 +387,7 @@ func (c *Commands) setCommandItems(commandType CommandType) { func (c *Commands) defaultCommands() []*CommandItem { commands := []*CommandItem{ NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}), - NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}), + NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}), NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}), } diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index 9a2cf2ceef2be54c6f8d9897d4ddd923fd07b80f..b1977545ded8e8eeb8fc1e59c5a0a31e18ce8610 100644 --- a/internal/ui/dialog/commands_item.go +++ b/internal/ui/dialog/commands_item.go @@ -66,5 +66,11 @@ func (c *CommandItem) Shortcut() string { // Render implements ListItem. func (c *CommandItem) Render(width int) string { - return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m) + styles := ListIemStyles{ + ItemBlurred: c.t.Dialog.NormalItem, + ItemFocused: c.t.Dialog.SelectedItem, + InfoTextBlurred: c.t.Base, + InfoTextFocused: c.t.Subtle, + } + return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m) } diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go index fe54a7e60649a222eabe80e2ecc02546036bac17..ca5dcb704d42dc6475369bf6d8020f707e16190e 100644 --- a/internal/ui/dialog/common.go +++ b/internal/ui/dialog/common.go @@ -1,6 +1,7 @@ package dialog import ( + "image/color" "strings" tea "charm.land/bubbletea/v2" @@ -42,6 +43,14 @@ func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor { type RenderContext struct { // Styles is the styles to use for rendering. Styles *styles.Styles + // TitleStyle is the style of the dialog title by default it uses Styles.Dialog.Title + TitleStyle lipgloss.Style + // ViewStyle is the style of the dialog title by default it uses Styles.Dialog.View + ViewStyle lipgloss.Style + // TitleGradientFromColor is the color the title gradient starts by defaults its Style.Primary + TitleGradientFromColor color.Color + // TitleGradientToColor is the color the title gradient starts by defaults its Style.Secondary + TitleGradientToColor color.Color // Width is the total width of the dialog including any margins, borders, // and paddings. Width int @@ -68,9 +77,13 @@ type RenderContext struct { // NewRenderContext creates a new RenderContext with the provided styles and width. func NewRenderContext(t *styles.Styles, width int) *RenderContext { return &RenderContext{ - Styles: t, - Width: width, - Parts: []string{}, + Styles: t, + TitleStyle: t.Dialog.Title, + ViewStyle: t.Dialog.View, + TitleGradientFromColor: t.Primary, + TitleGradientToColor: t.Secondary, + Width: width, + Parts: []string{}, } } @@ -83,8 +96,8 @@ func (rc *RenderContext) AddPart(part string) { // Render renders the dialog using the provided context. func (rc *RenderContext) Render() string { - titleStyle := rc.Styles.Dialog.Title - dialogStyle := rc.Styles.Dialog.View.Width(rc.Width) + titleStyle := rc.TitleStyle + dialogStyle := rc.ViewStyle.Width(rc.Width) var parts []string @@ -96,7 +109,7 @@ func (rc *RenderContext) Render() string { title := common.DialogTitle(rc.Styles, rc.Title, max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()- titleStyle.GetHorizontalFrameSize()- - titleInfoWidth)) + titleInfoWidth), rc.TitleGradientFromColor, rc.TitleGradientToColor) if len(rc.TitleInfo) > 0 { title += rc.TitleInfo } diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go index 40a8a25c57cd7cf0ce6252ef3113ce2af2f8d2f4..bfe30c0e3a04c24c71579bfbdbd06b576e1ad033 100644 --- a/internal/ui/dialog/models_item.go +++ b/internal/ui/dialog/models_item.go @@ -106,7 +106,13 @@ func (m *ModelItem) Render(width int) string { if m.showProvider { providerInfo = string(m.prov.Name) } - return renderItem(m.t, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m) + styles := ListIemStyles{ + ItemBlurred: m.t.Dialog.NormalItem, + ItemFocused: m.t.Dialog.SelectedItem, + InfoTextBlurred: m.t.Base, + InfoTextFocused: m.t.Subtle, + } + return renderItem(styles, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m) } // SetFocused implements ListItem. diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index e4c4ec664a893846f0b37fe2d4bfc323ac1773da..e4f7a521cacb51d215ca405883351558ed7179d6 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -227,7 +227,7 @@ func (m *OAuth) headerContent() string { if m.isOnboarding { return textStyle.Render(dialogTitle) } - return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset) + return common.DialogTitle(t, titleStyle.Render(dialogTitle), m.width-headerOffset, t.Primary, t.Secondary) } func (m *OAuth) innerDialogContent() string { diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go index 8f2ca1ed27e7eff5096bcb33c8f516a07fe2dd88..143dbbd8baffa8e89b1654175039e8eb9d913bf0 100644 --- a/internal/ui/dialog/permissions.go +++ b/internal/ui/dialog/permissions.go @@ -410,7 +410,7 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { func (p *Permissions) renderHeader(contentWidth int) string { t := p.com.Styles - title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize()) + title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize(), t.Primary, t.Secondary) title = t.Dialog.Title.Render(title) // Tool info. diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go index 7ccb575f55258000fe6246e1fac42cbb1553174a..4c5dad086bb01eb3dc12f2f6d379c87a5638d297 100644 --- a/internal/ui/dialog/reasoning.go +++ b/internal/ui/dialog/reasoning.go @@ -293,5 +293,11 @@ func (r *ReasoningItem) Render(width int) string { if r.isCurrent { info = "current" } - return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m) + styles := ListIemStyles{ + ItemBlurred: r.t.Dialog.NormalItem, + ItemFocused: r.t.Dialog.SelectedItem, + InfoTextBlurred: r.t.Base, + InfoTextFocused: r.t.Subtle, + } + return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m) } diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 2ace3c4634a2980d1b3e82afc947a9f21b9a5541..2dadc209bced543077a143d03bbc16f6bdf1524d 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -7,14 +7,25 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/uiutil" uv "github.com/charmbracelet/ultraviolet" ) // SessionsID is the identifier for the session selector dialog. const SessionsID = "session" +type sessionsMode uint8 + +// Possible modes a session item can be in +const ( + sessionsModeNormal sessionsMode = iota + sessionsModeDeleting + sessionsModeUpdating +) + // Session is a session selector dialog. type Session struct { com *common.Common @@ -22,13 +33,19 @@ type Session struct { list *list.FilterableList input textinput.Model selectedSessionInx int + sessions []session.Session + + sessionsMode sessionsMode keyMap struct { - Select key.Binding - Next key.Binding - Previous key.Binding - UpDown key.Binding - Close key.Binding + Select key.Binding + Next key.Binding + Previous key.Binding + UpDown key.Binding + Delete key.Binding + ConfirmDelete key.Binding + CancelDelete key.Binding + Close key.Binding } } @@ -37,12 +54,14 @@ var _ Dialog = (*Session)(nil) // NewSessions creates a new Session dialog. func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) { s := new(Session) + s.sessionsMode = sessionsModeNormal s.com = com sessions, err := com.App.Sessions.List(context.TODO()) if err != nil { return nil, err } + s.sessions = sessions for i, sess := range sessions { if sess.ID == selectedSessionID { s.selectedSessionInx = i @@ -54,7 +73,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) help.Styles = com.Styles.DialogHelpStyles() s.help = help - s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...) + s.list = list.NewFilterableList(sessionItems(com.Styles, sessionsModeNormal, sessions...)...) s.list.Focus() s.list.SetSelected(s.selectedSessionInx) @@ -80,6 +99,18 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) key.WithKeys("up", "down"), key.WithHelp("↑↓", "choose"), ) + s.keyMap.Delete = key.NewBinding( + key.WithKeys("ctrl+x"), + key.WithHelp("ctrl+x", "delete"), + ) + s.keyMap.ConfirmDelete = key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "delete"), + ) + s.keyMap.CancelDelete = key.NewBinding( + key.WithKeys("n", "esc"), + key.WithHelp("n", "cancel"), + ) s.keyMap.Close = CloseKey return s, nil @@ -94,40 +125,57 @@ func (s *Session) ID() string { func (s *Session) HandleMsg(msg tea.Msg) Action { switch msg := msg.(type) { case tea.KeyPressMsg: - switch { - case key.Matches(msg, s.keyMap.Close): - return ActionClose{} - case key.Matches(msg, s.keyMap.Previous): - s.list.Focus() - if s.list.IsSelectedFirst() { - s.list.SelectLast() - s.list.ScrollToBottom() - break + switch s.sessionsMode { + case sessionsModeDeleting: + switch { + case key.Matches(msg, s.keyMap.ConfirmDelete): + return s.confirmDeleteSession() + case key.Matches(msg, s.keyMap.CancelDelete): + s.sessionsMode = sessionsModeNormal + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) } - s.list.SelectPrev() - s.list.ScrollToSelected() - case key.Matches(msg, s.keyMap.Next): - s.list.Focus() - if s.list.IsSelectedLast() { - s.list.SelectFirst() + default: + switch { + case key.Matches(msg, s.keyMap.Close): + return ActionClose{} + case key.Matches(msg, s.keyMap.Delete): + if s.isCurrentSessionBusy() { + return ActionCmd{uiutil.ReportWarn("Agent is busy, please wait...")} + } + s.sessionsMode = sessionsModeDeleting + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeDeleting, s.sessions...)...) + case key.Matches(msg, s.keyMap.Previous): + s.list.Focus() + if s.list.IsSelectedFirst() { + s.list.SelectLast() + s.list.ScrollToBottom() + break + } + s.list.SelectPrev() + s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Next): + s.list.Focus() + if s.list.IsSelectedLast() { + s.list.SelectFirst() + s.list.ScrollToTop() + break + } + s.list.SelectNext() + s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Select): + if item := s.list.SelectedItem(); item != nil { + sessionItem := item.(*SessionItem) + return ActionSelectSession{sessionItem.Session} + } + default: + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + value := s.input.Value() + s.list.SetFilter(value) s.list.ScrollToTop() - break + s.list.SetSelected(0) + return ActionCmd{cmd} } - s.list.SelectNext() - s.list.ScrollToSelected() - case key.Matches(msg, s.keyMap.Select): - if item := s.list.SelectedItem(); item != nil { - sessionItem := item.(*SessionItem) - return ActionSelectSession{sessionItem.Session} - } - default: - var cmd tea.Cmd - s.input, cmd = s.input.Update(msg) - value := s.input.Value() - s.list.SetFilter(value) - s.list.ScrollToTop() - s.list.SetSelected(0) - return ActionCmd{cmd} } } return nil @@ -160,27 +208,101 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { s.list.ScrollToSelected() } + var cur *tea.Cursor rc := NewRenderContext(t, width) - rc.Title = "Switch Session" - inputView := t.Dialog.InputPrompt.Render(s.input.View()) - rc.AddPart(inputView) + rc.Title = "Sessions" + switch s.sessionsMode { + case sessionsModeDeleting: + rc.TitleStyle = t.Dialog.Sessions.DeletingTitle + rc.TitleGradientFromColor = t.Dialog.Sessions.DeletingTitleGradientFromColor + rc.TitleGradientToColor = t.Dialog.Sessions.DeletingTitleGradientToColor + rc.ViewStyle = t.Dialog.Sessions.DeletingView + rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?")) + default: + inputView := t.Dialog.InputPrompt.Render(s.input.View()) + cur = s.Cursor() + rc.AddPart(inputView) + } listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render()) rc.AddPart(listView) rc.Help = s.help.View(s) view := rc.Render() - cur := s.Cursor() DrawCenterCursor(scr, area, view, cur) return cur } +func (s *Session) selectedSessionItem() *SessionItem { + if item := s.list.SelectedItem(); item != nil { + return item.(*SessionItem) + } + return nil +} + +func (s *Session) confirmDeleteSession() Action { + sessionItem := s.selectedSessionItem() + s.sessionsMode = sessionsModeNormal + if sessionItem == nil { + return nil + } + + s.removeSession(sessionItem.ID()) + return ActionCmd{s.deleteSessionCmd(sessionItem.ID())} +} + +func (s *Session) removeSession(id string) { + var newSessions []session.Session + for _, sess := range s.sessions { + if sess.ID == id { + continue + } + newSessions = append(newSessions, sess) + } + s.sessions = newSessions + s.list.SetItems(sessionItems(s.com.Styles, sessionsModeNormal, s.sessions...)...) + s.list.SelectFirst() + s.list.ScrollToSelected() +} + +func (s *Session) deleteSessionCmd(id string) tea.Cmd { + return func() tea.Msg { + err := s.com.App.Sessions.Delete(context.TODO(), id) + if err != nil { + return uiutil.NewErrorMsg(err) + } + return nil + } +} + +func (s *Session) isCurrentSessionBusy() bool { + sessionItem := s.selectedSessionItem() + if sessionItem == nil { + return false + } + + if s.com.App.AgentCoordinator == nil { + return false + } + + return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID()) +} + // ShortHelp implements [help.KeyMap]. func (s *Session) ShortHelp() []key.Binding { - return []key.Binding{ - s.keyMap.UpDown, - s.keyMap.Select, - s.keyMap.Close, + switch s.sessionsMode { + case sessionsModeDeleting: + return []key.Binding{ + s.keyMap.ConfirmDelete, + s.keyMap.CancelDelete, + } + default: + return []key.Binding{ + s.keyMap.UpDown, + s.keyMap.Select, + s.keyMap.Delete, + s.keyMap.Close, + } } } @@ -191,8 +313,17 @@ func (s *Session) FullHelp() [][]key.Binding { s.keyMap.Select, s.keyMap.Next, s.keyMap.Previous, + s.keyMap.Delete, s.keyMap.Close, } + + switch s.sessionsMode { + case sessionsModeDeleting: + slice = []key.Binding{ + s.keyMap.ConfirmDelete, + s.keyMap.CancelDelete, + } + } for i := 0; i < len(slice); i += 4 { end := min(i+4, len(slice)) m = append(m, slice[i:end]) diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 6d6852b7359d5f19d85349f34eff3b21c0510a05..47ffd4878727c10a4be89ed373402dd214573a14 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -28,10 +28,11 @@ type ListItem interface { // SessionItem wraps a [session.Session] to implement the [ListItem] interface. type SessionItem struct { session.Session - t *styles.Styles - m fuzzy.Match - cache map[int]string - focused bool + t *styles.Styles + sessionsMode sessionsMode + m fuzzy.Match + cache map[int]string + focused bool } var _ ListItem = &SessionItem{} @@ -55,10 +56,29 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) { // Render returns the string representation of the session item. func (s *SessionItem) Render(width int) string { info := humanize.Time(time.Unix(s.UpdatedAt, 0)) - return renderItem(s.t, s.Title, info, s.focused, width, s.cache, &s.m) + styles := ListIemStyles{ + ItemBlurred: s.t.Dialog.NormalItem, + ItemFocused: s.t.Dialog.SelectedItem, + InfoTextBlurred: s.t.Subtle, + InfoTextFocused: s.t.Base, + } + + switch s.sessionsMode { + case sessionsModeDeleting: + styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred + styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused + } + return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m) +} + +type ListIemStyles struct { + ItemBlurred lipgloss.Style + ItemFocused lipgloss.Style + InfoTextBlurred lipgloss.Style + InfoTextFocused lipgloss.Style } -func renderItem(t *styles.Styles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { +func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { if cache == nil { cache = make(map[int]string) } @@ -68,9 +88,9 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width return cached } - style := t.Dialog.NormalItem + style := t.ItemBlurred if focused { - style = t.Dialog.SelectedItem + style = t.ItemFocused } var infoText string @@ -79,9 +99,9 @@ func renderItem(t *styles.Styles, title string, info string, focused bool, width if len(info) > 0 { infoText = fmt.Sprintf(" %s ", info) if focused { - infoText = t.Base.Render(infoText) + infoText = t.InfoTextFocused.Render(infoText) } else { - infoText = t.Subtle.Render(infoText) + infoText = t.InfoTextBlurred.Render(infoText) } infoWidth = lipgloss.Width(infoText) @@ -134,10 +154,10 @@ func (s *SessionItem) SetFocused(focused bool) { // sessionItems takes a slice of [session.Session]s and convert them to a slice // of [ListItem]s. -func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem { +func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Session) []list.FilterableItem { items := make([]list.FilterableItem, len(sessions)) for i, s := range sessions { - items[i] = &SessionItem{Session: s, t: t} + items[i] = &SessionItem{Session: s, t: t, sessionsMode: mode} } return items } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index ec17627a43fc70c945dd22cf5eee5082c5d5eac2..cd1ad42a0dc473c31b3ff280a7a224d64d0094c2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -412,6 +412,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dialog.CloseFrontDialog() case pubsub.Event[session.Session]: + if msg.Type == pubsub.DeletedEvent { + if m.session != nil && m.session.ID == msg.Payload.ID { + m.newSession() + } + break + } if m.session != nil && msg.Payload.ID == m.session.ID { prevHasInProgress := hasInProgressTodo(m.session.Todos) m.session = &msg.Payload diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index f40fd5113bf1495fa5d35e0b891e397e6a90b6ec..cce2471267bb179bf26cee2b29c870c0998584fb 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -338,6 +338,7 @@ type Styles struct { FullDesc lipgloss.Style FullSeparator lipgloss.Style } + NormalItem lipgloss.Style SelectedItem lipgloss.Style InputPrompt lipgloss.Style @@ -366,6 +367,16 @@ type Styles struct { Commands struct{} ImagePreview lipgloss.Style + + Sessions struct { + DeletingView lipgloss.Style + DeletingItemFocused lipgloss.Style + DeletingItemBlurred lipgloss.Style + DeletingTitle lipgloss.Style + DeletingMessage lipgloss.Style + DeletingTitleGradientFromColor color.Color + DeletingTitleGradientToColor color.Color + } } // Status bar and help @@ -1268,6 +1279,14 @@ func DefaultStyles() Styles { s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*") s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*") + s.Dialog.Sessions.DeletingTitle = s.Dialog.Title.Foreground(red) + s.Dialog.Sessions.DeletingView = s.Dialog.View.BorderForeground(red) + s.Dialog.Sessions.DeletingMessage = s.Base.Padding(1) + s.Dialog.Sessions.DeletingTitleGradientFromColor = red + s.Dialog.Sessions.DeletingTitleGradientToColor = s.Primary + s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle) + s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red) + s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") s.Status.InfoIndicator = s.Status.SuccessIndicator