package cmd import ( "encoding/json" "fmt" "os" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" "github.com/bnema/dumber/internal/application/usecase" "github.com/bnema/dumber/internal/cli/model" "github.com/bnema/dumber/internal/cli/styles" "github.com/bnema/dumber/internal/domain/entity" "github.com/bnema/dumber/internal/infrastructure/desktop" ) const defaultSessionsLimit = 20 var ( sessionsJSON bool sessionsLimit int ) var sessionsCmd = &cobra.Command{ Use: "sessions", Short: "Manage browser sessions", Long: `View, restore, and manage browser sessions. Sessions are automatically saved when the browser runs. You can list past sessions and restore them to continue where you left off. Run without arguments to open the interactive session browser.`, RunE: runSessions, } func init() { rootCmd.AddCommand(sessionsCmd) } func runSessions(_ *cobra.Command, _ []string) error { app := GetApp() if app == nil { return fmt.Errorf("app not initialized") } if app.ListSessionsUC == nil { return fmt.Errorf("session management not available") } // Get current session ID (empty if not running as browser) var currentSessionID entity.SessionID if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil { currentSessionID = active.ID } // Run interactive TUI m := model.NewSessionsModel(app.Ctx(), app.Theme, model.SessionsModelConfig{ ListSessionsUC: app.ListSessionsUC, RestoreUC: app.RestoreUC, DeleteSessionUC: app.DeleteSessionUC, CurrentSession: currentSessionID, MaxExitedSessions: app.Config.Session.MaxExitedSessions, }) p := tea.NewProgram(m, tea.WithAltScreen()) _, err := p.Run() return err } // sessions list var sessionsListCmd = &cobra.Command{ Use: "list", Short: "List saved sessions", Long: `List all saved browser sessions with their tab/pane counts. Sessions are marked as: ● current - the currently running session ○ active - another running dumber instance (blank) - exited session available for restoration`, RunE: runSessionsList, } func init() { sessionsCmd.AddCommand(sessionsListCmd) sessionsListCmd.Flags().BoolVar(&sessionsJSON, "json", false, "output as JSON") sessionsListCmd.Flags().IntVar(&sessionsLimit, "limit", defaultSessionsLimit, "maximum sessions to show") } func runSessionsList(_ *cobra.Command, _ []string) error { app := GetApp() if app == nil { return fmt.Errorf("app not initialized") } renderer := styles.NewSessionsCLIRenderer(app.Theme) if app.ListSessionsUC == nil { err := fmt.Errorf("session management not available") fmt.Fprintln(os.Stderr, renderer.RenderError(err)) return wrapPrintedError(err) } // Get current session ID (empty if not running as browser) var currentSessionID entity.SessionID if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil { currentSessionID = active.ID } output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit) if err != nil { wrappedErr := fmt.Errorf("list sessions: %w", err) fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr)) return wrapPrintedError(wrappedErr) } if sessionsJSON { return outputSessionsJSON(output.Sessions) } fmt.Println(renderer.RenderList(output.Sessions, sessionsLimit)) return nil } func outputSessionsJSON(sessions []entity.SessionInfo) error { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(sessions) } // sessions restore var sessionsRestoreCmd = &cobra.Command{ Use: "restore ", Short: "Restore a saved session", Long: `Restore a previously saved browser session. This launches a new browser window with all tabs and panes from the saved session. The session ID can be found using 'dumber sessions list'. You can use a short suffix of the session ID as long as it's unique. Example: dumber sessions restore 20251224_143022_abc1 dumber sessions restore abc1`, Args: cobra.ExactArgs(1), RunE: runSessionsRestore, } func init() { sessionsCmd.AddCommand(sessionsRestoreCmd) } func runSessionsRestore(_ *cobra.Command, args []string) error { app := GetApp() if app == nil { return fmt.Errorf("app not initialized") } renderer := styles.NewSessionsCLIRenderer(app.Theme) if app.RestoreUC == nil || app.ListSessionsUC == nil { err := fmt.Errorf("session restoration not available") fmt.Fprintln(os.Stderr, renderer.RenderError(err)) return wrapPrintedError(err) } // Find session by ID or suffix sessionInfo, err := findSessionByIDOrSuffix(args[0]) if err != nil { fmt.Fprintln(os.Stderr, renderer.RenderError(err)) return wrapPrintedError(err) } sessionID := sessionInfo.Session.ID // Validate the session has restorable state _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID}) if err != nil { wrappedErr := fmt.Errorf("restore session: %w", err) fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr)) return wrapPrintedError(wrappedErr) } // Spawn a new dumber instance with the session spawner := desktop.NewSessionSpawner(app.Ctx()) if err := spawner.SpawnWithSession(sessionID); err != nil { wrappedErr := fmt.Errorf("spawn browser: %w", err) fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr)) return wrapPrintedError(wrappedErr) } fmt.Println(renderer.RenderRestoreStarted(sessionID)) return nil } // sessions delete var sessionsDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a saved session", Long: `Delete a saved browser session and its state. This permanently removes the session snapshot. Active sessions cannot be deleted - close the browser first. You can use a short suffix of the session ID as long as it's unique. Example: dumber sessions delete 20251224_143022_abc1 dumber sessions delete abc1`, Args: cobra.ExactArgs(1), RunE: runSessionsDelete, } func init() { sessionsCmd.AddCommand(sessionsDeleteCmd) } func runSessionsDelete(_ *cobra.Command, args []string) error { app := GetApp() if app == nil { return fmt.Errorf("app not initialized") } renderer := styles.NewSessionsCLIRenderer(app.Theme) if app.DeleteSessionUC == nil || app.ListSessionsUC == nil { err := fmt.Errorf("session management not available") fmt.Fprintln(os.Stderr, renderer.RenderError(err)) return wrapPrintedError(err) } // Find session by ID or suffix sessionInfo, err := findSessionByIDOrSuffix(args[0]) if err != nil { fmt.Fprintln(os.Stderr, renderer.RenderError(err)) return wrapPrintedError(err) } // Get current session ID var currentSessionID entity.SessionID if active, activeErr := app.SessionUC.GetActiveSession(app.Ctx()); activeErr == nil && active != nil { currentSessionID = active.ID } // Delete using use case (handles validation internally) if err := app.DeleteSessionUC.Execute(app.Ctx(), usecase.DeleteSessionInput{ SessionID: sessionInfo.Session.ID, CurrentSessionID: currentSessionID, }); err != nil { wrappedErr := fmt.Errorf("delete session: %w", err) fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr)) return wrapPrintedError(wrappedErr) } fmt.Println(renderer.RenderDeleted(sessionInfo.Session.ID)) return nil } // findSessionByIDOrSuffix finds a session by exact ID or unique suffix. // Users typically identify sessions by the last few characters (e.g., "dee5"). func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) { app := GetApp() if app == nil || app.ListSessionsUC == nil { return nil, fmt.Errorf("app not initialized") } // Get current session ID (empty if not running as browser) var currentSessionID entity.SessionID if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil { currentSessionID = active.ID } // List all sessions output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, defaultSessionsLimit*5) if err != nil { return nil, fmt.Errorf("list sessions: %w", err) } // Find matching sessions var matches []entity.SessionInfo for _, info := range output.Sessions { if string(info.Session.ID) == idOrSuffix { // Exact match - return immediately return &info, nil } if strings.HasSuffix(string(info.Session.ID), idOrSuffix) { matches = append(matches, info) } } switch len(matches) { case 0: return nil, fmt.Errorf("no session found matching '%s'", idOrSuffix) case 1: return &matches[0], nil default: return nil, fmt.Errorf("ambiguous session ID '%s' matches %d sessions - be more specific", idOrSuffix, len(matches)) } }