sessions.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. package repl
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "github.com/charmbracelet/bubbles/key"
  7. "github.com/charmbracelet/bubbles/list"
  8. tea "github.com/charmbracelet/bubbletea"
  9. "github.com/charmbracelet/lipgloss"
  10. "github.com/kujtimiihoxha/termai/internal/app"
  11. "github.com/kujtimiihoxha/termai/internal/pubsub"
  12. "github.com/kujtimiihoxha/termai/internal/session"
  13. "github.com/kujtimiihoxha/termai/internal/tui/layout"
  14. "github.com/kujtimiihoxha/termai/internal/tui/styles"
  15. "github.com/kujtimiihoxha/termai/internal/tui/util"
  16. )
  17. type SessionsCmp interface {
  18. tea.Model
  19. layout.Sizeable
  20. layout.Focusable
  21. layout.Bordered
  22. layout.Bindings
  23. }
  24. type sessionsCmp struct {
  25. app *app.App
  26. list list.Model
  27. focused bool
  28. }
  29. type listItem struct {
  30. id, title, desc string
  31. }
  32. func (i listItem) Title() string { return i.title }
  33. func (i listItem) Description() string { return i.desc }
  34. func (i listItem) FilterValue() string { return i.title }
  35. type InsertSessionsMsg struct {
  36. sessions []session.Session
  37. }
  38. type SelectedSessionMsg struct {
  39. SessionID string
  40. }
  41. type sessionsKeyMap struct {
  42. Select key.Binding
  43. }
  44. var sessionKeyMapValue = sessionsKeyMap{
  45. Select: key.NewBinding(
  46. key.WithKeys("enter", " "),
  47. key.WithHelp("enter/space", "select session"),
  48. ),
  49. }
  50. func (i *sessionsCmp) Init() tea.Cmd {
  51. existing, err := i.app.Sessions.List(context.Background())
  52. if err != nil {
  53. return util.ReportError(err)
  54. }
  55. if len(existing) == 0 || existing[0].MessageCount > 0 {
  56. newSession, err := i.app.Sessions.Create(
  57. context.Background(),
  58. "New Session",
  59. )
  60. if err != nil {
  61. return util.ReportError(err)
  62. }
  63. existing = append([]session.Session{newSession}, existing...)
  64. }
  65. return tea.Batch(
  66. util.CmdHandler(InsertSessionsMsg{existing}),
  67. util.CmdHandler(SelectedSessionMsg{existing[0].ID}),
  68. )
  69. }
  70. func (i *sessionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  71. switch msg := msg.(type) {
  72. case InsertSessionsMsg:
  73. items := make([]list.Item, len(msg.sessions))
  74. for i, s := range msg.sessions {
  75. items[i] = listItem{
  76. id: s.ID,
  77. title: s.Title,
  78. desc: formatTokensAndCost(s.PromptTokens+s.CompletionTokens, s.Cost),
  79. }
  80. }
  81. return i, i.list.SetItems(items)
  82. case pubsub.Event[session.Session]:
  83. if msg.Type == pubsub.CreatedEvent && msg.Payload.ParentSessionID == "" {
  84. // Check if the session is already in the list
  85. items := i.list.Items()
  86. for _, item := range items {
  87. s := item.(listItem)
  88. if s.id == msg.Payload.ID {
  89. return i, nil
  90. }
  91. }
  92. // insert the new session at the top of the list
  93. items = append([]list.Item{listItem{
  94. id: msg.Payload.ID,
  95. title: msg.Payload.Title,
  96. desc: formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost),
  97. }}, items...)
  98. return i, i.list.SetItems(items)
  99. } else if msg.Type == pubsub.UpdatedEvent {
  100. // update the session in the list
  101. items := i.list.Items()
  102. for idx, item := range items {
  103. s := item.(listItem)
  104. if s.id == msg.Payload.ID {
  105. s.title = msg.Payload.Title
  106. s.desc = formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost)
  107. items[idx] = s
  108. break
  109. }
  110. }
  111. return i, i.list.SetItems(items)
  112. }
  113. case tea.KeyMsg:
  114. switch {
  115. case key.Matches(msg, sessionKeyMapValue.Select):
  116. selected := i.list.SelectedItem()
  117. if selected == nil {
  118. return i, nil
  119. }
  120. return i, util.CmdHandler(SelectedSessionMsg{selected.(listItem).id})
  121. }
  122. }
  123. if i.focused {
  124. u, cmd := i.list.Update(msg)
  125. i.list = u
  126. return i, cmd
  127. }
  128. return i, nil
  129. }
  130. func (i *sessionsCmp) View() string {
  131. return i.list.View()
  132. }
  133. func (i *sessionsCmp) Blur() tea.Cmd {
  134. i.focused = false
  135. return nil
  136. }
  137. func (i *sessionsCmp) Focus() tea.Cmd {
  138. i.focused = true
  139. return nil
  140. }
  141. func (i *sessionsCmp) GetSize() (int, int) {
  142. return i.list.Width(), i.list.Height()
  143. }
  144. func (i *sessionsCmp) IsFocused() bool {
  145. return i.focused
  146. }
  147. func (i *sessionsCmp) SetSize(width int, height int) {
  148. i.list.SetSize(width, height)
  149. }
  150. func (i *sessionsCmp) BorderText() map[layout.BorderPosition]string {
  151. totalCount := len(i.list.Items())
  152. itemsPerPage := i.list.Paginator.PerPage
  153. currentPage := i.list.Paginator.Page
  154. current := min(currentPage*itemsPerPage+itemsPerPage, totalCount)
  155. pageInfo := fmt.Sprintf(
  156. "%d-%d of %d",
  157. currentPage*itemsPerPage+1,
  158. current,
  159. totalCount,
  160. )
  161. title := "Sessions"
  162. if i.focused {
  163. title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
  164. }
  165. return map[layout.BorderPosition]string{
  166. layout.TopMiddleBorder: title,
  167. layout.BottomMiddleBorder: pageInfo,
  168. }
  169. }
  170. func (i *sessionsCmp) BindingKeys() []key.Binding {
  171. return append(layout.KeyMapToSlice(i.list.KeyMap), sessionKeyMapValue.Select)
  172. }
  173. func formatTokensAndCost(tokens int64, cost float64) string {
  174. // Format tokens in human-readable format (e.g., 110K, 1.2M)
  175. var formattedTokens string
  176. switch {
  177. case tokens >= 1_000_000:
  178. formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
  179. case tokens >= 1_000:
  180. formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
  181. default:
  182. formattedTokens = fmt.Sprintf("%d", tokens)
  183. }
  184. // Remove .0 suffix if present
  185. if strings.HasSuffix(formattedTokens, ".0K") {
  186. formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
  187. }
  188. if strings.HasSuffix(formattedTokens, ".0M") {
  189. formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
  190. }
  191. // Format cost with $ symbol and 2 decimal places
  192. formattedCost := fmt.Sprintf("$%.2f", cost)
  193. return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
  194. }
  195. func NewSessionsCmp(app *app.App) SessionsCmp {
  196. listDelegate := list.NewDefaultDelegate()
  197. defaultItemStyle := list.NewDefaultItemStyles()
  198. defaultItemStyle.SelectedTitle = defaultItemStyle.SelectedTitle.BorderForeground(styles.Secondary).Foreground(styles.Primary)
  199. defaultItemStyle.SelectedDesc = defaultItemStyle.SelectedDesc.BorderForeground(styles.Secondary).Foreground(styles.Primary)
  200. defaultStyle := list.DefaultStyles()
  201. defaultStyle.FilterPrompt = defaultStyle.FilterPrompt.Foreground(styles.Secondary)
  202. defaultStyle.FilterCursor = defaultStyle.FilterCursor.Foreground(styles.Flamingo)
  203. listDelegate.Styles = defaultItemStyle
  204. listComponent := list.New([]list.Item{}, listDelegate, 0, 0)
  205. listComponent.FilterInput.PromptStyle = defaultStyle.FilterPrompt
  206. listComponent.FilterInput.Cursor.Style = defaultStyle.FilterCursor
  207. listComponent.SetShowTitle(false)
  208. listComponent.SetShowPagination(false)
  209. listComponent.SetShowHelp(false)
  210. listComponent.SetShowStatusBar(false)
  211. listComponent.DisableQuitKeybindings()
  212. return &sessionsCmp{
  213. app: app,
  214. list: listComponent,
  215. focused: false,
  216. }
  217. }