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