v0.33.2 57f21d6

sessions.go RawHistoryFolder

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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close