session.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. package dialog
  2. import (
  3. "context"
  4. "strings"
  5. "slices"
  6. "github.com/charmbracelet/bubbles/v2/textinput"
  7. tea "github.com/charmbracelet/bubbletea/v2"
  8. "github.com/muesli/reflow/truncate"
  9. "github.com/sst/opencode-sdk-go"
  10. "github.com/sst/opencode/internal/app"
  11. "github.com/sst/opencode/internal/components/list"
  12. "github.com/sst/opencode/internal/components/modal"
  13. "github.com/sst/opencode/internal/components/toast"
  14. "github.com/sst/opencode/internal/layout"
  15. "github.com/sst/opencode/internal/styles"
  16. "github.com/sst/opencode/internal/theme"
  17. "github.com/sst/opencode/internal/util"
  18. )
  19. // SessionDialog interface for the session switching dialog
  20. type SessionDialog interface {
  21. layout.Modal
  22. }
  23. // sessionItem is a custom list item for sessions that can show delete confirmation
  24. type sessionItem struct {
  25. title string
  26. isDeleteConfirming bool
  27. isCurrentSession bool
  28. }
  29. func (s sessionItem) Render(
  30. selected bool,
  31. width int,
  32. isFirstInViewport bool,
  33. baseStyle styles.Style,
  34. ) string {
  35. t := theme.CurrentTheme()
  36. var text string
  37. if s.isDeleteConfirming {
  38. text = "Press again to confirm delete"
  39. } else {
  40. if s.isCurrentSession {
  41. text = "● " + s.title
  42. } else {
  43. text = s.title
  44. }
  45. }
  46. truncatedStr := truncate.StringWithTail(text, uint(width-1), "...")
  47. var itemStyle styles.Style
  48. if selected {
  49. if s.isDeleteConfirming {
  50. // Red background for delete confirmation
  51. itemStyle = baseStyle.
  52. Background(t.Error()).
  53. Foreground(t.BackgroundElement()).
  54. Width(width).
  55. PaddingLeft(1)
  56. } else if s.isCurrentSession {
  57. // Different style for current session when selected
  58. itemStyle = baseStyle.
  59. Background(t.Primary()).
  60. Foreground(t.BackgroundElement()).
  61. Width(width).
  62. PaddingLeft(1).
  63. Bold(true)
  64. } else {
  65. // Normal selection
  66. itemStyle = baseStyle.
  67. Background(t.Primary()).
  68. Foreground(t.BackgroundElement()).
  69. Width(width).
  70. PaddingLeft(1)
  71. }
  72. } else {
  73. if s.isDeleteConfirming {
  74. // Red text for delete confirmation when not selected
  75. itemStyle = baseStyle.
  76. Foreground(t.Error()).
  77. PaddingLeft(1)
  78. } else if s.isCurrentSession {
  79. // Highlight current session when not selected
  80. itemStyle = baseStyle.
  81. Foreground(t.Primary()).
  82. PaddingLeft(1).
  83. Bold(true)
  84. } else {
  85. itemStyle = baseStyle.
  86. PaddingLeft(1)
  87. }
  88. }
  89. return itemStyle.Render(truncatedStr)
  90. }
  91. func (s sessionItem) Selectable() bool {
  92. return true
  93. }
  94. type sessionDialog struct {
  95. width int
  96. height int
  97. modal *modal.Modal
  98. sessions []opencode.Session
  99. list list.List[sessionItem]
  100. app *app.App
  101. deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
  102. renameMode bool
  103. renameInput textinput.Model
  104. renameIndex int // index of session being renamed
  105. }
  106. func (s *sessionDialog) Init() tea.Cmd {
  107. return nil
  108. }
  109. func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  110. switch msg := msg.(type) {
  111. case tea.WindowSizeMsg:
  112. s.width = msg.Width
  113. s.height = msg.Height
  114. s.list.SetMaxWidth(layout.Current.Container.Width - 12)
  115. case tea.KeyPressMsg:
  116. if s.renameMode {
  117. switch msg.String() {
  118. case "enter":
  119. if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) && idx == s.renameIndex {
  120. newTitle := s.renameInput.Value()
  121. if strings.TrimSpace(newTitle) != "" {
  122. sessionToUpdate := s.sessions[idx]
  123. return s, tea.Sequence(
  124. func() tea.Msg {
  125. ctx := context.Background()
  126. err := s.app.UpdateSession(ctx, sessionToUpdate.ID, newTitle)
  127. if err != nil {
  128. return toast.NewErrorToast("Failed to rename session: " + err.Error())()
  129. }
  130. s.sessions[idx].Title = newTitle
  131. s.renameMode = false
  132. s.modal.SetTitle("Switch Session")
  133. s.updateListItems()
  134. return toast.NewSuccessToast("Session renamed successfully")()
  135. },
  136. )
  137. }
  138. }
  139. s.renameMode = false
  140. s.modal.SetTitle("Switch Session")
  141. s.updateListItems()
  142. return s, nil
  143. default:
  144. var cmd tea.Cmd
  145. s.renameInput, cmd = s.renameInput.Update(msg)
  146. return s, cmd
  147. }
  148. } else {
  149. switch msg.String() {
  150. case "enter":
  151. if s.deleteConfirmation >= 0 {
  152. s.deleteConfirmation = -1
  153. s.updateListItems()
  154. return s, nil
  155. }
  156. if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
  157. selectedSession := s.sessions[idx]
  158. return s, tea.Sequence(
  159. util.CmdHandler(modal.CloseModalMsg{}),
  160. util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
  161. )
  162. }
  163. case "n":
  164. return s, tea.Sequence(
  165. util.CmdHandler(modal.CloseModalMsg{}),
  166. util.CmdHandler(app.SessionClearedMsg{}),
  167. )
  168. case "r":
  169. if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
  170. s.renameMode = true
  171. s.renameIndex = idx
  172. s.setupRenameInput(s.sessions[idx].Title)
  173. s.modal.SetTitle("Rename Session")
  174. s.updateListItems()
  175. return s, textinput.Blink
  176. }
  177. case "x", "delete", "backspace":
  178. if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
  179. if s.deleteConfirmation == idx {
  180. // Second press - actually delete the session
  181. sessionToDelete := s.sessions[idx]
  182. return s, tea.Sequence(
  183. func() tea.Msg {
  184. s.sessions = slices.Delete(s.sessions, idx, idx+1)
  185. s.deleteConfirmation = -1
  186. s.updateListItems()
  187. return nil
  188. },
  189. s.deleteSession(sessionToDelete.ID),
  190. )
  191. } else {
  192. // First press - enter delete confirmation mode
  193. s.deleteConfirmation = idx
  194. s.updateListItems()
  195. return s, nil
  196. }
  197. }
  198. case "esc":
  199. if s.deleteConfirmation >= 0 {
  200. s.deleteConfirmation = -1
  201. s.updateListItems()
  202. return s, nil
  203. }
  204. }
  205. }
  206. }
  207. if !s.renameMode {
  208. var cmd tea.Cmd
  209. listModel, cmd := s.list.Update(msg)
  210. s.list = listModel.(list.List[sessionItem])
  211. return s, cmd
  212. }
  213. return s, nil
  214. }
  215. func (s *sessionDialog) Render(background string) string {
  216. if s.renameMode {
  217. // Show rename input instead of list
  218. t := theme.CurrentTheme()
  219. renameView := s.renameInput.View()
  220. mutedStyle := styles.NewStyle().
  221. Foreground(t.TextMuted()).
  222. Background(t.BackgroundPanel()).
  223. Render
  224. helpText := mutedStyle("Enter to confirm, Esc to cancel")
  225. helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
  226. content := strings.Join([]string{renameView, helpText}, "\n")
  227. return s.modal.Render(content, background)
  228. }
  229. listView := s.list.View()
  230. t := theme.CurrentTheme()
  231. keyStyle := styles.NewStyle().
  232. Foreground(t.Text()).
  233. Background(t.BackgroundPanel()).
  234. Bold(true).
  235. Render
  236. mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
  237. leftHelp := keyStyle("n") + mutedStyle(" new ") + keyStyle("r") + mutedStyle(" rename")
  238. rightHelp := keyStyle("x/del") + mutedStyle(" delete")
  239. bgColor := t.BackgroundPanel()
  240. helpText := layout.Render(layout.FlexOptions{
  241. Direction: layout.Row,
  242. Justify: layout.JustifySpaceBetween,
  243. Width: layout.Current.Container.Width - 14,
  244. Background: &bgColor,
  245. }, layout.FlexItem{View: leftHelp}, layout.FlexItem{View: rightHelp})
  246. helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
  247. content := strings.Join([]string{listView, helpText}, "\n")
  248. return s.modal.Render(content, background)
  249. }
  250. func (s *sessionDialog) setupRenameInput(currentTitle string) {
  251. t := theme.CurrentTheme()
  252. bgColor := t.BackgroundPanel()
  253. textColor := t.Text()
  254. textMutedColor := t.TextMuted()
  255. s.renameInput = textinput.New()
  256. s.renameInput.SetValue(currentTitle)
  257. s.renameInput.Focus()
  258. s.renameInput.CharLimit = 100
  259. s.renameInput.SetWidth(layout.Current.Container.Width - 20)
  260. s.renameInput.Styles.Blurred.Placeholder = styles.NewStyle().
  261. Foreground(textMutedColor).
  262. Background(bgColor).
  263. Lipgloss()
  264. s.renameInput.Styles.Blurred.Text = styles.NewStyle().
  265. Foreground(textColor).
  266. Background(bgColor).
  267. Lipgloss()
  268. s.renameInput.Styles.Focused.Placeholder = styles.NewStyle().
  269. Foreground(textMutedColor).
  270. Background(bgColor).
  271. Lipgloss()
  272. s.renameInput.Styles.Focused.Text = styles.NewStyle().
  273. Foreground(textColor).
  274. Background(bgColor).
  275. Lipgloss()
  276. s.renameInput.Styles.Focused.Prompt = styles.NewStyle().
  277. Background(bgColor).
  278. Lipgloss()
  279. }
  280. func (s *sessionDialog) updateListItems() {
  281. _, currentIdx := s.list.GetSelectedItem()
  282. var items []sessionItem
  283. for i, sess := range s.sessions {
  284. item := sessionItem{
  285. title: sess.Title,
  286. isDeleteConfirming: s.deleteConfirmation == i,
  287. isCurrentSession: s.app.Session != nil && s.app.Session.ID == sess.ID,
  288. }
  289. items = append(items, item)
  290. }
  291. s.list.SetItems(items)
  292. s.list.SetSelectedIndex(currentIdx)
  293. }
  294. func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
  295. return func() tea.Msg {
  296. ctx := context.Background()
  297. if err := s.app.DeleteSession(ctx, sessionID); err != nil {
  298. return toast.NewErrorToast("Failed to delete session: " + err.Error())()
  299. }
  300. return nil
  301. }
  302. }
  303. // ReopenSessionModalMsg is emitted when the session modal should be reopened
  304. type ReopenSessionModalMsg struct{}
  305. func (s *sessionDialog) Close() tea.Cmd {
  306. if s.renameMode {
  307. // If in rename mode, exit rename mode and return a command to reopen the modal
  308. s.renameMode = false
  309. s.modal.SetTitle("Switch Session")
  310. s.updateListItems()
  311. // Return a command that will reopen the session modal
  312. return func() tea.Msg {
  313. return ReopenSessionModalMsg{}
  314. }
  315. }
  316. // Normal close behavior
  317. return nil
  318. }
  319. // NewSessionDialog creates a new session switching dialog
  320. func NewSessionDialog(app *app.App) SessionDialog {
  321. sessions, _ := app.ListSessions(context.Background())
  322. var filteredSessions []opencode.Session
  323. var items []sessionItem
  324. for _, sess := range sessions {
  325. if sess.ParentID != "" {
  326. continue
  327. }
  328. filteredSessions = append(filteredSessions, sess)
  329. items = append(items, sessionItem{
  330. title: sess.Title,
  331. isDeleteConfirming: false,
  332. isCurrentSession: app.Session != nil && app.Session.ID == sess.ID,
  333. })
  334. }
  335. listComponent := list.NewListComponent(
  336. list.WithItems(items),
  337. list.WithMaxVisibleHeight[sessionItem](10),
  338. list.WithFallbackMessage[sessionItem]("No sessions available"),
  339. list.WithAlphaNumericKeys[sessionItem](true),
  340. list.WithRenderFunc(
  341. func(item sessionItem, selected bool, width int, baseStyle styles.Style) string {
  342. return item.Render(selected, width, false, baseStyle)
  343. },
  344. ),
  345. list.WithSelectableFunc(func(item sessionItem) bool {
  346. return true
  347. }),
  348. )
  349. listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
  350. return &sessionDialog{
  351. sessions: filteredSessions,
  352. list: listComponent,
  353. app: app,
  354. deleteConfirmation: -1,
  355. renameMode: false,
  356. renameIndex: -1,
  357. modal: modal.New(
  358. modal.WithTitle("Switch Session"),
  359. modal.WithMaxWidth(layout.Current.Container.Width-8),
  360. ),
  361. }
  362. }