1// Package model provides Bubble Tea models for CLI commands.
2package model
3
4import (
5 "context"
6 "fmt"
7 "strings"
8
9 "github.com/charmbracelet/bubbles/help"
10 "github.com/charmbracelet/bubbles/key"
11 tea "github.com/charmbracelet/bubbletea"
12 "github.com/charmbracelet/lipgloss"
13
14 "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/cli/styles"
16 "github.com/bnema/dumber/internal/domain/entity"
17 "github.com/bnema/dumber/internal/infrastructure/desktop"
18 "github.com/bnema/dumber/internal/logging"
19)
20
21// SessionsModel is the Bubble Tea model for interactive session browser.
22type SessionsModel struct {
23 // UI components
24 help help.Model
25 keys sessionsKeyMap
26 confirm *styles.ConfirmModel
27
28 // State
29 sessions []entity.SessionInfo
30 selectedIdx int
31 expandedIdx int // -1 means none expanded
32 width int
33 height int
34 err error
35 statusMessage string
36 currentSession entity.SessionID
37
38 // Config
39 maxListedSessions int
40
41 // Dependencies
42 ctx context.Context
43 listSessionsUC *usecase.ListSessionsUseCase
44 restoreUC *usecase.RestoreSessionUseCase
45 deleteSessionUC *usecase.DeleteSessionUseCase
46 theme *styles.Theme
47}
48
49// sessionsKeyMap defines keybindings for the sessions browser.
50type sessionsKeyMap struct {
51 Up key.Binding
52 Down key.Binding
53 Expand key.Binding
54 Restore key.Binding
55 Delete key.Binding
56 Refresh key.Binding
57 Help key.Binding
58 Quit key.Binding
59}
60
61// ShortHelp returns keybindings for the short help view.
62func (k sessionsKeyMap) ShortHelp() []key.Binding {
63 return []key.Binding{k.Up, k.Down, k.Expand, k.Restore, k.Delete, k.Quit}
64}
65
66// FullHelp returns keybindings for the full help view.
67func (k sessionsKeyMap) FullHelp() [][]key.Binding {
68 return [][]key.Binding{
69 {k.Up, k.Down, k.Expand},
70 {k.Restore, k.Delete, k.Refresh},
71 {k.Help, k.Quit},
72 }
73}
74
75func defaultSessionsKeyMap() sessionsKeyMap {
76 return sessionsKeyMap{
77 Up: key.NewBinding(
78 key.WithKeys("k", "up"),
79 key.WithHelp("↑/k", "up"),
80 ),
81 Down: key.NewBinding(
82 key.WithKeys("j", "down"),
83 key.WithHelp("↓/j", "down"),
84 ),
85 Expand: key.NewBinding(
86 key.WithKeys("enter", "tab"),
87 key.WithHelp("enter", "expand/collapse"),
88 ),
89 Restore: key.NewBinding(
90 key.WithKeys("r"),
91 key.WithHelp("r", "restore"),
92 ),
93 Delete: key.NewBinding(
94 key.WithKeys("x", "d"),
95 key.WithHelp("x", "delete"),
96 ),
97 Refresh: key.NewBinding(
98 key.WithKeys("R"),
99 key.WithHelp("R", "refresh"),
100 ),
101 Help: key.NewBinding(
102 key.WithKeys("?"),
103 key.WithHelp("?", "help"),
104 ),
105 Quit: key.NewBinding(
106 key.WithKeys("q", "esc", "ctrl+c"),
107 key.WithHelp("q", "quit"),
108 ),
109 }
110}
111
112// SessionsModelConfig holds configuration for the sessions model.
113type SessionsModelConfig struct {
114 ListSessionsUC *usecase.ListSessionsUseCase
115 RestoreUC *usecase.RestoreSessionUseCase
116 DeleteSessionUC *usecase.DeleteSessionUseCase
117 CurrentSession entity.SessionID
118 MaxExitedSessions int
119}
120
121// NewSessionsModel creates a new sessions browser model.
122func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel {
123 maxListed := cfg.MaxExitedSessions
124 if maxListed <= 0 {
125 maxListed = 50 // fallback default
126 }
127
128 return SessionsModel{
129 help: help.New(),
130 keys: defaultSessionsKeyMap(),
131 selectedIdx: 0,
132 expandedIdx: -1,
133 width: 80,
134 height: 24,
135 maxListedSessions: maxListed,
136 ctx: ctx,
137 listSessionsUC: cfg.ListSessionsUC,
138 restoreUC: cfg.RestoreUC,
139 deleteSessionUC: cfg.DeleteSessionUC,
140 currentSession: cfg.CurrentSession,
141 theme: theme,
142 }
143}
144
145// Init implements tea.Model.
146func (m SessionsModel) Init() tea.Cmd {
147 return m.loadSessions
148}
149
150// sessionsLoadedMsg is sent when sessions are loaded.
151type sessionsLoadedMsg struct {
152 sessions []entity.SessionInfo
153 err error
154}
155
156// sessionDeletedMsg is sent when a session is deleted.
157type sessionDeletedMsg struct {
158 sessionID entity.SessionID
159 err error
160}
161
162// sessionRestoredMsg is sent when a session restoration is triggered.
163type sessionRestoredMsg struct {
164 sessionID entity.SessionID
165 err error
166}
167
168func (m SessionsModel) loadSessions() tea.Msg {
169 log := logging.FromContext(m.ctx)
170 log.Debug().Msg("loading sessions")
171
172 if m.listSessionsUC == nil {
173 return sessionsLoadedMsg{err: fmt.Errorf("session management not available")}
174 }
175
176 output, err := m.listSessionsUC.Execute(m.ctx, m.currentSession, m.maxListedSessions)
177 if err != nil {
178 log.Error().Err(err).Msg("failed to load sessions")
179 return sessionsLoadedMsg{err: err}
180 }
181
182 log.Debug().Int("count", len(output.Sessions)).Msg("loaded sessions")
183 return sessionsLoadedMsg{sessions: output.Sessions}
184}
185
186// Update implements tea.Model.
187func (m SessionsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
188 // Handle confirm modal
189 if m.confirm != nil {
190 return m.handleConfirmModal(msg)
191 }
192
193 switch msg := msg.(type) {
194 case tea.WindowSizeMsg:
195 m.width = msg.Width
196 m.height = msg.Height
197 m.help.Width = msg.Width
198 return m, nil
199
200 case tea.KeyMsg:
201 return m.handleKeyMsg(msg)
202
203 case sessionsLoadedMsg:
204 if msg.err != nil {
205 m.err = msg.err
206 } else {
207 m.sessions = msg.sessions
208 m.err = nil
209 }
210 return m, nil
211
212 case sessionDeletedMsg:
213 if msg.err != nil {
214 m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
215 } else {
216 m.statusMessage = fmt.Sprintf("Session %s deleted", msg.sessionID)
217 }
218 return m, m.loadSessions
219
220 case sessionRestoredMsg:
221 if msg.err != nil {
222 m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
223 } else {
224 m.statusMessage = fmt.Sprintf("Restoring session %s...", msg.sessionID)
225 return m, tea.Quit
226 }
227 return m, nil
228 }
229
230 return m, nil
231}
232
233func (m SessionsModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) {
234 confirm, cmd := m.confirm.Update(msg)
235 m.confirm = &confirm
236 if m.confirm.Done() {
237 if m.confirm.Result() {
238 // User confirmed deletion
239 if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
240 session := m.sessions[m.selectedIdx]
241 cmd = m.deleteSession(session.Session.ID)
242 }
243 }
244 m.confirm = nil
245 return m, cmd
246 }
247 return m, cmd
248}
249
250func (m SessionsModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
251 switch {
252 case key.Matches(msg, m.keys.Quit):
253 return m, tea.Quit
254
255 case key.Matches(msg, m.keys.Up):
256 if m.selectedIdx > 0 {
257 m.selectedIdx--
258 }
259 return m, nil
260
261 case key.Matches(msg, m.keys.Down):
262 if m.selectedIdx < len(m.sessions)-1 {
263 m.selectedIdx++
264 }
265 return m, nil
266
267 case key.Matches(msg, m.keys.Expand):
268 if m.expandedIdx == m.selectedIdx {
269 m.expandedIdx = -1 // collapse
270 } else {
271 m.expandedIdx = m.selectedIdx // expand
272 }
273 return m, nil
274
275 case key.Matches(msg, m.keys.Restore):
276 if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
277 session := m.sessions[m.selectedIdx]
278 if session.IsCurrent {
279 m.statusMessage = "Cannot restore current session"
280 return m, nil
281 }
282 return m, m.restoreSession(session.Session.ID)
283 }
284 return m, nil
285
286 case key.Matches(msg, m.keys.Delete):
287 if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
288 session := m.sessions[m.selectedIdx]
289 if session.IsCurrent || session.IsActive {
290 m.statusMessage = "Cannot delete active session"
291 return m, nil
292 }
293 confirm := styles.NewConfirm(m.theme, fmt.Sprintf("Delete session %s?", session.Session.ID))
294 m.confirm = &confirm
295 }
296 return m, nil
297
298 case key.Matches(msg, m.keys.Refresh):
299 return m, m.loadSessions
300
301 case key.Matches(msg, m.keys.Help):
302 m.help.ShowAll = !m.help.ShowAll
303 return m, nil
304 }
305
306 return m, nil
307}
308
309func (m SessionsModel) deleteSession(sessionID entity.SessionID) tea.Cmd {
310 return func() tea.Msg {
311 if m.deleteSessionUC == nil {
312 return sessionDeletedMsg{sessionID: sessionID, err: fmt.Errorf("delete session use case not available")}
313 }
314
315 err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{
316 SessionID: sessionID,
317 CurrentSessionID: m.currentSession,
318 })
319 return sessionDeletedMsg{sessionID: sessionID, err: err}
320 }
321}
322
323func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd {
324 return func() tea.Msg {
325 log := logging.FromContext(m.ctx)
326 log.Info().Str("session_id", string(sessionID)).Msg("restoring session")
327
328 if m.restoreUC == nil {
329 return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("restore use case not available")}
330 }
331
332 // Validate the session has restorable state
333 _, err := m.restoreUC.Execute(m.ctx, usecase.RestoreInput{SessionID: sessionID})
334 if err != nil {
335 return sessionRestoredMsg{sessionID: sessionID, err: err}
336 }
337
338 // Spawn a new dumber instance
339 spawner := desktop.NewSessionSpawner(m.ctx)
340 if err := spawner.SpawnWithSession(sessionID); err != nil {
341 return sessionRestoredMsg{sessionID: sessionID, err: err}
342 }
343
344 return sessionRestoredMsg{sessionID: sessionID}
345 }
346}
347
348// View implements tea.Model.
349func (m SessionsModel) View() string {
350 // Handle confirm modal
351 if m.confirm != nil {
352 return m.confirm.View()
353 }
354
355 t := m.theme
356 var prefix strings.Builder
357
358 // Header
359 prefix.WriteString(m.renderHeader())
360 prefix.WriteString("\n")
361
362 // Error display
363 if m.err != nil {
364 prefix.WriteString("\n")
365 prefix.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366 prefix.WriteString("\n")
367 }
368
369 // Status message
370 if m.statusMessage != "" {
371 prefix.WriteString("\n")
372 prefix.WriteString(t.Subtle.Render(m.statusMessage))
373 prefix.WriteString("\n")
374 }
375
376 // Help
377 helpView := m.help.View(m.keys)
378
379 // Compute available height for list so the view never exceeds terminal height.
380 // This prevents Bubble Tea from truncating the bottom of the UI when there
381 // are many sessions.
382 prefixH := lipgloss.Height(prefix.String())
383 helpH := lipgloss.Height(helpView)
384 listH := m.height - prefixH - 1 - helpH // one spacer line between list and help
385 if listH < 1 {
386 listH = 1
387 }
388
389 listView := m.renderSessionsList(listH)
390
391 // Compose final output with a single spacer line above help.
392 return strings.TrimRight(prefix.String(), "\n") + "\n" + listView + "\n" + helpView
393}
394
395func (m SessionsModel) renderHeader() string {
396 t := m.theme
397
398 iconStyle := lipgloss.NewStyle().Foreground(t.Accent)
399 titleStyle := t.Title.MarginLeft(1)
400
401 icon := iconStyle.Render(styles.IconSession)
402 title := titleStyle.Render("Sessions")
403
404 // Count stats
405 var activeCount, exitedCount int
406 for _, s := range m.sessions {
407 if s.IsCurrent || s.IsActive {
408 activeCount++
409 } else {
410 exitedCount++
411 }
412 }
413
414 stats := t.Subtle.Render(fmt.Sprintf(" %s %d active %s %d exited",
415 styles.IconPlay, activeCount,
416 styles.IconStop, exitedCount,
417 ))
418
419 return icon + title + stats
420}
421
422func (m SessionsModel) renderSessionsList(maxHeight int) string {
423 t := m.theme
424
425 if maxHeight <= 0 {
426 return ""
427 }
428 if len(m.sessions) == 0 {
429 return t.Subtle.Render(" No saved sessions found.")
430 }
431
432 // Determine starting index so the selected row stays visible even when
433 // rows above it are expanded and consume multiple lines.
434 start := m.selectedIdx
435 if start < 0 {
436 start = 0
437 }
438 if start >= len(m.sessions) {
439 start = len(m.sessions) - 1
440 }
441 used := m.rowHeight(start)
442 for start > 0 {
443 prevHeight := m.rowHeight(start - 1)
444 if used+prevHeight > maxHeight {
445 break
446 }
447 start--
448 used += prevHeight
449 }
450
451 var b strings.Builder
452 remaining := maxHeight
453
454 for i := start; i < len(m.sessions) && remaining > 0; i++ {
455 info := m.sessions[i]
456 isSelected := i == m.selectedIdx
457 isExpanded := i == m.expandedIdx
458
459 b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
460 remaining--
461 if remaining <= 0 {
462 break
463 }
464
465 // Render expanded details, but never exceed available height.
466 if isExpanded && remaining > 0 {
467 details := m.renderSessionDetails(info)
468 details = truncateLines(details, remaining)
469 if details != "" {
470 b.WriteString("\n")
471 b.WriteString(details)
472 remaining -= lipgloss.Height(details)
473 }
474 }
475
476 if remaining > 0 {
477 b.WriteString("\n")
478 }
479 }
480
481 return strings.TrimRight(b.String(), "\n")
482}
483
484func (m SessionsModel) rowHeight(idx int) int {
485 if idx < 0 || idx >= len(m.sessions) {
486 return 0
487 }
488
489 height := 1 // always render one row line
490 if idx != m.expandedIdx {
491 return height
492 }
493
494 info := m.sessions[idx]
495 details := m.renderSessionDetails(info)
496 if details == "" {
497 return height
498 }
499
500 return height + lipgloss.Height(details)
501}
502
503func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
504 t := m.theme
505
506 // Cursor
507 cursor := " "
508 if isSelected {
509 cursor = t.Highlight.Render(styles.IconCursor + " ")
510 }
511
512 // Status icon
513 var statusIcon string
514 var statusStyle lipgloss.Style
515 switch {
516 case info.IsCurrent:
517 statusIcon = styles.IconPlay
518 statusStyle = lipgloss.NewStyle().Foreground(t.Accent)
519 case info.IsActive:
520 statusIcon = styles.IconPlay
521 statusStyle = lipgloss.NewStyle().Foreground(t.Warning)
522 default:
523 statusIcon = styles.IconStop
524 statusStyle = t.Subtle
525 }
526
527 // Session ID
528 idStyle := t.Normal
529 if isSelected {
530 idStyle = t.Highlight
531 }
532
533 // Label for current/active
534 label := ""
535 if info.IsCurrent {
536 label = t.Badge.Render("current")
537 } else if info.IsActive {
538 label = t.BadgeMuted.Render("active")
539 }
540
541 // Expand indicator
542 expandIcon := styles.IconExpand
543 if isExpanded {
544 expandIcon = styles.IconCollapse
545 }
546 expandStyle := t.Subtle
547
548 // Tab/pane counts
549 counts := t.Subtle.Render(fmt.Sprintf("%s %d %s %d",
550 styles.IconTab, info.TabCount,
551 styles.IconPane, info.PaneCount,
552 ))
553
554 // Time
555 relTime := usecase.GetRelativeTime(info.UpdatedAt)
556 timeStr := t.Subtle.Render(fmt.Sprintf("%s %s", styles.IconClock, relTime))
557
558 // Build row
559 row := fmt.Sprintf("%s%s %s %s %s %s %s",
560 cursor,
561 statusStyle.Render(statusIcon),
562 idStyle.Render(string(info.Session.ID)),
563 label,
564 expandStyle.Render(expandIcon),
565 counts,
566 timeStr,
567 )
568
569 return row
570}
571
572func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
573 t := m.theme
574 var b strings.Builder
575
576 if info.State == nil {
577 b.WriteString(t.Subtle.Render(" No state data available"))
578 return b.String()
579 }
580
581 // Tree prefix styles
582 treeStyle := lipgloss.NewStyle().Foreground(t.Border)
583 leafStyle := lipgloss.NewStyle().Foreground(t.Muted)
584
585 // Render tabs as tree
586 for tabIdx, tab := range info.State.Tabs {
587 isLastTab := tabIdx == len(info.State.Tabs)-1
588
589 // Tab branch
590 branch := "├── "
591 if isLastTab {
592 branch = "└── "
593 }
594
595 tabIcon := styles.IconTab
596 if tab.IsPinned {
597 tabIcon = styles.IconCheckboxChecked
598 }
599
600 tabName := tab.Name
601 if tabName == "" {
602 tabName = "Tab"
603 }
604
605 b.WriteString(fmt.Sprintf(" %s%s %s\n",
606 treeStyle.Render(branch),
607 leafStyle.Render(tabIcon),
608 t.Normal.Render(tabName),
609 ))
610
611 // Render panes under this tab
612 m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
613 }
614
615 return b.String()
616}
617
618func truncateLines(s string, maxLines int) string {
619 if maxLines <= 0 || s == "" {
620 return ""
621 }
622 lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
623 if len(lines) <= maxLines {
624 return strings.Join(lines, "\n")
625 }
626 return strings.Join(lines[:maxLines], "\n")
627}
628
629func (m SessionsModel) renderPaneTree(
630 b *strings.Builder,
631 ws *entity.WorkspaceSnapshot,
632 isLastTab bool,
633 t *styles.Theme,
634 treeStyle, leafStyle lipgloss.Style,
635) {
636 if ws == nil || ws.Root == nil {
637 return
638 }
639
640 // Determine prefix for children
641 childPrefix := " │ "
642 if isLastTab {
643 childPrefix = " "
644 }
645
646 m.renderPaneNode(b, ws.Root, childPrefix, true, t, treeStyle, leafStyle)
647}
648
649func (m SessionsModel) renderPaneNode(
650 b *strings.Builder,
651 node *entity.PaneNodeSnapshot,
652 prefix string,
653 isLast bool,
654 t *styles.Theme,
655 treeStyle, leafStyle lipgloss.Style,
656) {
657 if node == nil {
658 return
659 }
660
661 branch := "├── "
662 if isLast {
663 branch = "└── "
664 }
665
666 if node.Pane != nil {
667 // Leaf node (actual pane)
668 pane := node.Pane
669 title := pane.Title
670 if title == "" {
671 title = pane.URI
672 }
673 // Truncate long titles
674 const maxTitleLen = 50
675 if len(title) > maxTitleLen {
676 title = title[:maxTitleLen-3] + "..."
677 }
678
679 fmt.Fprintf(b, "%s%s%s %s\n",
680 prefix,
681 treeStyle.Render(branch),
682 leafStyle.Render(styles.IconPane),
683 t.Subtle.Render(title),
684 )
685 } else if len(node.Children) > 0 {
686 // Container node (split or stacked)
687 splitIcon := styles.IconSessionStack
688 splitType := "split"
689 if node.IsStacked {
690 splitType = "stacked"
691 } else if node.SplitRatio > 0 {
692 splitType = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
693 }
694
695 fmt.Fprintf(b, "%s%s%s %s\n",
696 prefix,
697 treeStyle.Render(branch),
698 leafStyle.Render(splitIcon),
699 t.Subtle.Render(splitType),
700 )
701
702 // Child prefix
703 childPrefix := prefix + "│ "
704 if isLast {
705 childPrefix = prefix + " "
706 }
707
708 // Render all children
709 for i, child := range node.Children {
710 isLastChild := i == len(node.Children)-1
711 m.renderPaneNode(b, child, childPrefix, isLastChild, t, treeStyle, leafStyle)
712 }
713 }
714}
715
716// Ensure interface compliance at compile time.
717var _ tea.Model = (*SessionsModel)(nil)