sessions.go 6.5 KB

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