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 b strings.Builder
357
358 // Header
359 header := m.renderHeader()
360 b.WriteString(header)
361 b.WriteString("\n\n")
362
363 // Error display
364 if m.err != nil {
365 b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366 b.WriteString("\n\n")
367 }
368
369 // Status message
370 if m.statusMessage != "" {
371 b.WriteString(t.Subtle.Render(m.statusMessage))
372 b.WriteString("\n\n")
373 }
374
375 // Sessions list
376 if len(m.sessions) == 0 {
377 b.WriteString(t.Subtle.Render(" No saved sessions found."))
378 b.WriteString("\n")
379 } else {
380 b.WriteString(m.renderSessionsList())
381 }
382
383 b.WriteString("\n")
384
385 // Help
386 helpView := m.help.View(m.keys)
387 b.WriteString(helpView)
388
389 return b.String()
390}
391
392func (m SessionsModel) renderHeader() string {
393 t := m.theme
394
395 iconStyle := lipgloss.NewStyle().Foreground(t.Accent)
396 titleStyle := t.Title.MarginLeft(1)
397
398 icon := iconStyle.Render(styles.IconSession)
399 title := titleStyle.Render("Sessions")
400
401 // Count stats
402 var activeCount, exitedCount int
403 for _, s := range m.sessions {
404 if s.IsCurrent || s.IsActive {
405 activeCount++
406 } else {
407 exitedCount++
408 }
409 }
410
411 stats := t.Subtle.Render(fmt.Sprintf(" %s %d active %s %d exited",
412 styles.IconPlay, activeCount,
413 styles.IconStop, exitedCount,
414 ))
415
416 return icon + title + stats
417}
418
419func (m SessionsModel) renderSessionsList() string {
420 var b strings.Builder
421
422 for i, info := range m.sessions {
423 isSelected := i == m.selectedIdx
424 isExpanded := i == m.expandedIdx
425
426 b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
427 b.WriteString("\n")
428
429 // Render expanded details
430 if isExpanded && info.State != nil {
431 b.WriteString(m.renderSessionDetails(info))
432 }
433 }
434
435 return b.String()
436}
437
438func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
439 t := m.theme
440
441 // Cursor
442 cursor := " "
443 if isSelected {
444 cursor = t.Highlight.Render(styles.IconCursor + " ")
445 }
446
447 // Status icon
448 var statusIcon string
449 var statusStyle lipgloss.Style
450 switch {
451 case info.IsCurrent:
452 statusIcon = styles.IconPlay
453 statusStyle = lipgloss.NewStyle().Foreground(t.Accent)
454 case info.IsActive:
455 statusIcon = styles.IconPlay
456 statusStyle = lipgloss.NewStyle().Foreground(t.Warning)
457 default:
458 statusIcon = styles.IconStop
459 statusStyle = t.Subtle
460 }
461
462 // Session ID
463 idStyle := t.Normal
464 if isSelected {
465 idStyle = t.Highlight
466 }
467
468 // Label for current/active
469 label := ""
470 if info.IsCurrent {
471 label = t.Badge.Render("current")
472 } else if info.IsActive {
473 label = t.BadgeMuted.Render("active")
474 }
475
476 // Expand indicator
477 expandIcon := styles.IconExpand
478 if isExpanded {
479 expandIcon = styles.IconCollapse
480 }
481 expandStyle := t.Subtle
482
483 // Tab/pane counts
484 counts := t.Subtle.Render(fmt.Sprintf("%s %d %s %d",
485 styles.IconTab, info.TabCount,
486 styles.IconPane, info.PaneCount,
487 ))
488
489 // Time
490 relTime := usecase.GetRelativeTime(info.UpdatedAt)
491 timeStr := t.Subtle.Render(fmt.Sprintf("%s %s", styles.IconClock, relTime))
492
493 // Build row
494 row := fmt.Sprintf("%s%s %s %s %s %s %s",
495 cursor,
496 statusStyle.Render(statusIcon),
497 idStyle.Render(string(info.Session.ID)),
498 label,
499 expandStyle.Render(expandIcon),
500 counts,
501 timeStr,
502 )
503
504 return row
505}
506
507func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
508 t := m.theme
509 var b strings.Builder
510
511 if info.State == nil {
512 b.WriteString(t.Subtle.Render(" No state data available\n"))
513 return b.String()
514 }
515
516 // Tree prefix styles
517 treeStyle := lipgloss.NewStyle().Foreground(t.Border)
518 leafStyle := lipgloss.NewStyle().Foreground(t.Muted)
519
520 // Render tabs as tree
521 for tabIdx, tab := range info.State.Tabs {
522 isLastTab := tabIdx == len(info.State.Tabs)-1
523
524 // Tab branch
525 branch := "├── "
526 if isLastTab {
527 branch = "└── "
528 }
529
530 tabIcon := styles.IconTab
531 if tab.IsPinned {
532 tabIcon = styles.IconCheckboxChecked
533 }
534
535 tabName := tab.Name
536 if tabName == "" {
537 tabName = "Tab"
538 }
539
540 b.WriteString(fmt.Sprintf(" %s%s %s\n",
541 treeStyle.Render(branch),
542 leafStyle.Render(tabIcon),
543 t.Normal.Render(tabName),
544 ))
545
546 // Render panes under this tab
547 m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
548 }
549
550 b.WriteString("\n")
551 return b.String()
552}
553
554func (m SessionsModel) renderPaneTree(
555 b *strings.Builder,
556 ws *entity.WorkspaceSnapshot,
557 isLastTab bool,
558 t *styles.Theme,
559 treeStyle, leafStyle lipgloss.Style,
560) {
561 if ws == nil || ws.Root == nil {
562 return
563 }
564
565 // Determine prefix for children
566 childPrefix := " │ "
567 if isLastTab {
568 childPrefix = " "
569 }
570
571 m.renderPaneNode(b, ws.Root, childPrefix, true, t, treeStyle, leafStyle)
572}
573
574func (m SessionsModel) renderPaneNode(
575 b *strings.Builder,
576 node *entity.PaneNodeSnapshot,
577 prefix string,
578 isLast bool,
579 t *styles.Theme,
580 treeStyle, leafStyle lipgloss.Style,
581) {
582 if node == nil {
583 return
584 }
585
586 branch := "├── "
587 if isLast {
588 branch = "└── "
589 }
590
591 if node.Pane != nil {
592 // Leaf node (actual pane)
593 pane := node.Pane
594 title := pane.Title
595 if title == "" {
596 title = pane.URI
597 }
598 // Truncate long titles
599 const maxTitleLen = 50
600 if len(title) > maxTitleLen {
601 title = title[:maxTitleLen-3] + "..."
602 }
603
604 fmt.Fprintf(b, "%s%s%s %s\n",
605 prefix,
606 treeStyle.Render(branch),
607 leafStyle.Render(styles.IconPane),
608 t.Subtle.Render(title),
609 )
610 } else if len(node.Children) > 0 {
611 // Container node (split or stacked)
612 splitIcon := styles.IconSessionStack
613 splitType := "split"
614 if node.IsStacked {
615 splitType = "stacked"
616 } else if node.SplitRatio > 0 {
617 splitType = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
618 }
619
620 fmt.Fprintf(b, "%s%s%s %s\n",
621 prefix,
622 treeStyle.Render(branch),
623 leafStyle.Render(splitIcon),
624 t.Subtle.Render(splitType),
625 )
626
627 // Child prefix
628 childPrefix := prefix + "│ "
629 if isLast {
630 childPrefix = prefix + " "
631 }
632
633 // Render all children
634 for i, child := range node.Children {
635 isLastChild := i == len(node.Children)-1
636 m.renderPaneNode(b, child, childPrefix, isLastChild, t, treeStyle, leafStyle)
637 }
638 }
639}
640
641// Ensure interface compliance at compile time.
642var _ tea.Model = (*SessionsModel)(nil)