detached 2718ba9

feat(cli): add interactive sessions TUI browser

commit
2718ba98d7010b973e31c8767652c67d9860b40b
author
bnema · · 2025-12-24 21:27 UTC
committer
brice · · 2025-12-25 05:18 UTC
parent
8022d74
signature
unsigned
4 files · +983 -9
M internal/cli/app.go +20 -9
@@ -31,6 +31,11 @@ type App struct {
3131 // Use cases
3232 SearchHistoryUC *usecase.SearchHistoryUseCase
3333 SessionUC *usecase.ManageSessionUseCase
34+ ListSessionsUC *usecase.ListSessionsUseCase
35+ RestoreUC *usecase.RestoreSessionUseCase
36+
37+ // Repositories
38+ SessionStateRepo repository.SessionStateRepository
3439
3540 // Services
3641 FaviconService *favicon.Service
@@ -105,24 +110,30 @@ func NewApp() (*App, error) {
105110
106111 // Create repositories
107112 historyRepo := sqlite.NewHistoryRepository(db)
113+ sessionStateRepo := sqlite.NewSessionStateRepository(db)
108114
109115 // Create use cases
110116 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
117+ listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir)
118+ restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
111119
112120 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
113121 faviconCacheDir, _ := config.GetFaviconCacheDir()
114122 faviconService := favicon.NewService(faviconCacheDir)
115123
116124 return &App{
117- Config: cfg,
118- Theme: theme,
119- db: db,
120- History: historyRepo,
121- SearchHistoryUC: searchHistoryUC,
122- SessionUC: sessionUC,
123- FaviconService: faviconService,
124- ctx: ctx,
125- logCleanup: logCleanup,
125+ Config: cfg,
126+ Theme: theme,
127+ db: db,
128+ History: historyRepo,
129+ SearchHistoryUC: searchHistoryUC,
130+ SessionUC: sessionUC,
131+ ListSessionsUC: listSessionsUC,
132+ RestoreUC: restoreUC,
133+ SessionStateRepo: sessionStateRepo,
134+ FaviconService: faviconService,
135+ ctx: ctx,
136+ logCleanup: logCleanup,
126137 }, nil
127138 }
128139
@@ -31,6 +31,11 @@ type App struct {
31 // Use cases31 // Use cases
32 SearchHistoryUC *usecase.SearchHistoryUseCase32 SearchHistoryUC *usecase.SearchHistoryUseCase
33 SessionUC *usecase.ManageSessionUseCase33 SessionUC *usecase.ManageSessionUseCase
34+ ListSessionsUC *usecase.ListSessionsUseCase
35+ RestoreUC *usecase.RestoreSessionUseCase
36+
37+ // Repositories
38+ SessionStateRepo repository.SessionStateRepository
34 39
35 // Services40 // Services
36 FaviconService *favicon.Service41 FaviconService *favicon.Service
@@ -105,24 +110,30 @@ func NewApp() (*App, error) {
105 110
106 // Create repositories111 // Create repositories
107 historyRepo := sqlite.NewHistoryRepository(db)112 historyRepo := sqlite.NewHistoryRepository(db)
113+ sessionStateRepo := sqlite.NewSessionStateRepository(db)
108 114
109 // Create use cases115 // Create use cases
110 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)116 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
117+ listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir)
118+ restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
111 119
112 // Create favicon service for CLI (path resolution for dmenu/fuzzel)120 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
113 faviconCacheDir, _ := config.GetFaviconCacheDir()121 faviconCacheDir, _ := config.GetFaviconCacheDir()
114 faviconService := favicon.NewService(faviconCacheDir)122 faviconService := favicon.NewService(faviconCacheDir)
115 123
116 return &App{124 return &App{
117- Config: cfg,125+ Config: cfg,
118- Theme: theme,126+ Theme: theme,
119- db: db,127+ db: db,
120- History: historyRepo,128+ History: historyRepo,
121- SearchHistoryUC: searchHistoryUC,129+ SearchHistoryUC: searchHistoryUC,
122- SessionUC: sessionUC,130+ SessionUC: sessionUC,
123- FaviconService: faviconService,131+ ListSessionsUC: listSessionsUC,
124- ctx: ctx,132+ RestoreUC: restoreUC,
125- logCleanup: logCleanup,133+ SessionStateRepo: sessionStateRepo,
134+ FaviconService: faviconService,
135+ ctx: ctx,
136+ logCleanup: logCleanup,
126 }, nil137 }, nil
127 }138 }
128 139
A internal/cli/cmd/sessions.go +304 -0
@@ -0,0 +1,304 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "text/tabwriter"
9+
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/spf13/cobra"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/cli/model"
15+ "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/infrastructure/desktop"
17+)
18+
19+const defaultSessionsLimit = 20
20+
21+var (
22+ sessionsJSON bool
23+ sessionsLimit int
24+)
25+
26+var sessionsCmd = &cobra.Command{
27+ Use: "sessions",
28+ Short: "Manage browser sessions",
29+ Long: `View, restore, and manage browser sessions.
30+
31+Sessions are automatically saved when the browser runs. You can list
32+past sessions and restore them to continue where you left off.
33+
34+Run without arguments to open the interactive session browser.`,
35+ RunE: runSessions,
36+}
37+
38+func init() {
39+ rootCmd.AddCommand(sessionsCmd)
40+}
41+
42+func runSessions(_ *cobra.Command, _ []string) error {
43+ app := GetApp()
44+ if app == nil {
45+ return fmt.Errorf("app not initialized")
46+ }
47+
48+ if app.ListSessionsUC == nil {
49+ return fmt.Errorf("session management not available")
50+ }
51+
52+ // Get current session ID (empty if not running as browser)
53+ var currentSessionID entity.SessionID
54+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
55+ currentSessionID = active.ID
56+ }
57+
58+ // Run interactive TUI
59+ m := model.NewSessionsModel(app.Ctx(), app.Theme, model.SessionsModelConfig{
60+ ListSessionsUC: app.ListSessionsUC,
61+ RestoreUC: app.RestoreUC,
62+ SessionStateRepo: app.SessionStateRepo,
63+ CurrentSession: currentSessionID,
64+ MaxListedSessions: app.Config.Session.MaxListedSessions,
65+ })
66+
67+ p := tea.NewProgram(m, tea.WithAltScreen())
68+ _, err := p.Run()
69+ return err
70+}
71+
72+// sessions list
73+var sessionsListCmd = &cobra.Command{
74+ Use: "list",
75+ Short: "List saved sessions",
76+ Long: `List all saved browser sessions with their tab/pane counts.
77+
78+Sessions are marked as:
79+ ● current - the currently running session
80+ ○ active - another running dumber instance
81+ (blank) - exited session available for restoration`,
82+ RunE: runSessionsList,
83+}
84+
85+func init() {
86+ sessionsCmd.AddCommand(sessionsListCmd)
87+ sessionsListCmd.Flags().BoolVar(&sessionsJSON, "json", false, "output as JSON")
88+ sessionsListCmd.Flags().IntVar(&sessionsLimit, "limit", defaultSessionsLimit, "maximum sessions to show")
89+}
90+
91+func runSessionsList(_ *cobra.Command, _ []string) error {
92+ app := GetApp()
93+ if app == nil {
94+ return fmt.Errorf("app not initialized")
95+ }
96+
97+ if app.ListSessionsUC == nil {
98+ return fmt.Errorf("session management not available")
99+ }
100+
101+ // Get current session ID (empty if not running as browser)
102+ var currentSessionID entity.SessionID
103+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
104+ currentSessionID = active.ID
105+ }
106+
107+ output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)
108+ if err != nil {
109+ return fmt.Errorf("list sessions: %w", err)
110+ }
111+
112+ if sessionsJSON {
113+ return outputSessionsJSON(output.Sessions)
114+ }
115+
116+ return outputSessionsTable(output.Sessions)
117+}
118+
119+func outputSessionsJSON(sessions []entity.SessionInfo) error {
120+ enc := json.NewEncoder(os.Stdout)
121+ enc.SetIndent("", " ")
122+ return enc.Encode(sessions)
123+}
124+
125+func outputSessionsTable(sessions []entity.SessionInfo) error {
126+ if len(sessions) == 0 {
127+ fmt.Println("No saved sessions found.")
128+ return nil
129+ }
130+
131+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
132+ _, _ = fmt.Fprintln(w, "STATUS\tSESSION ID\tTABS\tPANES\tLAST UPDATED")
133+
134+ for _, info := range sessions {
135+ status := " "
136+ switch {
137+ case info.IsCurrent:
138+ status = "●"
139+ case info.IsActive:
140+ status = "○"
141+ }
142+
143+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
144+ _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
145+ status,
146+ info.Session.ID,
147+ info.TabCount,
148+ info.PaneCount,
149+ relTime,
150+ )
151+ }
152+
153+ return w.Flush()
154+}
155+
156+// sessions restore <id>
157+var sessionsRestoreCmd = &cobra.Command{
158+ Use: "restore <session-id>",
159+ Short: "Restore a saved session",
160+ Long: `Restore a previously saved browser session.
161+
162+This launches a new browser window with all tabs and panes from the
163+saved session. The session ID can be found using 'dumber sessions list'.
164+
165+You can use a short suffix of the session ID as long as it's unique.
166+
167+Example:
168+ dumber sessions restore 20251224_143022_abc1
169+ dumber sessions restore abc1`,
170+ Args: cobra.ExactArgs(1),
171+ RunE: runSessionsRestore,
172+}
173+
174+func init() {
175+ sessionsCmd.AddCommand(sessionsRestoreCmd)
176+}
177+
178+func runSessionsRestore(_ *cobra.Command, args []string) error {
179+ app := GetApp()
180+ if app == nil {
181+ return fmt.Errorf("app not initialized")
182+ }
183+
184+ if app.RestoreUC == nil || app.ListSessionsUC == nil {
185+ return fmt.Errorf("session restoration not available")
186+ }
187+
188+ // Find session by ID or suffix
189+ sessionInfo, err := findSessionByIDOrSuffix(args[0])
190+ if err != nil {
191+ return err
192+ }
193+
194+ sessionID := sessionInfo.Session.ID
195+
196+ // Validate the session has restorable state
197+ _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})
198+ if err != nil {
199+ return fmt.Errorf("restore session: %w", err)
200+ }
201+
202+ // Spawn a new dumber instance with the session
203+ spawner := desktop.NewSessionSpawner(app.Ctx())
204+ if err := spawner.SpawnWithSession(sessionID); err != nil {
205+ return fmt.Errorf("spawn browser: %w", err)
206+ }
207+
208+ fmt.Printf("Restoring session %s...\n", sessionID)
209+ return nil
210+}
211+
212+// sessions delete <id>
213+var sessionsDeleteCmd = &cobra.Command{
214+ Use: "delete <session-id>",
215+ Short: "Delete a saved session",
216+ Long: `Delete a saved browser session and its state.
217+
218+This permanently removes the session snapshot. Active sessions cannot
219+be deleted - close the browser first.
220+
221+You can use a short suffix of the session ID as long as it's unique.
222+
223+Example:
224+ dumber sessions delete 20251224_143022_abc1
225+ dumber sessions delete abc1`,
226+ Args: cobra.ExactArgs(1),
227+ RunE: runSessionsDelete,
228+}
229+
230+func init() {
231+ sessionsCmd.AddCommand(sessionsDeleteCmd)
232+}
233+
234+func runSessionsDelete(_ *cobra.Command, args []string) error {
235+ app := GetApp()
236+ if app == nil {
237+ return fmt.Errorf("app not initialized")
238+ }
239+
240+ if app.SessionStateRepo == nil || app.ListSessionsUC == nil {
241+ return fmt.Errorf("session management not available")
242+ }
243+
244+ // Find session by ID or suffix
245+ sessionInfo, err := findSessionByIDOrSuffix(args[0])
246+ if err != nil {
247+ return err
248+ }
249+
250+ // Check if session is active
251+ if sessionInfo.IsActive || sessionInfo.IsCurrent {
252+ return fmt.Errorf("cannot delete active session - close the browser first")
253+ }
254+
255+ // Delete the session state
256+ if err := app.SessionStateRepo.DeleteSnapshot(app.Ctx(), sessionInfo.Session.ID); err != nil {
257+ return fmt.Errorf("delete session: %w", err)
258+ }
259+
260+ fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID)
261+ return nil
262+}
263+
264+// findSessionByIDOrSuffix finds a session by exact ID or unique suffix.
265+// Users typically identify sessions by the last few characters (e.g., "dee5").
266+func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
267+ app := GetApp()
268+ if app == nil || app.ListSessionsUC == nil {
269+ return nil, fmt.Errorf("app not initialized")
270+ }
271+
272+ // Get current session ID (empty if not running as browser)
273+ var currentSessionID entity.SessionID
274+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
275+ currentSessionID = active.ID
276+ }
277+
278+ // List all sessions
279+ output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, defaultSessionsLimit*5)
280+ if err != nil {
281+ return nil, fmt.Errorf("list sessions: %w", err)
282+ }
283+
284+ // Find matching sessions
285+ var matches []entity.SessionInfo
286+ for _, info := range output.Sessions {
287+ if string(info.Session.ID) == idOrSuffix {
288+ // Exact match - return immediately
289+ return &info, nil
290+ }
291+ if strings.HasSuffix(string(info.Session.ID), idOrSuffix) {
292+ matches = append(matches, info)
293+ }
294+ }
295+
296+ switch len(matches) {
297+ case 0:
298+ return nil, fmt.Errorf("no session found matching '%s'", idOrSuffix)
299+ case 1:
300+ return &matches[0], nil
301+ default:
302+ return nil, fmt.Errorf("ambiguous session ID '%s' matches %d sessions - be more specific", idOrSuffix, len(matches))
303+ }
304+}
@@ -0,0 +1,304 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "text/tabwriter"
9+
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/spf13/cobra"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/cli/model"
15+ "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/infrastructure/desktop"
17+)
18+
19+const defaultSessionsLimit = 20
20+
21+var (
22+ sessionsJSON bool
23+ sessionsLimit int
24+)
25+
26+var sessionsCmd = &cobra.Command{
27+ Use: "sessions",
28+ Short: "Manage browser sessions",
29+ Long: `View, restore, and manage browser sessions.
30+
31+Sessions are automatically saved when the browser runs. You can list
32+past sessions and restore them to continue where you left off.
33+
34+Run without arguments to open the interactive session browser.`,
35+ RunE: runSessions,
36+}
37+
38+func init() {
39+ rootCmd.AddCommand(sessionsCmd)
40+}
41+
42+func runSessions(_ *cobra.Command, _ []string) error {
43+ app := GetApp()
44+ if app == nil {
45+ return fmt.Errorf("app not initialized")
46+ }
47+
48+ if app.ListSessionsUC == nil {
49+ return fmt.Errorf("session management not available")
50+ }
51+
52+ // Get current session ID (empty if not running as browser)
53+ var currentSessionID entity.SessionID
54+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
55+ currentSessionID = active.ID
56+ }
57+
58+ // Run interactive TUI
59+ m := model.NewSessionsModel(app.Ctx(), app.Theme, model.SessionsModelConfig{
60+ ListSessionsUC: app.ListSessionsUC,
61+ RestoreUC: app.RestoreUC,
62+ SessionStateRepo: app.SessionStateRepo,
63+ CurrentSession: currentSessionID,
64+ MaxListedSessions: app.Config.Session.MaxListedSessions,
65+ })
66+
67+ p := tea.NewProgram(m, tea.WithAltScreen())
68+ _, err := p.Run()
69+ return err
70+}
71+
72+// sessions list
73+var sessionsListCmd = &cobra.Command{
74+ Use: "list",
75+ Short: "List saved sessions",
76+ Long: `List all saved browser sessions with their tab/pane counts.
77+
78+Sessions are marked as:
79+ ● current - the currently running session
80+ ○ active - another running dumber instance
81+ (blank) - exited session available for restoration`,
82+ RunE: runSessionsList,
83+}
84+
85+func init() {
86+ sessionsCmd.AddCommand(sessionsListCmd)
87+ sessionsListCmd.Flags().BoolVar(&sessionsJSON, "json", false, "output as JSON")
88+ sessionsListCmd.Flags().IntVar(&sessionsLimit, "limit", defaultSessionsLimit, "maximum sessions to show")
89+}
90+
91+func runSessionsList(_ *cobra.Command, _ []string) error {
92+ app := GetApp()
93+ if app == nil {
94+ return fmt.Errorf("app not initialized")
95+ }
96+
97+ if app.ListSessionsUC == nil {
98+ return fmt.Errorf("session management not available")
99+ }
100+
101+ // Get current session ID (empty if not running as browser)
102+ var currentSessionID entity.SessionID
103+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
104+ currentSessionID = active.ID
105+ }
106+
107+ output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)
108+ if err != nil {
109+ return fmt.Errorf("list sessions: %w", err)
110+ }
111+
112+ if sessionsJSON {
113+ return outputSessionsJSON(output.Sessions)
114+ }
115+
116+ return outputSessionsTable(output.Sessions)
117+}
118+
119+func outputSessionsJSON(sessions []entity.SessionInfo) error {
120+ enc := json.NewEncoder(os.Stdout)
121+ enc.SetIndent("", " ")
122+ return enc.Encode(sessions)
123+}
124+
125+func outputSessionsTable(sessions []entity.SessionInfo) error {
126+ if len(sessions) == 0 {
127+ fmt.Println("No saved sessions found.")
128+ return nil
129+ }
130+
131+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
132+ _, _ = fmt.Fprintln(w, "STATUS\tSESSION ID\tTABS\tPANES\tLAST UPDATED")
133+
134+ for _, info := range sessions {
135+ status := " "
136+ switch {
137+ case info.IsCurrent:
138+ status = "●"
139+ case info.IsActive:
140+ status = "○"
141+ }
142+
143+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
144+ _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
145+ status,
146+ info.Session.ID,
147+ info.TabCount,
148+ info.PaneCount,
149+ relTime,
150+ )
151+ }
152+
153+ return w.Flush()
154+}
155+
156+// sessions restore <id>
157+var sessionsRestoreCmd = &cobra.Command{
158+ Use: "restore <session-id>",
159+ Short: "Restore a saved session",
160+ Long: `Restore a previously saved browser session.
161+
162+This launches a new browser window with all tabs and panes from the
163+saved session. The session ID can be found using 'dumber sessions list'.
164+
165+You can use a short suffix of the session ID as long as it's unique.
166+
167+Example:
168+ dumber sessions restore 20251224_143022_abc1
169+ dumber sessions restore abc1`,
170+ Args: cobra.ExactArgs(1),
171+ RunE: runSessionsRestore,
172+}
173+
174+func init() {
175+ sessionsCmd.AddCommand(sessionsRestoreCmd)
176+}
177+
178+func runSessionsRestore(_ *cobra.Command, args []string) error {
179+ app := GetApp()
180+ if app == nil {
181+ return fmt.Errorf("app not initialized")
182+ }
183+
184+ if app.RestoreUC == nil || app.ListSessionsUC == nil {
185+ return fmt.Errorf("session restoration not available")
186+ }
187+
188+ // Find session by ID or suffix
189+ sessionInfo, err := findSessionByIDOrSuffix(args[0])
190+ if err != nil {
191+ return err
192+ }
193+
194+ sessionID := sessionInfo.Session.ID
195+
196+ // Validate the session has restorable state
197+ _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})
198+ if err != nil {
199+ return fmt.Errorf("restore session: %w", err)
200+ }
201+
202+ // Spawn a new dumber instance with the session
203+ spawner := desktop.NewSessionSpawner(app.Ctx())
204+ if err := spawner.SpawnWithSession(sessionID); err != nil {
205+ return fmt.Errorf("spawn browser: %w", err)
206+ }
207+
208+ fmt.Printf("Restoring session %s...\n", sessionID)
209+ return nil
210+}
211+
212+// sessions delete <id>
213+var sessionsDeleteCmd = &cobra.Command{
214+ Use: "delete <session-id>",
215+ Short: "Delete a saved session",
216+ Long: `Delete a saved browser session and its state.
217+
218+This permanently removes the session snapshot. Active sessions cannot
219+be deleted - close the browser first.
220+
221+You can use a short suffix of the session ID as long as it's unique.
222+
223+Example:
224+ dumber sessions delete 20251224_143022_abc1
225+ dumber sessions delete abc1`,
226+ Args: cobra.ExactArgs(1),
227+ RunE: runSessionsDelete,
228+}
229+
230+func init() {
231+ sessionsCmd.AddCommand(sessionsDeleteCmd)
232+}
233+
234+func runSessionsDelete(_ *cobra.Command, args []string) error {
235+ app := GetApp()
236+ if app == nil {
237+ return fmt.Errorf("app not initialized")
238+ }
239+
240+ if app.SessionStateRepo == nil || app.ListSessionsUC == nil {
241+ return fmt.Errorf("session management not available")
242+ }
243+
244+ // Find session by ID or suffix
245+ sessionInfo, err := findSessionByIDOrSuffix(args[0])
246+ if err != nil {
247+ return err
248+ }
249+
250+ // Check if session is active
251+ if sessionInfo.IsActive || sessionInfo.IsCurrent {
252+ return fmt.Errorf("cannot delete active session - close the browser first")
253+ }
254+
255+ // Delete the session state
256+ if err := app.SessionStateRepo.DeleteSnapshot(app.Ctx(), sessionInfo.Session.ID); err != nil {
257+ return fmt.Errorf("delete session: %w", err)
258+ }
259+
260+ fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID)
261+ return nil
262+}
263+
264+// findSessionByIDOrSuffix finds a session by exact ID or unique suffix.
265+// Users typically identify sessions by the last few characters (e.g., "dee5").
266+func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
267+ app := GetApp()
268+ if app == nil || app.ListSessionsUC == nil {
269+ return nil, fmt.Errorf("app not initialized")
270+ }
271+
272+ // Get current session ID (empty if not running as browser)
273+ var currentSessionID entity.SessionID
274+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
275+ currentSessionID = active.ID
276+ }
277+
278+ // List all sessions
279+ output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, defaultSessionsLimit*5)
280+ if err != nil {
281+ return nil, fmt.Errorf("list sessions: %w", err)
282+ }
283+
284+ // Find matching sessions
285+ var matches []entity.SessionInfo
286+ for _, info := range output.Sessions {
287+ if string(info.Session.ID) == idOrSuffix {
288+ // Exact match - return immediately
289+ return &info, nil
290+ }
291+ if strings.HasSuffix(string(info.Session.ID), idOrSuffix) {
292+ matches = append(matches, info)
293+ }
294+ }
295+
296+ switch len(matches) {
297+ case 0:
298+ return nil, fmt.Errorf("no session found matching '%s'", idOrSuffix)
299+ case 1:
300+ return &matches[0], nil
301+ default:
302+ return nil, fmt.Errorf("ambiguous session ID '%s' matches %d sessions - be more specific", idOrSuffix, len(matches))
303+ }
304+}
A internal/cli/model/sessions.go +646 -0
@@ -0,0 +1,646 @@
1+// Package model provides Bubble Tea models for CLI commands.
2+package model
3+
4+import (
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.
22+type 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+ sessionStateRepo interface {
46+ DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error
47+ }
48+ theme *styles.Theme
49+}
50+
51+// sessionsKeyMap defines keybindings for the sessions browser.
52+type sessionsKeyMap struct {
53+ Up key.Binding
54+ Down key.Binding
55+ Expand key.Binding
56+ Restore key.Binding
57+ Delete key.Binding
58+ Refresh key.Binding
59+ Help key.Binding
60+ Quit key.Binding
61+}
62+
63+// ShortHelp returns keybindings for the short help view.
64+func (k sessionsKeyMap) ShortHelp() []key.Binding {
65+ return []key.Binding{k.Up, k.Down, k.Expand, k.Restore, k.Delete, k.Quit}
66+}
67+
68+// FullHelp returns keybindings for the full help view.
69+func (k sessionsKeyMap) FullHelp() [][]key.Binding {
70+ return [][]key.Binding{
71+ {k.Up, k.Down, k.Expand},
72+ {k.Restore, k.Delete, k.Refresh},
73+ {k.Help, k.Quit},
74+ }
75+}
76+
77+func defaultSessionsKeyMap() sessionsKeyMap {
78+ return sessionsKeyMap{
79+ Up: key.NewBinding(
80+ key.WithKeys("k", "up"),
81+ key.WithHelp("↑/k", "up"),
82+ ),
83+ Down: key.NewBinding(
84+ key.WithKeys("j", "down"),
85+ key.WithHelp("↓/j", "down"),
86+ ),
87+ Expand: key.NewBinding(
88+ key.WithKeys("enter", "tab"),
89+ key.WithHelp("enter", "expand/collapse"),
90+ ),
91+ Restore: key.NewBinding(
92+ key.WithKeys("r"),
93+ key.WithHelp("r", "restore"),
94+ ),
95+ Delete: key.NewBinding(
96+ key.WithKeys("x", "d"),
97+ key.WithHelp("x", "delete"),
98+ ),
99+ Refresh: key.NewBinding(
100+ key.WithKeys("R"),
101+ key.WithHelp("R", "refresh"),
102+ ),
103+ Help: key.NewBinding(
104+ key.WithKeys("?"),
105+ key.WithHelp("?", "help"),
106+ ),
107+ Quit: key.NewBinding(
108+ key.WithKeys("q", "esc", "ctrl+c"),
109+ key.WithHelp("q", "quit"),
110+ ),
111+ }
112+}
113+
114+// SessionsModelConfig holds configuration for the sessions model.
115+type SessionsModelConfig struct {
116+ ListSessionsUC *usecase.ListSessionsUseCase
117+ RestoreUC *usecase.RestoreSessionUseCase
118+ SessionStateRepo interface {
119+ DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error
120+ }
121+ CurrentSession entity.SessionID
122+ MaxListedSessions int
123+}
124+
125+// NewSessionsModel creates a new sessions browser model.
126+func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel {
127+ maxListed := cfg.MaxListedSessions
128+ if maxListed <= 0 {
129+ maxListed = 50 // fallback default
130+ }
131+
132+ return SessionsModel{
133+ help: help.New(),
134+ keys: defaultSessionsKeyMap(),
135+ selectedIdx: 0,
136+ expandedIdx: -1,
137+ width: 80,
138+ height: 24,
139+ maxListedSessions: maxListed,
140+ ctx: ctx,
141+ listSessionsUC: cfg.ListSessionsUC,
142+ restoreUC: cfg.RestoreUC,
143+ sessionStateRepo: cfg.SessionStateRepo,
144+ currentSession: cfg.CurrentSession,
145+ theme: theme,
146+ }
147+}
148+
149+// Init implements tea.Model.
150+func (m SessionsModel) Init() tea.Cmd {
151+ return m.loadSessions
152+}
153+
154+// sessionsLoadedMsg is sent when sessions are loaded.
155+type sessionsLoadedMsg struct {
156+ sessions []entity.SessionInfo
157+ err error
158+}
159+
160+// sessionDeletedMsg is sent when a session is deleted.
161+type sessionDeletedMsg struct {
162+ sessionID entity.SessionID
163+ err error
164+}
165+
166+// sessionRestoredMsg is sent when a session restoration is triggered.
167+type sessionRestoredMsg struct {
168+ sessionID entity.SessionID
169+ err error
170+}
171+
172+func (m SessionsModel) loadSessions() tea.Msg {
173+ log := logging.FromContext(m.ctx)
174+ log.Debug().Msg("loading sessions")
175+
176+ if m.listSessionsUC == nil {
177+ return sessionsLoadedMsg{err: fmt.Errorf("session management not available")}
178+ }
179+
180+ output, err := m.listSessionsUC.Execute(m.ctx, m.currentSession, m.maxListedSessions)
181+ if err != nil {
182+ log.Error().Err(err).Msg("failed to load sessions")
183+ return sessionsLoadedMsg{err: err}
184+ }
185+
186+ log.Debug().Int("count", len(output.Sessions)).Msg("loaded sessions")
187+ return sessionsLoadedMsg{sessions: output.Sessions}
188+}
189+
190+// Update implements tea.Model.
191+func (m SessionsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
192+ // Handle confirm modal
193+ if m.confirm != nil {
194+ return m.handleConfirmModal(msg)
195+ }
196+
197+ switch msg := msg.(type) {
198+ case tea.WindowSizeMsg:
199+ m.width = msg.Width
200+ m.height = msg.Height
201+ m.help.Width = msg.Width
202+ return m, nil
203+
204+ case tea.KeyMsg:
205+ return m.handleKeyMsg(msg)
206+
207+ case sessionsLoadedMsg:
208+ if msg.err != nil {
209+ m.err = msg.err
210+ } else {
211+ m.sessions = msg.sessions
212+ m.err = nil
213+ }
214+ return m, nil
215+
216+ case sessionDeletedMsg:
217+ if msg.err != nil {
218+ m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
219+ } else {
220+ m.statusMessage = fmt.Sprintf("Session %s deleted", msg.sessionID)
221+ }
222+ return m, m.loadSessions
223+
224+ case sessionRestoredMsg:
225+ if msg.err != nil {
226+ m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
227+ } else {
228+ m.statusMessage = fmt.Sprintf("Restoring session %s...", msg.sessionID)
229+ return m, tea.Quit
230+ }
231+ return m, nil
232+ }
233+
234+ return m, nil
235+}
236+
237+func (m SessionsModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) {
238+ confirm, cmd := m.confirm.Update(msg)
239+ m.confirm = &confirm
240+ if m.confirm.Done() {
241+ if m.confirm.Result() {
242+ // User confirmed deletion
243+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
244+ session := m.sessions[m.selectedIdx]
245+ cmd = m.deleteSession(session.Session.ID)
246+ }
247+ }
248+ m.confirm = nil
249+ return m, cmd
250+ }
251+ return m, cmd
252+}
253+
254+func (m SessionsModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
255+ switch {
256+ case key.Matches(msg, m.keys.Quit):
257+ return m, tea.Quit
258+
259+ case key.Matches(msg, m.keys.Up):
260+ if m.selectedIdx > 0 {
261+ m.selectedIdx--
262+ }
263+ return m, nil
264+
265+ case key.Matches(msg, m.keys.Down):
266+ if m.selectedIdx < len(m.sessions)-1 {
267+ m.selectedIdx++
268+ }
269+ return m, nil
270+
271+ case key.Matches(msg, m.keys.Expand):
272+ if m.expandedIdx == m.selectedIdx {
273+ m.expandedIdx = -1 // collapse
274+ } else {
275+ m.expandedIdx = m.selectedIdx // expand
276+ }
277+ return m, nil
278+
279+ case key.Matches(msg, m.keys.Restore):
280+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
281+ session := m.sessions[m.selectedIdx]
282+ if session.IsCurrent {
283+ m.statusMessage = "Cannot restore current session"
284+ return m, nil
285+ }
286+ return m, m.restoreSession(session.Session.ID)
287+ }
288+ return m, nil
289+
290+ case key.Matches(msg, m.keys.Delete):
291+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
292+ session := m.sessions[m.selectedIdx]
293+ if session.IsCurrent || session.IsActive {
294+ m.statusMessage = "Cannot delete active session"
295+ return m, nil
296+ }
297+ confirm := styles.NewConfirm(m.theme, fmt.Sprintf("Delete session %s?", session.Session.ID))
298+ m.confirm = &confirm
299+ }
300+ return m, nil
301+
302+ case key.Matches(msg, m.keys.Refresh):
303+ return m, m.loadSessions
304+
305+ case key.Matches(msg, m.keys.Help):
306+ m.help.ShowAll = !m.help.ShowAll
307+ return m, nil
308+ }
309+
310+ return m, nil
311+}
312+
313+func (m SessionsModel) deleteSession(sessionID entity.SessionID) tea.Cmd {
314+ return func() tea.Msg {
315+ log := logging.FromContext(m.ctx)
316+ log.Info().Str("session_id", string(sessionID)).Msg("deleting session")
317+
318+ if m.sessionStateRepo == nil {
319+ return sessionDeletedMsg{sessionID: sessionID, err: fmt.Errorf("session state repo not available")}
320+ }
321+
322+ err := m.sessionStateRepo.DeleteSnapshot(m.ctx, sessionID)
323+ return sessionDeletedMsg{sessionID: sessionID, err: err}
324+ }
325+}
326+
327+func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd {
328+ return func() tea.Msg {
329+ log := logging.FromContext(m.ctx)
330+ log.Info().Str("session_id", string(sessionID)).Msg("restoring session")
331+
332+ if m.restoreUC == nil {
333+ return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("restore use case not available")}
334+ }
335+
336+ // Validate the session has restorable state
337+ _, err := m.restoreUC.Execute(m.ctx, usecase.RestoreInput{SessionID: sessionID})
338+ if err != nil {
339+ return sessionRestoredMsg{sessionID: sessionID, err: err}
340+ }
341+
342+ // Spawn a new dumber instance
343+ spawner := desktop.NewSessionSpawner(m.ctx)
344+ if err := spawner.SpawnWithSession(sessionID); err != nil {
345+ return sessionRestoredMsg{sessionID: sessionID, err: err}
346+ }
347+
348+ return sessionRestoredMsg{sessionID: sessionID}
349+ }
350+}
351+
352+// View implements tea.Model.
353+func (m SessionsModel) View() string {
354+ // Handle confirm modal
355+ if m.confirm != nil {
356+ return m.confirm.View()
357+ }
358+
359+ t := m.theme
360+ var b strings.Builder
361+
362+ // Header
363+ header := m.renderHeader()
364+ b.WriteString(header)
365+ b.WriteString("\n\n")
366+
367+ // Error display
368+ if m.err != nil {
369+ b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
370+ b.WriteString("\n\n")
371+ }
372+
373+ // Status message
374+ if m.statusMessage != "" {
375+ b.WriteString(t.Subtle.Render(m.statusMessage))
376+ b.WriteString("\n\n")
377+ }
378+
379+ // Sessions list
380+ if len(m.sessions) == 0 {
381+ b.WriteString(t.Subtle.Render(" No saved sessions found."))
382+ b.WriteString("\n")
383+ } else {
384+ b.WriteString(m.renderSessionsList())
385+ }
386+
387+ b.WriteString("\n")
388+
389+ // Help
390+ helpView := m.help.View(m.keys)
391+ b.WriteString(helpView)
392+
393+ return b.String()
394+}
395+
396+func (m SessionsModel) renderHeader() string {
397+ t := m.theme
398+
399+ iconStyle := lipgloss.NewStyle().Foreground(t.Accent)
400+ titleStyle := t.Title.MarginLeft(1)
401+
402+ icon := iconStyle.Render(styles.IconSession)
403+ title := titleStyle.Render("Sessions")
404+
405+ // Count stats
406+ var activeCount, exitedCount int
407+ for _, s := range m.sessions {
408+ if s.IsCurrent || s.IsActive {
409+ activeCount++
410+ } else {
411+ exitedCount++
412+ }
413+ }
414+
415+ stats := t.Subtle.Render(fmt.Sprintf(" %s %d active %s %d exited",
416+ styles.IconPlay, activeCount,
417+ styles.IconStop, exitedCount,
418+ ))
419+
420+ return icon + title + stats
421+}
422+
423+func (m SessionsModel) renderSessionsList() string {
424+ var b strings.Builder
425+
426+ for i, info := range m.sessions {
427+ isSelected := i == m.selectedIdx
428+ isExpanded := i == m.expandedIdx
429+
430+ b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
431+ b.WriteString("\n")
432+
433+ // Render expanded details
434+ if isExpanded && info.State != nil {
435+ b.WriteString(m.renderSessionDetails(info))
436+ }
437+ }
438+
439+ return b.String()
440+}
441+
442+func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
443+ t := m.theme
444+
445+ // Cursor
446+ cursor := " "
447+ if isSelected {
448+ cursor = t.Highlight.Render(styles.IconCursor + " ")
449+ }
450+
451+ // Status icon
452+ var statusIcon string
453+ var statusStyle lipgloss.Style
454+ switch {
455+ case info.IsCurrent:
456+ statusIcon = styles.IconPlay
457+ statusStyle = lipgloss.NewStyle().Foreground(t.Accent)
458+ case info.IsActive:
459+ statusIcon = styles.IconPlay
460+ statusStyle = lipgloss.NewStyle().Foreground(t.Warning)
461+ default:
462+ statusIcon = styles.IconStop
463+ statusStyle = t.Subtle
464+ }
465+
466+ // Session ID
467+ idStyle := t.Normal
468+ if isSelected {
469+ idStyle = t.Highlight
470+ }
471+
472+ // Label for current/active
473+ label := ""
474+ if info.IsCurrent {
475+ label = t.Badge.Render("current")
476+ } else if info.IsActive {
477+ label = t.BadgeMuted.Render("active")
478+ }
479+
480+ // Expand indicator
481+ expandIcon := styles.IconExpand
482+ if isExpanded {
483+ expandIcon = styles.IconCollapse
484+ }
485+ expandStyle := t.Subtle
486+
487+ // Tab/pane counts
488+ counts := t.Subtle.Render(fmt.Sprintf("%s %d %s %d",
489+ styles.IconTab, info.TabCount,
490+ styles.IconPane, info.PaneCount,
491+ ))
492+
493+ // Time
494+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
495+ timeStr := t.Subtle.Render(fmt.Sprintf("%s %s", styles.IconClock, relTime))
496+
497+ // Build row
498+ row := fmt.Sprintf("%s%s %s %s %s %s %s",
499+ cursor,
500+ statusStyle.Render(statusIcon),
501+ idStyle.Render(string(info.Session.ID)),
502+ label,
503+ expandStyle.Render(expandIcon),
504+ counts,
505+ timeStr,
506+ )
507+
508+ return row
509+}
510+
511+func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
512+ t := m.theme
513+ var b strings.Builder
514+
515+ if info.State == nil {
516+ b.WriteString(t.Subtle.Render(" No state data available\n"))
517+ return b.String()
518+ }
519+
520+ // Tree prefix styles
521+ treeStyle := lipgloss.NewStyle().Foreground(t.Border)
522+ leafStyle := lipgloss.NewStyle().Foreground(t.Muted)
523+
524+ // Render tabs as tree
525+ for tabIdx, tab := range info.State.Tabs {
526+ isLastTab := tabIdx == len(info.State.Tabs)-1
527+
528+ // Tab branch
529+ branch := "├── "
530+ if isLastTab {
531+ branch = "└── "
532+ }
533+
534+ tabIcon := styles.IconTab
535+ if tab.IsPinned {
536+ tabIcon = styles.IconCheckboxChecked
537+ }
538+
539+ tabName := tab.Name
540+ if tabName == "" {
541+ tabName = "Tab"
542+ }
543+
544+ b.WriteString(fmt.Sprintf(" %s%s %s\n",
545+ treeStyle.Render(branch),
546+ leafStyle.Render(tabIcon),
547+ t.Normal.Render(tabName),
548+ ))
549+
550+ // Render panes under this tab
551+ m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
552+ }
553+
554+ b.WriteString("\n")
555+ return b.String()
556+}
557+
558+func (m SessionsModel) renderPaneTree(
559+ b *strings.Builder,
560+ ws *entity.WorkspaceSnapshot,
561+ isLastTab bool,
562+ t *styles.Theme,
563+ treeStyle, leafStyle lipgloss.Style,
564+) {
565+ if ws == nil || ws.Root == nil {
566+ return
567+ }
568+
569+ // Determine prefix for children
570+ childPrefix := " │ "
571+ if isLastTab {
572+ childPrefix = " "
573+ }
574+
575+ m.renderPaneNode(b, ws.Root, childPrefix, true, t, treeStyle, leafStyle)
576+}
577+
578+func (m SessionsModel) renderPaneNode(
579+ b *strings.Builder,
580+ node *entity.PaneNodeSnapshot,
581+ prefix string,
582+ isLast bool,
583+ t *styles.Theme,
584+ treeStyle, leafStyle lipgloss.Style,
585+) {
586+ if node == nil {
587+ return
588+ }
589+
590+ branch := "├── "
591+ if isLast {
592+ branch = "└── "
593+ }
594+
595+ if node.Pane != nil {
596+ // Leaf node (actual pane)
597+ pane := node.Pane
598+ title := pane.Title
599+ if title == "" {
600+ title = pane.URI
601+ }
602+ // Truncate long titles
603+ const maxTitleLen = 50
604+ if len(title) > maxTitleLen {
605+ title = title[:maxTitleLen-3] + "..."
606+ }
607+
608+ fmt.Fprintf(b, "%s%s%s %s\n",
609+ prefix,
610+ treeStyle.Render(branch),
611+ leafStyle.Render(styles.IconPane),
612+ t.Subtle.Render(title),
613+ )
614+ } else if len(node.Children) > 0 {
615+ // Container node (split or stacked)
616+ splitIcon := styles.IconSessionStack
617+ splitType := "split"
618+ if node.IsStacked {
619+ splitType = "stacked"
620+ } else if node.SplitRatio > 0 {
621+ splitType = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
622+ }
623+
624+ fmt.Fprintf(b, "%s%s%s %s\n",
625+ prefix,
626+ treeStyle.Render(branch),
627+ leafStyle.Render(splitIcon),
628+ t.Subtle.Render(splitType),
629+ )
630+
631+ // Child prefix
632+ childPrefix := prefix + "│ "
633+ if isLast {
634+ childPrefix = prefix + " "
635+ }
636+
637+ // Render all children
638+ for i, child := range node.Children {
639+ isLastChild := i == len(node.Children)-1
640+ m.renderPaneNode(b, child, childPrefix, isLastChild, t, treeStyle, leafStyle)
641+ }
642+ }
643+}
644+
645+// Ensure interface compliance at compile time.
646+var _ tea.Model = (*SessionsModel)(nil)
@@ -0,0 +1,646 @@
1+// Package model provides Bubble Tea models for CLI commands.
2+package model
3+
4+import (
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.
22+type 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+ sessionStateRepo interface {
46+ DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error
47+ }
48+ theme *styles.Theme
49+}
50+
51+// sessionsKeyMap defines keybindings for the sessions browser.
52+type sessionsKeyMap struct {
53+ Up key.Binding
54+ Down key.Binding
55+ Expand key.Binding
56+ Restore key.Binding
57+ Delete key.Binding
58+ Refresh key.Binding
59+ Help key.Binding
60+ Quit key.Binding
61+}
62+
63+// ShortHelp returns keybindings for the short help view.
64+func (k sessionsKeyMap) ShortHelp() []key.Binding {
65+ return []key.Binding{k.Up, k.Down, k.Expand, k.Restore, k.Delete, k.Quit}
66+}
67+
68+// FullHelp returns keybindings for the full help view.
69+func (k sessionsKeyMap) FullHelp() [][]key.Binding {
70+ return [][]key.Binding{
71+ {k.Up, k.Down, k.Expand},
72+ {k.Restore, k.Delete, k.Refresh},
73+ {k.Help, k.Quit},
74+ }
75+}
76+
77+func defaultSessionsKeyMap() sessionsKeyMap {
78+ return sessionsKeyMap{
79+ Up: key.NewBinding(
80+ key.WithKeys("k", "up"),
81+ key.WithHelp("↑/k", "up"),
82+ ),
83+ Down: key.NewBinding(
84+ key.WithKeys("j", "down"),
85+ key.WithHelp("↓/j", "down"),
86+ ),
87+ Expand: key.NewBinding(
88+ key.WithKeys("enter", "tab"),
89+ key.WithHelp("enter", "expand/collapse"),
90+ ),
91+ Restore: key.NewBinding(
92+ key.WithKeys("r"),
93+ key.WithHelp("r", "restore"),
94+ ),
95+ Delete: key.NewBinding(
96+ key.WithKeys("x", "d"),
97+ key.WithHelp("x", "delete"),
98+ ),
99+ Refresh: key.NewBinding(
100+ key.WithKeys("R"),
101+ key.WithHelp("R", "refresh"),
102+ ),
103+ Help: key.NewBinding(
104+ key.WithKeys("?"),
105+ key.WithHelp("?", "help"),
106+ ),
107+ Quit: key.NewBinding(
108+ key.WithKeys("q", "esc", "ctrl+c"),
109+ key.WithHelp("q", "quit"),
110+ ),
111+ }
112+}
113+
114+// SessionsModelConfig holds configuration for the sessions model.
115+type SessionsModelConfig struct {
116+ ListSessionsUC *usecase.ListSessionsUseCase
117+ RestoreUC *usecase.RestoreSessionUseCase
118+ SessionStateRepo interface {
119+ DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error
120+ }
121+ CurrentSession entity.SessionID
122+ MaxListedSessions int
123+}
124+
125+// NewSessionsModel creates a new sessions browser model.
126+func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel {
127+ maxListed := cfg.MaxListedSessions
128+ if maxListed <= 0 {
129+ maxListed = 50 // fallback default
130+ }
131+
132+ return SessionsModel{
133+ help: help.New(),
134+ keys: defaultSessionsKeyMap(),
135+ selectedIdx: 0,
136+ expandedIdx: -1,
137+ width: 80,
138+ height: 24,
139+ maxListedSessions: maxListed,
140+ ctx: ctx,
141+ listSessionsUC: cfg.ListSessionsUC,
142+ restoreUC: cfg.RestoreUC,
143+ sessionStateRepo: cfg.SessionStateRepo,
144+ currentSession: cfg.CurrentSession,
145+ theme: theme,
146+ }
147+}
148+
149+// Init implements tea.Model.
150+func (m SessionsModel) Init() tea.Cmd {
151+ return m.loadSessions
152+}
153+
154+// sessionsLoadedMsg is sent when sessions are loaded.
155+type sessionsLoadedMsg struct {
156+ sessions []entity.SessionInfo
157+ err error
158+}
159+
160+// sessionDeletedMsg is sent when a session is deleted.
161+type sessionDeletedMsg struct {
162+ sessionID entity.SessionID
163+ err error
164+}
165+
166+// sessionRestoredMsg is sent when a session restoration is triggered.
167+type sessionRestoredMsg struct {
168+ sessionID entity.SessionID
169+ err error
170+}
171+
172+func (m SessionsModel) loadSessions() tea.Msg {
173+ log := logging.FromContext(m.ctx)
174+ log.Debug().Msg("loading sessions")
175+
176+ if m.listSessionsUC == nil {
177+ return sessionsLoadedMsg{err: fmt.Errorf("session management not available")}
178+ }
179+
180+ output, err := m.listSessionsUC.Execute(m.ctx, m.currentSession, m.maxListedSessions)
181+ if err != nil {
182+ log.Error().Err(err).Msg("failed to load sessions")
183+ return sessionsLoadedMsg{err: err}
184+ }
185+
186+ log.Debug().Int("count", len(output.Sessions)).Msg("loaded sessions")
187+ return sessionsLoadedMsg{sessions: output.Sessions}
188+}
189+
190+// Update implements tea.Model.
191+func (m SessionsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
192+ // Handle confirm modal
193+ if m.confirm != nil {
194+ return m.handleConfirmModal(msg)
195+ }
196+
197+ switch msg := msg.(type) {
198+ case tea.WindowSizeMsg:
199+ m.width = msg.Width
200+ m.height = msg.Height
201+ m.help.Width = msg.Width
202+ return m, nil
203+
204+ case tea.KeyMsg:
205+ return m.handleKeyMsg(msg)
206+
207+ case sessionsLoadedMsg:
208+ if msg.err != nil {
209+ m.err = msg.err
210+ } else {
211+ m.sessions = msg.sessions
212+ m.err = nil
213+ }
214+ return m, nil
215+
216+ case sessionDeletedMsg:
217+ if msg.err != nil {
218+ m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
219+ } else {
220+ m.statusMessage = fmt.Sprintf("Session %s deleted", msg.sessionID)
221+ }
222+ return m, m.loadSessions
223+
224+ case sessionRestoredMsg:
225+ if msg.err != nil {
226+ m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
227+ } else {
228+ m.statusMessage = fmt.Sprintf("Restoring session %s...", msg.sessionID)
229+ return m, tea.Quit
230+ }
231+ return m, nil
232+ }
233+
234+ return m, nil
235+}
236+
237+func (m SessionsModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) {
238+ confirm, cmd := m.confirm.Update(msg)
239+ m.confirm = &confirm
240+ if m.confirm.Done() {
241+ if m.confirm.Result() {
242+ // User confirmed deletion
243+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
244+ session := m.sessions[m.selectedIdx]
245+ cmd = m.deleteSession(session.Session.ID)
246+ }
247+ }
248+ m.confirm = nil
249+ return m, cmd
250+ }
251+ return m, cmd
252+}
253+
254+func (m SessionsModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
255+ switch {
256+ case key.Matches(msg, m.keys.Quit):
257+ return m, tea.Quit
258+
259+ case key.Matches(msg, m.keys.Up):
260+ if m.selectedIdx > 0 {
261+ m.selectedIdx--
262+ }
263+ return m, nil
264+
265+ case key.Matches(msg, m.keys.Down):
266+ if m.selectedIdx < len(m.sessions)-1 {
267+ m.selectedIdx++
268+ }
269+ return m, nil
270+
271+ case key.Matches(msg, m.keys.Expand):
272+ if m.expandedIdx == m.selectedIdx {
273+ m.expandedIdx = -1 // collapse
274+ } else {
275+ m.expandedIdx = m.selectedIdx // expand
276+ }
277+ return m, nil
278+
279+ case key.Matches(msg, m.keys.Restore):
280+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
281+ session := m.sessions[m.selectedIdx]
282+ if session.IsCurrent {
283+ m.statusMessage = "Cannot restore current session"
284+ return m, nil
285+ }
286+ return m, m.restoreSession(session.Session.ID)
287+ }
288+ return m, nil
289+
290+ case key.Matches(msg, m.keys.Delete):
291+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
292+ session := m.sessions[m.selectedIdx]
293+ if session.IsCurrent || session.IsActive {
294+ m.statusMessage = "Cannot delete active session"
295+ return m, nil
296+ }
297+ confirm := styles.NewConfirm(m.theme, fmt.Sprintf("Delete session %s?", session.Session.ID))
298+ m.confirm = &confirm
299+ }
300+ return m, nil
301+
302+ case key.Matches(msg, m.keys.Refresh):
303+ return m, m.loadSessions
304+
305+ case key.Matches(msg, m.keys.Help):
306+ m.help.ShowAll = !m.help.ShowAll
307+ return m, nil
308+ }
309+
310+ return m, nil
311+}
312+
313+func (m SessionsModel) deleteSession(sessionID entity.SessionID) tea.Cmd {
314+ return func() tea.Msg {
315+ log := logging.FromContext(m.ctx)
316+ log.Info().Str("session_id", string(sessionID)).Msg("deleting session")
317+
318+ if m.sessionStateRepo == nil {
319+ return sessionDeletedMsg{sessionID: sessionID, err: fmt.Errorf("session state repo not available")}
320+ }
321+
322+ err := m.sessionStateRepo.DeleteSnapshot(m.ctx, sessionID)
323+ return sessionDeletedMsg{sessionID: sessionID, err: err}
324+ }
325+}
326+
327+func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd {
328+ return func() tea.Msg {
329+ log := logging.FromContext(m.ctx)
330+ log.Info().Str("session_id", string(sessionID)).Msg("restoring session")
331+
332+ if m.restoreUC == nil {
333+ return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("restore use case not available")}
334+ }
335+
336+ // Validate the session has restorable state
337+ _, err := m.restoreUC.Execute(m.ctx, usecase.RestoreInput{SessionID: sessionID})
338+ if err != nil {
339+ return sessionRestoredMsg{sessionID: sessionID, err: err}
340+ }
341+
342+ // Spawn a new dumber instance
343+ spawner := desktop.NewSessionSpawner(m.ctx)
344+ if err := spawner.SpawnWithSession(sessionID); err != nil {
345+ return sessionRestoredMsg{sessionID: sessionID, err: err}
346+ }
347+
348+ return sessionRestoredMsg{sessionID: sessionID}
349+ }
350+}
351+
352+// View implements tea.Model.
353+func (m SessionsModel) View() string {
354+ // Handle confirm modal
355+ if m.confirm != nil {
356+ return m.confirm.View()
357+ }
358+
359+ t := m.theme
360+ var b strings.Builder
361+
362+ // Header
363+ header := m.renderHeader()
364+ b.WriteString(header)
365+ b.WriteString("\n\n")
366+
367+ // Error display
368+ if m.err != nil {
369+ b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
370+ b.WriteString("\n\n")
371+ }
372+
373+ // Status message
374+ if m.statusMessage != "" {
375+ b.WriteString(t.Subtle.Render(m.statusMessage))
376+ b.WriteString("\n\n")
377+ }
378+
379+ // Sessions list
380+ if len(m.sessions) == 0 {
381+ b.WriteString(t.Subtle.Render(" No saved sessions found."))
382+ b.WriteString("\n")
383+ } else {
384+ b.WriteString(m.renderSessionsList())
385+ }
386+
387+ b.WriteString("\n")
388+
389+ // Help
390+ helpView := m.help.View(m.keys)
391+ b.WriteString(helpView)
392+
393+ return b.String()
394+}
395+
396+func (m SessionsModel) renderHeader() string {
397+ t := m.theme
398+
399+ iconStyle := lipgloss.NewStyle().Foreground(t.Accent)
400+ titleStyle := t.Title.MarginLeft(1)
401+
402+ icon := iconStyle.Render(styles.IconSession)
403+ title := titleStyle.Render("Sessions")
404+
405+ // Count stats
406+ var activeCount, exitedCount int
407+ for _, s := range m.sessions {
408+ if s.IsCurrent || s.IsActive {
409+ activeCount++
410+ } else {
411+ exitedCount++
412+ }
413+ }
414+
415+ stats := t.Subtle.Render(fmt.Sprintf(" %s %d active %s %d exited",
416+ styles.IconPlay, activeCount,
417+ styles.IconStop, exitedCount,
418+ ))
419+
420+ return icon + title + stats
421+}
422+
423+func (m SessionsModel) renderSessionsList() string {
424+ var b strings.Builder
425+
426+ for i, info := range m.sessions {
427+ isSelected := i == m.selectedIdx
428+ isExpanded := i == m.expandedIdx
429+
430+ b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
431+ b.WriteString("\n")
432+
433+ // Render expanded details
434+ if isExpanded && info.State != nil {
435+ b.WriteString(m.renderSessionDetails(info))
436+ }
437+ }
438+
439+ return b.String()
440+}
441+
442+func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
443+ t := m.theme
444+
445+ // Cursor
446+ cursor := " "
447+ if isSelected {
448+ cursor = t.Highlight.Render(styles.IconCursor + " ")
449+ }
450+
451+ // Status icon
452+ var statusIcon string
453+ var statusStyle lipgloss.Style
454+ switch {
455+ case info.IsCurrent:
456+ statusIcon = styles.IconPlay
457+ statusStyle = lipgloss.NewStyle().Foreground(t.Accent)
458+ case info.IsActive:
459+ statusIcon = styles.IconPlay
460+ statusStyle = lipgloss.NewStyle().Foreground(t.Warning)
461+ default:
462+ statusIcon = styles.IconStop
463+ statusStyle = t.Subtle
464+ }
465+
466+ // Session ID
467+ idStyle := t.Normal
468+ if isSelected {
469+ idStyle = t.Highlight
470+ }
471+
472+ // Label for current/active
473+ label := ""
474+ if info.IsCurrent {
475+ label = t.Badge.Render("current")
476+ } else if info.IsActive {
477+ label = t.BadgeMuted.Render("active")
478+ }
479+
480+ // Expand indicator
481+ expandIcon := styles.IconExpand
482+ if isExpanded {
483+ expandIcon = styles.IconCollapse
484+ }
485+ expandStyle := t.Subtle
486+
487+ // Tab/pane counts
488+ counts := t.Subtle.Render(fmt.Sprintf("%s %d %s %d",
489+ styles.IconTab, info.TabCount,
490+ styles.IconPane, info.PaneCount,
491+ ))
492+
493+ // Time
494+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
495+ timeStr := t.Subtle.Render(fmt.Sprintf("%s %s", styles.IconClock, relTime))
496+
497+ // Build row
498+ row := fmt.Sprintf("%s%s %s %s %s %s %s",
499+ cursor,
500+ statusStyle.Render(statusIcon),
501+ idStyle.Render(string(info.Session.ID)),
502+ label,
503+ expandStyle.Render(expandIcon),
504+ counts,
505+ timeStr,
506+ )
507+
508+ return row
509+}
510+
511+func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
512+ t := m.theme
513+ var b strings.Builder
514+
515+ if info.State == nil {
516+ b.WriteString(t.Subtle.Render(" No state data available\n"))
517+ return b.String()
518+ }
519+
520+ // Tree prefix styles
521+ treeStyle := lipgloss.NewStyle().Foreground(t.Border)
522+ leafStyle := lipgloss.NewStyle().Foreground(t.Muted)
523+
524+ // Render tabs as tree
525+ for tabIdx, tab := range info.State.Tabs {
526+ isLastTab := tabIdx == len(info.State.Tabs)-1
527+
528+ // Tab branch
529+ branch := "├── "
530+ if isLastTab {
531+ branch = "└── "
532+ }
533+
534+ tabIcon := styles.IconTab
535+ if tab.IsPinned {
536+ tabIcon = styles.IconCheckboxChecked
537+ }
538+
539+ tabName := tab.Name
540+ if tabName == "" {
541+ tabName = "Tab"
542+ }
543+
544+ b.WriteString(fmt.Sprintf(" %s%s %s\n",
545+ treeStyle.Render(branch),
546+ leafStyle.Render(tabIcon),
547+ t.Normal.Render(tabName),
548+ ))
549+
550+ // Render panes under this tab
551+ m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
552+ }
553+
554+ b.WriteString("\n")
555+ return b.String()
556+}
557+
558+func (m SessionsModel) renderPaneTree(
559+ b *strings.Builder,
560+ ws *entity.WorkspaceSnapshot,
561+ isLastTab bool,
562+ t *styles.Theme,
563+ treeStyle, leafStyle lipgloss.Style,
564+) {
565+ if ws == nil || ws.Root == nil {
566+ return
567+ }
568+
569+ // Determine prefix for children
570+ childPrefix := " │ "
571+ if isLastTab {
572+ childPrefix = " "
573+ }
574+
575+ m.renderPaneNode(b, ws.Root, childPrefix, true, t, treeStyle, leafStyle)
576+}
577+
578+func (m SessionsModel) renderPaneNode(
579+ b *strings.Builder,
580+ node *entity.PaneNodeSnapshot,
581+ prefix string,
582+ isLast bool,
583+ t *styles.Theme,
584+ treeStyle, leafStyle lipgloss.Style,
585+) {
586+ if node == nil {
587+ return
588+ }
589+
590+ branch := "├── "
591+ if isLast {
592+ branch = "└── "
593+ }
594+
595+ if node.Pane != nil {
596+ // Leaf node (actual pane)
597+ pane := node.Pane
598+ title := pane.Title
599+ if title == "" {
600+ title = pane.URI
601+ }
602+ // Truncate long titles
603+ const maxTitleLen = 50
604+ if len(title) > maxTitleLen {
605+ title = title[:maxTitleLen-3] + "..."
606+ }
607+
608+ fmt.Fprintf(b, "%s%s%s %s\n",
609+ prefix,
610+ treeStyle.Render(branch),
611+ leafStyle.Render(styles.IconPane),
612+ t.Subtle.Render(title),
613+ )
614+ } else if len(node.Children) > 0 {
615+ // Container node (split or stacked)
616+ splitIcon := styles.IconSessionStack
617+ splitType := "split"
618+ if node.IsStacked {
619+ splitType = "stacked"
620+ } else if node.SplitRatio > 0 {
621+ splitType = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
622+ }
623+
624+ fmt.Fprintf(b, "%s%s%s %s\n",
625+ prefix,
626+ treeStyle.Render(branch),
627+ leafStyle.Render(splitIcon),
628+ t.Subtle.Render(splitType),
629+ )
630+
631+ // Child prefix
632+ childPrefix := prefix + "│ "
633+ if isLast {
634+ childPrefix = prefix + " "
635+ }
636+
637+ // Render all children
638+ for i, child := range node.Children {
639+ isLastChild := i == len(node.Children)-1
640+ m.renderPaneNode(b, child, childPrefix, isLastChild, t, treeStyle, leafStyle)
641+ }
642+ }
643+}
644+
645+// Ensure interface compliance at compile time.
646+var _ tea.Model = (*SessionsModel)(nil)
M internal/cli/styles/icons.go +13 -0
@@ -40,4 +40,17 @@ const (
4040
4141 // UI
4242 IconCursor = "\uf054" // chevron-right
43+
44+ // Sessions / browser
45+ IconSession = "\uf2d2" // window
46+ IconSessionStack = "\uf24d" // clone/stack
47+ IconTab = "\uf0ce" // table
48+ IconPane = "\uf0db" // columns
49+ IconTree = "\uf1bb" // tree
50+ IconClock = "\uf017" // clock
51+ IconPlay = "\uf04b" // play (running)
52+ IconStop = "\uf04d" // stop (exited)
53+ IconRestore = "\uf0e2" // rotate-left (restore)
54+ IconExpand = "\uf065" // expand
55+ IconCollapse = "\uf066" // compress
4356 )
@@ -40,4 +40,17 @@ const (
40 40
41 // UI41 // UI
42 IconCursor = "\uf054" // chevron-right42 IconCursor = "\uf054" // chevron-right
43+
44+ // Sessions / browser
45+ IconSession = "\uf2d2" // window
46+ IconSessionStack = "\uf24d" // clone/stack
47+ IconTab = "\uf0ce" // table
48+ IconPane = "\uf0db" // columns
49+ IconTree = "\uf1bb" // tree
50+ IconClock = "\uf017" // clock
51+ IconPlay = "\uf04b" // play (running)
52+ IconStop = "\uf04d" // stop (exited)
53+ IconRestore = "\uf0e2" // rotate-left (restore)
54+ IconExpand = "\uf065" // expand
55+ IconCollapse = "\uf066" // compress
43 )56 )

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