detached 99cf04e

feat(cli): add CLI interface with session-based logging

commit
99cf04e03a72ec7ffa980fae14dd3fb4727261a3
author
bnema · · 2025-12-17 20:45 UTC
parent
e4dcc49
signature
unsigned
21 files · +3915 -0
A internal/cli/app.go +138 -0
@@ -0,0 +1,138 @@
1+// Package cli provides CLI commands using Bubble Tea TUI.
2+package cli
3+
4+import (
5+ "context"
6+ "database/sql"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+
11+ "github.com/bnema/dumber/internal/application/usecase"
12+ "github.com/bnema/dumber/internal/cli/styles"
13+ "github.com/bnema/dumber/internal/domain/repository"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
16+ "github.com/bnema/dumber/internal/logging"
17+)
18+
19+// App holds CLI dependencies.
20+type App struct {
21+ Config *config.Config
22+ Theme *styles.Theme
23+ db *sql.DB
24+ History repository.HistoryRepository
25+
26+ // Use cases
27+ SearchHistoryUC *usecase.SearchHistoryUseCase
28+
29+ // Context with logger
30+ ctx context.Context
31+ logCleanup func()
32+}
33+
34+// NewApp creates a new CLI application with all dependencies.
35+func NewApp() (*App, error) {
36+ // Load config
37+ cfg, err := loadConfig()
38+ if err != nil {
39+ return nil, fmt.Errorf("load config: %w", err)
40+ }
41+
42+ // Create theme from config
43+ theme := styles.NewTheme(cfg)
44+
45+ // Initialize logger with file output for debugging
46+ // Logs can be viewed with: tail -f ~/.local/state/dumber/logs/session_*.log
47+ logCfg := logging.DefaultConfig()
48+ if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {
49+ logCfg.Level = logging.ParseLevel(level)
50+ }
51+
52+ // Create session log file
53+ home, _ := os.UserHomeDir()
54+ sessionID := logging.GenerateSessionID()
55+ logDir := filepath.Join(home, ".local", "state", "dumber", "logs")
56+
57+ logger, logCleanup, err := logging.NewWithFile(logCfg, logging.FileConfig{
58+ Enabled: true,
59+ LogDir: logDir,
60+ SessionID: sessionID,
61+ })
62+ if err != nil {
63+ // Fallback to no-file logging
64+ logger = logging.NewFromEnv()
65+ logCleanup = func() {}
66+ }
67+
68+ ctx := logging.WithContext(context.Background(), logger)
69+ logger.Info().Str("command", "cli").Msg("CLI session started")
70+
71+ // Determine database path - use share directory per XDG spec (user data)
72+ dbFile := cfg.Database.Path
73+ if dbFile == "" {
74+ dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db")
75+ }
76+ dbPath := filepath.Dir(dbFile)
77+
78+ // Ensure data directory exists
79+ if err := os.MkdirAll(dbPath, 0755); err != nil {
80+ return nil, fmt.Errorf("create data dir: %w", err)
81+ }
82+
83+ // Open database connection
84+ db, err := sqlite.NewConnection(ctx, dbFile)
85+ if err != nil {
86+ return nil, fmt.Errorf("open database: %w", err)
87+ }
88+
89+ logger.Debug().Str("db_path", dbFile).Msg("database connected")
90+
91+ // Create repositories
92+ historyRepo := sqlite.NewHistoryRepository(db)
93+
94+ // Create use cases
95+ searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
96+
97+ return &App{
98+ Config: cfg,
99+ Theme: theme,
100+ db: db,
101+ History: historyRepo,
102+ SearchHistoryUC: searchHistoryUC,
103+ ctx: ctx,
104+ logCleanup: logCleanup,
105+ }, nil
106+}
107+
108+// Close releases all resources.
109+func (a *App) Close() error {
110+ if a.logCleanup != nil {
111+ a.logCleanup()
112+ }
113+ if a.db != nil {
114+ return a.db.Close()
115+ }
116+ return nil
117+}
118+
119+// Ctx returns the application context with logger.
120+func (a *App) Ctx() context.Context {
121+ return a.ctx
122+}
123+
124+// loadConfig loads configuration from standard locations.
125+func loadConfig() (*config.Config, error) {
126+ mgr, err := config.NewManager()
127+ if err != nil {
128+ // Return default config if manager fails
129+ return config.DefaultConfig(), nil
130+ }
131+
132+ if err := mgr.Load(); err != nil {
133+ // Return default config if loading fails
134+ return config.DefaultConfig(), nil
135+ }
136+
137+ return mgr.Get(), nil
138+}
@@ -0,0 +1,138 @@
1+// Package cli provides CLI commands using Bubble Tea TUI.
2+package cli
3+
4+import (
5+ "context"
6+ "database/sql"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+
11+ "github.com/bnema/dumber/internal/application/usecase"
12+ "github.com/bnema/dumber/internal/cli/styles"
13+ "github.com/bnema/dumber/internal/domain/repository"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
16+ "github.com/bnema/dumber/internal/logging"
17+)
18+
19+// App holds CLI dependencies.
20+type App struct {
21+ Config *config.Config
22+ Theme *styles.Theme
23+ db *sql.DB
24+ History repository.HistoryRepository
25+
26+ // Use cases
27+ SearchHistoryUC *usecase.SearchHistoryUseCase
28+
29+ // Context with logger
30+ ctx context.Context
31+ logCleanup func()
32+}
33+
34+// NewApp creates a new CLI application with all dependencies.
35+func NewApp() (*App, error) {
36+ // Load config
37+ cfg, err := loadConfig()
38+ if err != nil {
39+ return nil, fmt.Errorf("load config: %w", err)
40+ }
41+
42+ // Create theme from config
43+ theme := styles.NewTheme(cfg)
44+
45+ // Initialize logger with file output for debugging
46+ // Logs can be viewed with: tail -f ~/.local/state/dumber/logs/session_*.log
47+ logCfg := logging.DefaultConfig()
48+ if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {
49+ logCfg.Level = logging.ParseLevel(level)
50+ }
51+
52+ // Create session log file
53+ home, _ := os.UserHomeDir()
54+ sessionID := logging.GenerateSessionID()
55+ logDir := filepath.Join(home, ".local", "state", "dumber", "logs")
56+
57+ logger, logCleanup, err := logging.NewWithFile(logCfg, logging.FileConfig{
58+ Enabled: true,
59+ LogDir: logDir,
60+ SessionID: sessionID,
61+ })
62+ if err != nil {
63+ // Fallback to no-file logging
64+ logger = logging.NewFromEnv()
65+ logCleanup = func() {}
66+ }
67+
68+ ctx := logging.WithContext(context.Background(), logger)
69+ logger.Info().Str("command", "cli").Msg("CLI session started")
70+
71+ // Determine database path - use share directory per XDG spec (user data)
72+ dbFile := cfg.Database.Path
73+ if dbFile == "" {
74+ dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db")
75+ }
76+ dbPath := filepath.Dir(dbFile)
77+
78+ // Ensure data directory exists
79+ if err := os.MkdirAll(dbPath, 0755); err != nil {
80+ return nil, fmt.Errorf("create data dir: %w", err)
81+ }
82+
83+ // Open database connection
84+ db, err := sqlite.NewConnection(ctx, dbFile)
85+ if err != nil {
86+ return nil, fmt.Errorf("open database: %w", err)
87+ }
88+
89+ logger.Debug().Str("db_path", dbFile).Msg("database connected")
90+
91+ // Create repositories
92+ historyRepo := sqlite.NewHistoryRepository(db)
93+
94+ // Create use cases
95+ searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
96+
97+ return &App{
98+ Config: cfg,
99+ Theme: theme,
100+ db: db,
101+ History: historyRepo,
102+ SearchHistoryUC: searchHistoryUC,
103+ ctx: ctx,
104+ logCleanup: logCleanup,
105+ }, nil
106+}
107+
108+// Close releases all resources.
109+func (a *App) Close() error {
110+ if a.logCleanup != nil {
111+ a.logCleanup()
112+ }
113+ if a.db != nil {
114+ return a.db.Close()
115+ }
116+ return nil
117+}
118+
119+// Ctx returns the application context with logger.
120+func (a *App) Ctx() context.Context {
121+ return a.ctx
122+}
123+
124+// loadConfig loads configuration from standard locations.
125+func loadConfig() (*config.Config, error) {
126+ mgr, err := config.NewManager()
127+ if err != nil {
128+ // Return default config if manager fails
129+ return config.DefaultConfig(), nil
130+ }
131+
132+ if err := mgr.Load(); err != nil {
133+ // Return default config if loading fails
134+ return config.DefaultConfig(), nil
135+ }
136+
137+ return mgr.Get(), nil
138+}
A internal/cli/cmd/dmenu.go +164 -0
@@ -0,0 +1,164 @@
1+package cmd
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "strings"
10+
11+ tea "github.com/charmbracelet/bubbletea"
12+ "github.com/spf13/cobra"
13+
14+ "github.com/bnema/dumber/internal/cli/model"
15+)
16+
17+var (
18+ dmenuInteractive bool
19+ dmenuSelect bool
20+ dmenuMax int
21+)
22+
23+var dmenuCmd = &cobra.Command{
24+ Use: "dmenu",
25+ Short: "Launcher integration for rofi/fuzzel",
26+ Long: `Output history for use with rofi, fuzzel, or other launchers.
27+
28+Default mode outputs entries for piping:
29+ dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
30+
31+Interactive mode provides a built-in TUI fuzzy finder:
32+ dumber dmenu --interactive`,
33+ RunE: runDmenu,
34+}
35+
36+func init() {
37+ rootCmd.AddCommand(dmenuCmd)
38+
39+ dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
40+ dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")
41+ dmenuCmd.Flags().IntVar(&dmenuMax, "max", 100, "maximum entries to output")
42+}
43+
44+func runDmenu(cmd *cobra.Command, args []string) error {
45+ app := GetApp()
46+ if app == nil {
47+ return fmt.Errorf("app not initialized")
48+ }
49+
50+ // Selection mode: read URL from stdin and open it
51+ if dmenuSelect {
52+ return runDmenuSelect()
53+ }
54+
55+ // Interactive TUI mode
56+ if dmenuInteractive {
57+ return runDmenuInteractive()
58+ }
59+
60+ // Pipe mode: output entries for rofi/fuzzel
61+ return runDmenuPipe()
62+}
63+
64+// runDmenuPipe outputs history entries for launcher consumption.
65+func runDmenuPipe() error {
66+ app := GetApp()
67+ ctx := context.Background()
68+
69+ entries, err := app.SearchHistoryUC.GetRecent(ctx, dmenuMax, 0)
70+ if err != nil {
71+ return fmt.Errorf("get history: %w", err)
72+ }
73+
74+ // Output in rofi-compatible format with icon hints
75+ // Format: text\0icon\x1ficonpath
76+ for _, entry := range entries {
77+ title := entry.Title
78+ if title == "" {
79+ title = entry.URL
80+ }
81+
82+ // Truncate long titles
83+ if len(title) > 80 {
84+ title = title[:77] + "..."
85+ }
86+
87+ // Output with URL as the actual value (for selection)
88+ // Using tab separator so rofi can use -d '\t'
89+ fmt.Printf("%s\t%s\n", title, entry.URL)
90+ }
91+
92+ return nil
93+}
94+
95+// runDmenuSelect processes a selection from stdin.
96+func runDmenuSelect() error {
97+ reader := bufio.NewReader(os.Stdin)
98+ line, err := reader.ReadString('\n')
99+ if err != nil {
100+ return fmt.Errorf("read selection: %w", err)
101+ }
102+
103+ line = strings.TrimSpace(line)
104+ if line == "" {
105+ return nil // No selection
106+ }
107+
108+ // If the line contains a tab, the URL is after it
109+ // Otherwise, try to extract URL or use the whole line
110+ url := line
111+ if idx := strings.Index(line, "\t"); idx != -1 {
112+ url = strings.TrimSpace(line[idx+1:])
113+ }
114+
115+ // Try to open the URL
116+ if url != "" {
117+ return openInBrowser(url)
118+ }
119+
120+ return nil
121+}
122+
123+// runDmenuInteractive runs the interactive TUI dmenu.
124+func runDmenuInteractive() error {
125+ app := GetApp()
126+
127+ m := model.NewDmenuModel(app.Theme, app.SearchHistoryUC)
128+
129+ p := tea.NewProgram(m, tea.WithAltScreen())
130+ finalModel, err := p.Run()
131+ if err != nil {
132+ return err
133+ }
134+
135+ // If user selected an entry, open it
136+ dm, ok := finalModel.(model.DmenuModel)
137+ if ok && dm.SelectedURL() != "" {
138+ return openInBrowser(dm.SelectedURL())
139+ }
140+
141+ return nil
142+}
143+
144+// openInBrowser opens a URL in the default browser.
145+func openInBrowser(url string) error {
146+ // Try xdg-open first (Linux)
147+ cmd := newCommand("xdg-open", url)
148+ return cmd.Start()
149+}
150+
151+// newCommand creates a command (abstracted for testing).
152+var newCommand = func(name string, args ...string) interface{ Start() error } {
153+ return &commandWrapper{name: name, args: args}
154+}
155+
156+type commandWrapper struct {
157+ name string
158+ args []string
159+}
160+
161+func (c *commandWrapper) Start() error {
162+ cmd := exec.Command(c.name, c.args...)
163+ return cmd.Start()
164+}
@@ -0,0 +1,164 @@
1+package cmd
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "strings"
10+
11+ tea "github.com/charmbracelet/bubbletea"
12+ "github.com/spf13/cobra"
13+
14+ "github.com/bnema/dumber/internal/cli/model"
15+)
16+
17+var (
18+ dmenuInteractive bool
19+ dmenuSelect bool
20+ dmenuMax int
21+)
22+
23+var dmenuCmd = &cobra.Command{
24+ Use: "dmenu",
25+ Short: "Launcher integration for rofi/fuzzel",
26+ Long: `Output history for use with rofi, fuzzel, or other launchers.
27+
28+Default mode outputs entries for piping:
29+ dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
30+
31+Interactive mode provides a built-in TUI fuzzy finder:
32+ dumber dmenu --interactive`,
33+ RunE: runDmenu,
34+}
35+
36+func init() {
37+ rootCmd.AddCommand(dmenuCmd)
38+
39+ dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
40+ dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")
41+ dmenuCmd.Flags().IntVar(&dmenuMax, "max", 100, "maximum entries to output")
42+}
43+
44+func runDmenu(cmd *cobra.Command, args []string) error {
45+ app := GetApp()
46+ if app == nil {
47+ return fmt.Errorf("app not initialized")
48+ }
49+
50+ // Selection mode: read URL from stdin and open it
51+ if dmenuSelect {
52+ return runDmenuSelect()
53+ }
54+
55+ // Interactive TUI mode
56+ if dmenuInteractive {
57+ return runDmenuInteractive()
58+ }
59+
60+ // Pipe mode: output entries for rofi/fuzzel
61+ return runDmenuPipe()
62+}
63+
64+// runDmenuPipe outputs history entries for launcher consumption.
65+func runDmenuPipe() error {
66+ app := GetApp()
67+ ctx := context.Background()
68+
69+ entries, err := app.SearchHistoryUC.GetRecent(ctx, dmenuMax, 0)
70+ if err != nil {
71+ return fmt.Errorf("get history: %w", err)
72+ }
73+
74+ // Output in rofi-compatible format with icon hints
75+ // Format: text\0icon\x1ficonpath
76+ for _, entry := range entries {
77+ title := entry.Title
78+ if title == "" {
79+ title = entry.URL
80+ }
81+
82+ // Truncate long titles
83+ if len(title) > 80 {
84+ title = title[:77] + "..."
85+ }
86+
87+ // Output with URL as the actual value (for selection)
88+ // Using tab separator so rofi can use -d '\t'
89+ fmt.Printf("%s\t%s\n", title, entry.URL)
90+ }
91+
92+ return nil
93+}
94+
95+// runDmenuSelect processes a selection from stdin.
96+func runDmenuSelect() error {
97+ reader := bufio.NewReader(os.Stdin)
98+ line, err := reader.ReadString('\n')
99+ if err != nil {
100+ return fmt.Errorf("read selection: %w", err)
101+ }
102+
103+ line = strings.TrimSpace(line)
104+ if line == "" {
105+ return nil // No selection
106+ }
107+
108+ // If the line contains a tab, the URL is after it
109+ // Otherwise, try to extract URL or use the whole line
110+ url := line
111+ if idx := strings.Index(line, "\t"); idx != -1 {
112+ url = strings.TrimSpace(line[idx+1:])
113+ }
114+
115+ // Try to open the URL
116+ if url != "" {
117+ return openInBrowser(url)
118+ }
119+
120+ return nil
121+}
122+
123+// runDmenuInteractive runs the interactive TUI dmenu.
124+func runDmenuInteractive() error {
125+ app := GetApp()
126+
127+ m := model.NewDmenuModel(app.Theme, app.SearchHistoryUC)
128+
129+ p := tea.NewProgram(m, tea.WithAltScreen())
130+ finalModel, err := p.Run()
131+ if err != nil {
132+ return err
133+ }
134+
135+ // If user selected an entry, open it
136+ dm, ok := finalModel.(model.DmenuModel)
137+ if ok && dm.SelectedURL() != "" {
138+ return openInBrowser(dm.SelectedURL())
139+ }
140+
141+ return nil
142+}
143+
144+// openInBrowser opens a URL in the default browser.
145+func openInBrowser(url string) error {
146+ // Try xdg-open first (Linux)
147+ cmd := newCommand("xdg-open", url)
148+ return cmd.Start()
149+}
150+
151+// newCommand creates a command (abstracted for testing).
152+var newCommand = func(name string, args ...string) interface{ Start() error } {
153+ return &commandWrapper{name: name, args: args}
154+}
155+
156+type commandWrapper struct {
157+ name string
158+ args []string
159+}
160+
161+func (c *commandWrapper) Start() error {
162+ cmd := exec.Command(c.name, c.args...)
163+ return cmd.Start()
164+}
A internal/cli/cmd/history.go +139 -0
@@ -0,0 +1,139 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+
8+ tea "github.com/charmbracelet/bubbletea"
9+ "github.com/spf13/cobra"
10+
11+ "github.com/bnema/dumber/internal/cli/model"
12+)
13+
14+var (
15+ historyJSON bool
16+ historyMax int
17+)
18+
19+var historyCmd = &cobra.Command{
20+ Use: "history",
21+ Short: "Browse and manage history",
22+ Long: `Interactive history browser with timeline tabs, fuzzy search, and cleanup.`,
23+ RunE: runHistory,
24+}
25+
26+func init() {
27+ rootCmd.AddCommand(historyCmd)
28+
29+ historyCmd.Flags().BoolVar(&historyJSON, "json", false, "output as JSON")
30+ historyCmd.Flags().IntVar(&historyMax, "max", 50, "maximum entries to show (for --json)")
31+}
32+
33+func runHistory(cmd *cobra.Command, args []string) error {
34+ app := GetApp()
35+ if app == nil {
36+ return fmt.Errorf("app not initialized")
37+ }
38+
39+ // JSON output mode (non-interactive)
40+ if historyJSON {
41+ return runHistoryJSON()
42+ }
43+
44+ // Interactive TUI mode
45+ return runHistoryTUI()
46+}
47+
48+// runHistoryTUI runs the interactive history browser.
49+func runHistoryTUI() error {
50+ app := GetApp()
51+
52+ m := model.NewHistoryModel(app.Ctx(), app.Theme, app.SearchHistoryUC)
53+
54+ p := tea.NewProgram(m, tea.WithAltScreen())
55+ _, err := p.Run()
56+ return err
57+}
58+
59+// runHistoryJSON outputs history as JSON.
60+func runHistoryJSON() error {
61+ app := GetApp()
62+
63+ m := model.NewHistoryListModel(app.Ctx(), app.SearchHistoryUC, historyMax)
64+
65+ // Run briefly to load data
66+ p := tea.NewProgram(m)
67+ finalModel, err := p.Run()
68+ if err != nil {
69+ return fmt.Errorf("run model: %w", err)
70+ }
71+
72+ // Extract results
73+ listModel, ok := finalModel.(model.HistoryListModel)
74+ if !ok {
75+ return fmt.Errorf("unexpected model type")
76+ }
77+
78+ if listModel.Error() != nil {
79+ return listModel.Error()
80+ }
81+
82+ // Output as JSON
83+ entries := listModel.Entries()
84+ enc := json.NewEncoder(os.Stdout)
85+ enc.SetIndent("", " ")
86+ return enc.Encode(entries)
87+}
88+
89+// statsCmd shows history statistics.
90+var statsCmd = &cobra.Command{
91+ Use: "stats",
92+ Short: "Show history statistics",
93+ Long: `Display analytics about your browsing history.`,
94+ RunE: runStats,
95+}
96+
97+func init() {
98+ historyCmd.AddCommand(statsCmd)
99+}
100+
101+func runStats(cmd *cobra.Command, args []string) error {
102+ app := GetApp()
103+ if app == nil {
104+ return fmt.Errorf("app not initialized")
105+ }
106+
107+ // Run stats model
108+ m := model.NewStatsModel(app.Theme, app.SearchHistoryUC)
109+
110+ p := tea.NewProgram(m)
111+ _, err := p.Run()
112+ return err
113+}
114+
115+// clearCmd clears history.
116+var clearCmd = &cobra.Command{
117+ Use: "clear",
118+ Short: "Clear history",
119+ Long: `Interactively select time range to clear history.`,
120+ RunE: runClear,
121+}
122+
123+func init() {
124+ historyCmd.AddCommand(clearCmd)
125+}
126+
127+func runClear(cmd *cobra.Command, args []string) error {
128+ app := GetApp()
129+ if app == nil {
130+ return fmt.Errorf("app not initialized")
131+ }
132+
133+ // Run cleanup modal directly
134+ m := model.NewCleanupModel(app.Theme, app.SearchHistoryUC)
135+
136+ p := tea.NewProgram(m)
137+ _, err := p.Run()
138+ return err
139+}
@@ -0,0 +1,139 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+
8+ tea "github.com/charmbracelet/bubbletea"
9+ "github.com/spf13/cobra"
10+
11+ "github.com/bnema/dumber/internal/cli/model"
12+)
13+
14+var (
15+ historyJSON bool
16+ historyMax int
17+)
18+
19+var historyCmd = &cobra.Command{
20+ Use: "history",
21+ Short: "Browse and manage history",
22+ Long: `Interactive history browser with timeline tabs, fuzzy search, and cleanup.`,
23+ RunE: runHistory,
24+}
25+
26+func init() {
27+ rootCmd.AddCommand(historyCmd)
28+
29+ historyCmd.Flags().BoolVar(&historyJSON, "json", false, "output as JSON")
30+ historyCmd.Flags().IntVar(&historyMax, "max", 50, "maximum entries to show (for --json)")
31+}
32+
33+func runHistory(cmd *cobra.Command, args []string) error {
34+ app := GetApp()
35+ if app == nil {
36+ return fmt.Errorf("app not initialized")
37+ }
38+
39+ // JSON output mode (non-interactive)
40+ if historyJSON {
41+ return runHistoryJSON()
42+ }
43+
44+ // Interactive TUI mode
45+ return runHistoryTUI()
46+}
47+
48+// runHistoryTUI runs the interactive history browser.
49+func runHistoryTUI() error {
50+ app := GetApp()
51+
52+ m := model.NewHistoryModel(app.Ctx(), app.Theme, app.SearchHistoryUC)
53+
54+ p := tea.NewProgram(m, tea.WithAltScreen())
55+ _, err := p.Run()
56+ return err
57+}
58+
59+// runHistoryJSON outputs history as JSON.
60+func runHistoryJSON() error {
61+ app := GetApp()
62+
63+ m := model.NewHistoryListModel(app.Ctx(), app.SearchHistoryUC, historyMax)
64+
65+ // Run briefly to load data
66+ p := tea.NewProgram(m)
67+ finalModel, err := p.Run()
68+ if err != nil {
69+ return fmt.Errorf("run model: %w", err)
70+ }
71+
72+ // Extract results
73+ listModel, ok := finalModel.(model.HistoryListModel)
74+ if !ok {
75+ return fmt.Errorf("unexpected model type")
76+ }
77+
78+ if listModel.Error() != nil {
79+ return listModel.Error()
80+ }
81+
82+ // Output as JSON
83+ entries := listModel.Entries()
84+ enc := json.NewEncoder(os.Stdout)
85+ enc.SetIndent("", " ")
86+ return enc.Encode(entries)
87+}
88+
89+// statsCmd shows history statistics.
90+var statsCmd = &cobra.Command{
91+ Use: "stats",
92+ Short: "Show history statistics",
93+ Long: `Display analytics about your browsing history.`,
94+ RunE: runStats,
95+}
96+
97+func init() {
98+ historyCmd.AddCommand(statsCmd)
99+}
100+
101+func runStats(cmd *cobra.Command, args []string) error {
102+ app := GetApp()
103+ if app == nil {
104+ return fmt.Errorf("app not initialized")
105+ }
106+
107+ // Run stats model
108+ m := model.NewStatsModel(app.Theme, app.SearchHistoryUC)
109+
110+ p := tea.NewProgram(m)
111+ _, err := p.Run()
112+ return err
113+}
114+
115+// clearCmd clears history.
116+var clearCmd = &cobra.Command{
117+ Use: "clear",
118+ Short: "Clear history",
119+ Long: `Interactively select time range to clear history.`,
120+ RunE: runClear,
121+}
122+
123+func init() {
124+ historyCmd.AddCommand(clearCmd)
125+}
126+
127+func runClear(cmd *cobra.Command, args []string) error {
128+ app := GetApp()
129+ if app == nil {
130+ return fmt.Errorf("app not initialized")
131+ }
132+
133+ // Run cleanup modal directly
134+ m := model.NewCleanupModel(app.Theme, app.SearchHistoryUC)
135+
136+ p := tea.NewProgram(m)
137+ _, err := p.Run()
138+ return err
139+}
A internal/cli/cmd/logs.go +425 -0
@@ -0,0 +1,425 @@
1+package cmd
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "strings"
12+ "time"
13+
14+ "github.com/spf13/cobra"
15+
16+ "github.com/bnema/dumber/internal/cli/styles"
17+ "github.com/bnema/dumber/internal/infrastructure/config"
18+ "github.com/bnema/dumber/internal/logging"
19+)
20+
21+var (
22+ logsFollow bool
23+ logsLines int
24+ logsClearAll bool
25+)
26+
27+var logsCmd = &cobra.Command{
28+ Use: "logs [session]",
29+ Short: "View application logs",
30+ Long: `View dumber application logs by session.
31+
32+Without arguments, lists all available sessions.
33+With a session ID (or partial match), shows logs for that session.
34+
35+Examples:
36+ dumber logs # List all sessions
37+ dumber logs a7b3 # View logs for session ending in 'a7b3'
38+ dumber logs -f a7b3 # Follow logs in real-time
39+ dumber logs -n 100 a7b3 # Show last 100 lines`,
40+ RunE: runLogs,
41+}
42+
43+func init() {
44+ rootCmd.AddCommand(logsCmd)
45+
46+ logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "follow log output in real-time")
47+ logsCmd.Flags().IntVarP(&logsLines, "lines", "n", 50, "number of lines to show")
48+}
49+
50+// SessionInfo holds metadata about a log session.
51+type SessionInfo struct {
52+ SessionID string
53+ ShortID string
54+ Filename string
55+ Path string
56+ Size int64
57+ ModTime time.Time
58+}
59+
60+func runLogs(cmd *cobra.Command, args []string) error {
61+ app := GetApp()
62+ if app == nil {
63+ return fmt.Errorf("app not initialized")
64+ }
65+
66+ logDir := getLogDir()
67+
68+ // List sessions if no argument provided
69+ if len(args) == 0 {
70+ return listSessions(logDir, app.Theme)
71+ }
72+
73+ // Find session by partial match
74+ sessionQuery := args[0]
75+ session, err := findSession(logDir, sessionQuery)
76+ if err != nil {
77+ return err
78+ }
79+
80+ if logsFollow {
81+ return tailSession(session.Path, app.Theme)
82+ }
83+
84+ return showSession(session.Path, logsLines, app.Theme)
85+}
86+
87+// getLogDir returns the log directory path.
88+func getLogDir() string {
89+ logDir, err := config.GetLogDir()
90+ if err != nil {
91+ // Fallback to XDG default
92+ stateDir := os.Getenv("XDG_STATE_HOME")
93+ if stateDir == "" {
94+ home, _ := os.UserHomeDir()
95+ stateDir = filepath.Join(home, ".local", "state")
96+ }
97+ return filepath.Join(stateDir, "dumber", "logs")
98+ }
99+ return logDir
100+}
101+
102+// listSessions displays all available log sessions.
103+func listSessions(logDir string, theme *styles.Theme) error {
104+ sessions, err := getSessions(logDir)
105+ if err != nil {
106+ return err
107+ }
108+
109+ if len(sessions) == 0 {
110+ fmt.Println(theme.Subtle.Render("No sessions found. Run 'dumber browse' to create logs."))
111+ return nil
112+ }
113+
114+ // Header
115+ fmt.Println(theme.Title.Render("Sessions (newest first):"))
116+ fmt.Println()
117+
118+ // Table format: ShortID | DateTime | Size
119+ for _, s := range sessions {
120+ dateStr := s.ModTime.Format("2006-01-02 15:04:05")
121+ sizeStr := formatSize(s.Size)
122+
123+ line := fmt.Sprintf(" %s %s %s",
124+ theme.Highlight.Render(s.ShortID),
125+ theme.Subtle.Render(dateStr),
126+ theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)),
127+ )
128+ fmt.Println(line)
129+ }
130+
131+ fmt.Println()
132+ fmt.Println(theme.Subtle.Render("Use 'dumber logs <id>' to view a session"))
133+ return nil
134+}
135+
136+// getSessions returns all session log files, sorted by modification time (newest first).
137+func getSessions(logDir string) ([]SessionInfo, error) {
138+ entries, err := os.ReadDir(logDir)
139+ if err != nil {
140+ if os.IsNotExist(err) {
141+ return nil, nil
142+ }
143+ return nil, fmt.Errorf("read log directory: %w", err)
144+ }
145+
146+ var sessions []SessionInfo
147+ for _, entry := range entries {
148+ if entry.IsDir() {
149+ continue
150+ }
151+
152+ sessionID, ok := logging.ParseSessionFilename(entry.Name())
153+ if !ok {
154+ continue
155+ }
156+
157+ info, err := entry.Info()
158+ if err != nil {
159+ continue
160+ }
161+
162+ sessions = append(sessions, SessionInfo{
163+ SessionID: sessionID,
164+ ShortID: logging.ShortSessionID(sessionID),
165+ Filename: entry.Name(),
166+ Path: filepath.Join(logDir, entry.Name()),
167+ Size: info.Size(),
168+ ModTime: info.ModTime(),
169+ })
170+ }
171+
172+ // Sort by modification time (newest first)
173+ sort.Slice(sessions, func(i, j int) bool {
174+ return sessions[i].ModTime.After(sessions[j].ModTime)
175+ })
176+
177+ return sessions, nil
178+}
179+
180+// findSession finds a session by partial ID match.
181+func findSession(logDir, query string) (*SessionInfo, error) {
182+ sessions, err := getSessions(logDir)
183+ if err != nil {
184+ return nil, err
185+ }
186+
187+ if len(sessions) == 0 {
188+ return nil, fmt.Errorf("no sessions found")
189+ }
190+
191+ query = strings.ToLower(query)
192+
193+ // Try exact short ID match first
194+ for _, s := range sessions {
195+ if strings.ToLower(s.ShortID) == query {
196+ return &s, nil
197+ }
198+ }
199+
200+ // Try partial match on full session ID
201+ var matches []SessionInfo
202+ for _, s := range sessions {
203+ if strings.Contains(strings.ToLower(s.SessionID), query) {
204+ matches = append(matches, s)
205+ }
206+ }
207+
208+ switch len(matches) {
209+ case 0:
210+ return nil, fmt.Errorf("no session matching '%s' found", query)
211+ case 1:
212+ return &matches[0], nil
213+ default:
214+ // Multiple matches - show them and ask user to be more specific
215+ var ids []string
216+ for _, m := range matches {
217+ ids = append(ids, m.ShortID)
218+ }
219+ return nil, fmt.Errorf("multiple sessions match '%s': %s", query, strings.Join(ids, ", "))
220+ }
221+}
222+
223+// showSession displays the last N lines of a session log.
224+func showSession(logPath string, lines int, theme *styles.Theme) error {
225+ file, err := os.Open(logPath)
226+ if err != nil {
227+ return fmt.Errorf("open log file: %w", err)
228+ }
229+ defer file.Close()
230+
231+ // Read all lines
232+ var allLines []string
233+ scanner := bufio.NewScanner(file)
234+ for scanner.Scan() {
235+ allLines = append(allLines, scanner.Text())
236+ }
237+
238+ if err := scanner.Err(); err != nil {
239+ return fmt.Errorf("read log file: %w", err)
240+ }
241+
242+ // Get last N lines
243+ start := 0
244+ if len(allLines) > lines {
245+ start = len(allLines) - lines
246+ }
247+
248+ for _, line := range allLines[start:] {
249+ fmt.Println(colorizeLogLine(line, theme))
250+ }
251+
252+ return nil
253+}
254+
255+// tailSession follows a session log in real-time.
256+func tailSession(logPath string, theme *styles.Theme) error {
257+ file, err := os.Open(logPath)
258+ if err != nil {
259+ return fmt.Errorf("open log file: %w", err)
260+ }
261+ defer file.Close()
262+
263+ // Seek to end
264+ _, _ = file.Seek(0, io.SeekEnd)
265+
266+ fmt.Println(theme.Subtle.Render("Following logs... (Ctrl+C to stop)"))
267+ fmt.Println()
268+
269+ reader := bufio.NewReader(file)
270+ for {
271+ line, err := reader.ReadString('\n')
272+ if err != nil {
273+ // No new data, wait a bit
274+ time.Sleep(100 * time.Millisecond)
275+ continue
276+ }
277+ fmt.Print(colorizeLogLine(line, theme))
278+ }
279+}
280+
281+// logEntry represents a parsed JSON log entry.
282+type logEntry struct {
283+ Level string `json:"level"`
284+ Time string `json:"time"`
285+ Message string `json:"message"`
286+ Session string `json:"session"`
287+}
288+
289+// colorizeLogLine adds color based on log level.
290+func colorizeLogLine(line string, theme *styles.Theme) string {
291+ // Try to parse as JSON
292+ var entry logEntry
293+ if err := json.Unmarshal([]byte(line), &entry); err == nil {
294+ return formatJSONLogLine(entry, theme)
295+ }
296+
297+ // Fallback to pattern matching for non-JSON logs
298+ switch {
299+ case containsAny(line, "ERR", "ERROR", "error"):
300+ return theme.ErrorStyle.Render(line)
301+ case containsAny(line, "WRN", "WARN", "warn"):
302+ return theme.WarningStyle.Render(line)
303+ case containsAny(line, "DBG", "DEBUG", "debug"):
304+ return theme.Subtle.Render(line)
305+ default:
306+ return line
307+ }
308+}
309+
310+// formatJSONLogLine formats a parsed JSON log entry with colors.
311+func formatJSONLogLine(entry logEntry, theme *styles.Theme) string {
312+ // Parse time if present
313+ timeStr := ""
314+ if entry.Time != "" {
315+ if t, err := time.Parse(time.RFC3339, entry.Time); err == nil {
316+ timeStr = t.Format("15:04:05")
317+ } else {
318+ timeStr = entry.Time
319+ }
320+ }
321+
322+ // Level styling
323+ var levelStr string
324+ switch entry.Level {
325+ case "error":
326+ levelStr = theme.ErrorStyle.Render("ERR")
327+ case "warn":
328+ levelStr = theme.WarningStyle.Render("WRN")
329+ case "info":
330+ levelStr = theme.Highlight.Render("INF")
331+ case "debug":
332+ levelStr = theme.Subtle.Render("DBG")
333+ case "trace":
334+ levelStr = theme.Subtle.Render("TRC")
335+ default:
336+ levelStr = entry.Level
337+ }
338+
339+ return fmt.Sprintf("%s %s %s", theme.Subtle.Render(timeStr), levelStr, entry.Message)
340+}
341+
342+// containsAny checks if s contains any of the substrings.
343+func containsAny(s string, substrs ...string) bool {
344+ sLower := strings.ToLower(s)
345+ for _, substr := range substrs {
346+ if strings.Contains(sLower, strings.ToLower(substr)) {
347+ return true
348+ }
349+ }
350+ return false
351+}
352+
353+// logsClearCmd clears old session logs.
354+var logsClearCmd = &cobra.Command{
355+ Use: "clear",
356+ Short: "Clear old log files",
357+ Long: `Remove old session log files.
358+
359+By default, removes sessions older than the configured MaxAge (default 7 days).
360+Use --all to remove all sessions.`,
361+ RunE: runLogsClear,
362+}
363+
364+func init() {
365+ logsCmd.AddCommand(logsClearCmd)
366+ logsClearCmd.Flags().BoolVar(&logsClearAll, "all", false, "remove all session logs")
367+}
368+
369+func runLogsClear(cmd *cobra.Command, args []string) error {
370+ app := GetApp()
371+ if app == nil {
372+ return fmt.Errorf("app not initialized")
373+ }
374+
375+ logDir := getLogDir()
376+ sessions, err := getSessions(logDir)
377+ if err != nil {
378+ return err
379+ }
380+
381+ if len(sessions) == 0 {
382+ fmt.Println(app.Theme.Subtle.Render("No logs to clear"))
383+ return nil
384+ }
385+
386+ // Get MaxAge from config (default 7 days)
387+ maxAge := 7
388+ if app.Config != nil && app.Config.Logging.MaxAge > 0 {
389+ maxAge = app.Config.Logging.MaxAge
390+ }
391+
392+ cutoff := time.Now().AddDate(0, 0, -maxAge)
393+ var removed int
394+
395+ for _, s := range sessions {
396+ shouldRemove := logsClearAll || s.ModTime.Before(cutoff)
397+ if !shouldRemove {
398+ continue
399+ }
400+
401+ if err := os.Remove(s.Path); err != nil {
402+ fmt.Printf("%s %s: %v\n",
403+ app.Theme.ErrorStyle.Render("\u2717"),
404+ s.ShortID,
405+ err,
406+ )
407+ continue
408+ }
409+
410+ fmt.Printf("%s %s (%s)\n",
411+ app.Theme.SuccessStyle.Render("\u2713"),
412+ s.ShortID,
413+ formatSize(s.Size),
414+ )
415+ removed++
416+ }
417+
418+ if removed == 0 {
419+ fmt.Println(app.Theme.Subtle.Render("No sessions older than " + fmt.Sprintf("%d days", maxAge)))
420+ } else {
421+ fmt.Printf("\n%s\n", app.Theme.SuccessStyle.Render(fmt.Sprintf("Cleared %d session(s)", removed)))
422+ }
423+
424+ return nil
425+}
@@ -0,0 +1,425 @@
1+package cmd
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "strings"
12+ "time"
13+
14+ "github.com/spf13/cobra"
15+
16+ "github.com/bnema/dumber/internal/cli/styles"
17+ "github.com/bnema/dumber/internal/infrastructure/config"
18+ "github.com/bnema/dumber/internal/logging"
19+)
20+
21+var (
22+ logsFollow bool
23+ logsLines int
24+ logsClearAll bool
25+)
26+
27+var logsCmd = &cobra.Command{
28+ Use: "logs [session]",
29+ Short: "View application logs",
30+ Long: `View dumber application logs by session.
31+
32+Without arguments, lists all available sessions.
33+With a session ID (or partial match), shows logs for that session.
34+
35+Examples:
36+ dumber logs # List all sessions
37+ dumber logs a7b3 # View logs for session ending in 'a7b3'
38+ dumber logs -f a7b3 # Follow logs in real-time
39+ dumber logs -n 100 a7b3 # Show last 100 lines`,
40+ RunE: runLogs,
41+}
42+
43+func init() {
44+ rootCmd.AddCommand(logsCmd)
45+
46+ logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "follow log output in real-time")
47+ logsCmd.Flags().IntVarP(&logsLines, "lines", "n", 50, "number of lines to show")
48+}
49+
50+// SessionInfo holds metadata about a log session.
51+type SessionInfo struct {
52+ SessionID string
53+ ShortID string
54+ Filename string
55+ Path string
56+ Size int64
57+ ModTime time.Time
58+}
59+
60+func runLogs(cmd *cobra.Command, args []string) error {
61+ app := GetApp()
62+ if app == nil {
63+ return fmt.Errorf("app not initialized")
64+ }
65+
66+ logDir := getLogDir()
67+
68+ // List sessions if no argument provided
69+ if len(args) == 0 {
70+ return listSessions(logDir, app.Theme)
71+ }
72+
73+ // Find session by partial match
74+ sessionQuery := args[0]
75+ session, err := findSession(logDir, sessionQuery)
76+ if err != nil {
77+ return err
78+ }
79+
80+ if logsFollow {
81+ return tailSession(session.Path, app.Theme)
82+ }
83+
84+ return showSession(session.Path, logsLines, app.Theme)
85+}
86+
87+// getLogDir returns the log directory path.
88+func getLogDir() string {
89+ logDir, err := config.GetLogDir()
90+ if err != nil {
91+ // Fallback to XDG default
92+ stateDir := os.Getenv("XDG_STATE_HOME")
93+ if stateDir == "" {
94+ home, _ := os.UserHomeDir()
95+ stateDir = filepath.Join(home, ".local", "state")
96+ }
97+ return filepath.Join(stateDir, "dumber", "logs")
98+ }
99+ return logDir
100+}
101+
102+// listSessions displays all available log sessions.
103+func listSessions(logDir string, theme *styles.Theme) error {
104+ sessions, err := getSessions(logDir)
105+ if err != nil {
106+ return err
107+ }
108+
109+ if len(sessions) == 0 {
110+ fmt.Println(theme.Subtle.Render("No sessions found. Run 'dumber browse' to create logs."))
111+ return nil
112+ }
113+
114+ // Header
115+ fmt.Println(theme.Title.Render("Sessions (newest first):"))
116+ fmt.Println()
117+
118+ // Table format: ShortID | DateTime | Size
119+ for _, s := range sessions {
120+ dateStr := s.ModTime.Format("2006-01-02 15:04:05")
121+ sizeStr := formatSize(s.Size)
122+
123+ line := fmt.Sprintf(" %s %s %s",
124+ theme.Highlight.Render(s.ShortID),
125+ theme.Subtle.Render(dateStr),
126+ theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)),
127+ )
128+ fmt.Println(line)
129+ }
130+
131+ fmt.Println()
132+ fmt.Println(theme.Subtle.Render("Use 'dumber logs <id>' to view a session"))
133+ return nil
134+}
135+
136+// getSessions returns all session log files, sorted by modification time (newest first).
137+func getSessions(logDir string) ([]SessionInfo, error) {
138+ entries, err := os.ReadDir(logDir)
139+ if err != nil {
140+ if os.IsNotExist(err) {
141+ return nil, nil
142+ }
143+ return nil, fmt.Errorf("read log directory: %w", err)
144+ }
145+
146+ var sessions []SessionInfo
147+ for _, entry := range entries {
148+ if entry.IsDir() {
149+ continue
150+ }
151+
152+ sessionID, ok := logging.ParseSessionFilename(entry.Name())
153+ if !ok {
154+ continue
155+ }
156+
157+ info, err := entry.Info()
158+ if err != nil {
159+ continue
160+ }
161+
162+ sessions = append(sessions, SessionInfo{
163+ SessionID: sessionID,
164+ ShortID: logging.ShortSessionID(sessionID),
165+ Filename: entry.Name(),
166+ Path: filepath.Join(logDir, entry.Name()),
167+ Size: info.Size(),
168+ ModTime: info.ModTime(),
169+ })
170+ }
171+
172+ // Sort by modification time (newest first)
173+ sort.Slice(sessions, func(i, j int) bool {
174+ return sessions[i].ModTime.After(sessions[j].ModTime)
175+ })
176+
177+ return sessions, nil
178+}
179+
180+// findSession finds a session by partial ID match.
181+func findSession(logDir, query string) (*SessionInfo, error) {
182+ sessions, err := getSessions(logDir)
183+ if err != nil {
184+ return nil, err
185+ }
186+
187+ if len(sessions) == 0 {
188+ return nil, fmt.Errorf("no sessions found")
189+ }
190+
191+ query = strings.ToLower(query)
192+
193+ // Try exact short ID match first
194+ for _, s := range sessions {
195+ if strings.ToLower(s.ShortID) == query {
196+ return &s, nil
197+ }
198+ }
199+
200+ // Try partial match on full session ID
201+ var matches []SessionInfo
202+ for _, s := range sessions {
203+ if strings.Contains(strings.ToLower(s.SessionID), query) {
204+ matches = append(matches, s)
205+ }
206+ }
207+
208+ switch len(matches) {
209+ case 0:
210+ return nil, fmt.Errorf("no session matching '%s' found", query)
211+ case 1:
212+ return &matches[0], nil
213+ default:
214+ // Multiple matches - show them and ask user to be more specific
215+ var ids []string
216+ for _, m := range matches {
217+ ids = append(ids, m.ShortID)
218+ }
219+ return nil, fmt.Errorf("multiple sessions match '%s': %s", query, strings.Join(ids, ", "))
220+ }
221+}
222+
223+// showSession displays the last N lines of a session log.
224+func showSession(logPath string, lines int, theme *styles.Theme) error {
225+ file, err := os.Open(logPath)
226+ if err != nil {
227+ return fmt.Errorf("open log file: %w", err)
228+ }
229+ defer file.Close()
230+
231+ // Read all lines
232+ var allLines []string
233+ scanner := bufio.NewScanner(file)
234+ for scanner.Scan() {
235+ allLines = append(allLines, scanner.Text())
236+ }
237+
238+ if err := scanner.Err(); err != nil {
239+ return fmt.Errorf("read log file: %w", err)
240+ }
241+
242+ // Get last N lines
243+ start := 0
244+ if len(allLines) > lines {
245+ start = len(allLines) - lines
246+ }
247+
248+ for _, line := range allLines[start:] {
249+ fmt.Println(colorizeLogLine(line, theme))
250+ }
251+
252+ return nil
253+}
254+
255+// tailSession follows a session log in real-time.
256+func tailSession(logPath string, theme *styles.Theme) error {
257+ file, err := os.Open(logPath)
258+ if err != nil {
259+ return fmt.Errorf("open log file: %w", err)
260+ }
261+ defer file.Close()
262+
263+ // Seek to end
264+ _, _ = file.Seek(0, io.SeekEnd)
265+
266+ fmt.Println(theme.Subtle.Render("Following logs... (Ctrl+C to stop)"))
267+ fmt.Println()
268+
269+ reader := bufio.NewReader(file)
270+ for {
271+ line, err := reader.ReadString('\n')
272+ if err != nil {
273+ // No new data, wait a bit
274+ time.Sleep(100 * time.Millisecond)
275+ continue
276+ }
277+ fmt.Print(colorizeLogLine(line, theme))
278+ }
279+}
280+
281+// logEntry represents a parsed JSON log entry.
282+type logEntry struct {
283+ Level string `json:"level"`
284+ Time string `json:"time"`
285+ Message string `json:"message"`
286+ Session string `json:"session"`
287+}
288+
289+// colorizeLogLine adds color based on log level.
290+func colorizeLogLine(line string, theme *styles.Theme) string {
291+ // Try to parse as JSON
292+ var entry logEntry
293+ if err := json.Unmarshal([]byte(line), &entry); err == nil {
294+ return formatJSONLogLine(entry, theme)
295+ }
296+
297+ // Fallback to pattern matching for non-JSON logs
298+ switch {
299+ case containsAny(line, "ERR", "ERROR", "error"):
300+ return theme.ErrorStyle.Render(line)
301+ case containsAny(line, "WRN", "WARN", "warn"):
302+ return theme.WarningStyle.Render(line)
303+ case containsAny(line, "DBG", "DEBUG", "debug"):
304+ return theme.Subtle.Render(line)
305+ default:
306+ return line
307+ }
308+}
309+
310+// formatJSONLogLine formats a parsed JSON log entry with colors.
311+func formatJSONLogLine(entry logEntry, theme *styles.Theme) string {
312+ // Parse time if present
313+ timeStr := ""
314+ if entry.Time != "" {
315+ if t, err := time.Parse(time.RFC3339, entry.Time); err == nil {
316+ timeStr = t.Format("15:04:05")
317+ } else {
318+ timeStr = entry.Time
319+ }
320+ }
321+
322+ // Level styling
323+ var levelStr string
324+ switch entry.Level {
325+ case "error":
326+ levelStr = theme.ErrorStyle.Render("ERR")
327+ case "warn":
328+ levelStr = theme.WarningStyle.Render("WRN")
329+ case "info":
330+ levelStr = theme.Highlight.Render("INF")
331+ case "debug":
332+ levelStr = theme.Subtle.Render("DBG")
333+ case "trace":
334+ levelStr = theme.Subtle.Render("TRC")
335+ default:
336+ levelStr = entry.Level
337+ }
338+
339+ return fmt.Sprintf("%s %s %s", theme.Subtle.Render(timeStr), levelStr, entry.Message)
340+}
341+
342+// containsAny checks if s contains any of the substrings.
343+func containsAny(s string, substrs ...string) bool {
344+ sLower := strings.ToLower(s)
345+ for _, substr := range substrs {
346+ if strings.Contains(sLower, strings.ToLower(substr)) {
347+ return true
348+ }
349+ }
350+ return false
351+}
352+
353+// logsClearCmd clears old session logs.
354+var logsClearCmd = &cobra.Command{
355+ Use: "clear",
356+ Short: "Clear old log files",
357+ Long: `Remove old session log files.
358+
359+By default, removes sessions older than the configured MaxAge (default 7 days).
360+Use --all to remove all sessions.`,
361+ RunE: runLogsClear,
362+}
363+
364+func init() {
365+ logsCmd.AddCommand(logsClearCmd)
366+ logsClearCmd.Flags().BoolVar(&logsClearAll, "all", false, "remove all session logs")
367+}
368+
369+func runLogsClear(cmd *cobra.Command, args []string) error {
370+ app := GetApp()
371+ if app == nil {
372+ return fmt.Errorf("app not initialized")
373+ }
374+
375+ logDir := getLogDir()
376+ sessions, err := getSessions(logDir)
377+ if err != nil {
378+ return err
379+ }
380+
381+ if len(sessions) == 0 {
382+ fmt.Println(app.Theme.Subtle.Render("No logs to clear"))
383+ return nil
384+ }
385+
386+ // Get MaxAge from config (default 7 days)
387+ maxAge := 7
388+ if app.Config != nil && app.Config.Logging.MaxAge > 0 {
389+ maxAge = app.Config.Logging.MaxAge
390+ }
391+
392+ cutoff := time.Now().AddDate(0, 0, -maxAge)
393+ var removed int
394+
395+ for _, s := range sessions {
396+ shouldRemove := logsClearAll || s.ModTime.Before(cutoff)
397+ if !shouldRemove {
398+ continue
399+ }
400+
401+ if err := os.Remove(s.Path); err != nil {
402+ fmt.Printf("%s %s: %v\n",
403+ app.Theme.ErrorStyle.Render("\u2717"),
404+ s.ShortID,
405+ err,
406+ )
407+ continue
408+ }
409+
410+ fmt.Printf("%s %s (%s)\n",
411+ app.Theme.SuccessStyle.Render("\u2713"),
412+ s.ShortID,
413+ formatSize(s.Size),
414+ )
415+ removed++
416+ }
417+
418+ if removed == 0 {
419+ fmt.Println(app.Theme.Subtle.Render("No sessions older than " + fmt.Sprintf("%d days", maxAge)))
420+ } else {
421+ fmt.Printf("\n%s\n", app.Theme.SuccessStyle.Render(fmt.Sprintf("Cleared %d session(s)", removed)))
422+ }
423+
424+ return nil
425+}
A internal/cli/cmd/purge.go +160 -0
@@ -0,0 +1,160 @@
1+package cmd
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+var (
14+ purgeForce bool
15+ purgeAll bool
16+)
17+
18+var purgeCmd = &cobra.Command{
19+ Use: "purge",
20+ Short: "Remove all dumber data and configuration",
21+ Long: `Remove all dumber XDG directories (config, data, state, cache).
22+
23+This will delete:
24+ - ~/.config/dumber/ (configuration)
25+ - ~/.local/share/dumber/ (data, database)
26+ - ~/.local/state/dumber/ (state, logs)
27+ - ~/.cache/dumber/ (cache)
28+
29+Use --force to skip confirmation prompt.`,
30+ RunE: runPurge,
31+}
32+
33+func init() {
34+ rootCmd.AddCommand(purgeCmd)
35+ purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "skip confirmation prompt")
36+ purgeCmd.Flags().BoolVar(&purgeAll, "all", true, "remove all directories (default)")
37+}
38+
39+func runPurge(cmd *cobra.Command, args []string) error {
40+ home, err := os.UserHomeDir()
41+ if err != nil {
42+ return fmt.Errorf("get home dir: %w", err)
43+ }
44+
45+ // Build list of directories to remove
46+ dirs := []struct {
47+ path string
48+ desc string
49+ }{
50+ {getXDGPath("XDG_CONFIG_HOME", home, ".config", "dumber"), "config"},
51+ {getXDGPath("XDG_DATA_HOME", home, ".local/share", "dumber"), "data"},
52+ {getXDGPath("XDG_STATE_HOME", home, ".local/state", "dumber"), "state"},
53+ {getXDGPath("XDG_CACHE_HOME", home, ".cache", "dumber"), "cache"},
54+ }
55+
56+ // Check which directories exist
57+ var existing []struct {
58+ path string
59+ desc string
60+ size int64
61+ }
62+
63+ for _, d := range dirs {
64+ if info, err := os.Stat(d.path); err == nil && info.IsDir() {
65+ size := getDirSize(d.path)
66+ existing = append(existing, struct {
67+ path string
68+ desc string
69+ size int64
70+ }{d.path, d.desc, size})
71+ }
72+ }
73+
74+ if len(existing) == 0 {
75+ fmt.Println("No dumber directories found.")
76+ return nil
77+ }
78+
79+ // Show what will be deleted
80+ fmt.Println("The following directories will be removed:")
81+ fmt.Println()
82+ for _, d := range existing {
83+ fmt.Printf(" \u2022 %s (%s, %s)\n", d.path, d.desc, formatSize(d.size))
84+ }
85+ fmt.Println()
86+
87+ // Confirm unless --force
88+ if !purgeForce {
89+ fmt.Print("Are you sure? [y/N]: ")
90+ reader := bufio.NewReader(os.Stdin)
91+ response, _ := reader.ReadString('\n')
92+ response = strings.TrimSpace(strings.ToLower(response))
93+
94+ if response != "y" && response != "yes" {
95+ fmt.Println("Aborted.")
96+ return nil
97+ }
98+ }
99+
100+ // Remove directories
101+ var errors []string
102+ for _, d := range existing {
103+ if err := os.RemoveAll(d.path); err != nil {
104+ errors = append(errors, fmt.Sprintf("%s: %v", d.path, err))
105+ } else {
106+ fmt.Printf("\u2713 Removed %s\n", d.path)
107+ }
108+ }
109+
110+ if len(errors) > 0 {
111+ fmt.Println()
112+ fmt.Println("Errors:")
113+ for _, e := range errors {
114+ fmt.Printf(" \u2717 %s\n", e)
115+ }
116+ return fmt.Errorf("failed to remove some directories")
117+ }
118+
119+ fmt.Println()
120+ fmt.Println("Purge complete.")
121+ return nil
122+}
123+
124+// getXDGPath returns the XDG path for dumber.
125+func getXDGPath(envVar, home, defaultSuffix, appName string) string {
126+ base := os.Getenv(envVar)
127+ if base == "" {
128+ base = filepath.Join(home, defaultSuffix)
129+ }
130+ return filepath.Join(base, appName)
131+}
132+
133+// getDirSize calculates the total size of a directory.
134+func getDirSize(path string) int64 {
135+ var size int64
136+ _ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
137+ if err != nil {
138+ return nil
139+ }
140+ if !info.IsDir() {
141+ size += info.Size()
142+ }
143+ return nil
144+ })
145+ return size
146+}
147+
148+// formatSize formats bytes as human-readable size.
149+func formatSize(bytes int64) string {
150+ const unit = 1024
151+ if bytes < unit {
152+ return fmt.Sprintf("%d B", bytes)
153+ }
154+ div, exp := int64(unit), 0
155+ for n := bytes / unit; n >= unit; n /= unit {
156+ div *= unit
157+ exp++
158+ }
159+ return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
160+}
@@ -0,0 +1,160 @@
1+package cmd
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+var (
14+ purgeForce bool
15+ purgeAll bool
16+)
17+
18+var purgeCmd = &cobra.Command{
19+ Use: "purge",
20+ Short: "Remove all dumber data and configuration",
21+ Long: `Remove all dumber XDG directories (config, data, state, cache).
22+
23+This will delete:
24+ - ~/.config/dumber/ (configuration)
25+ - ~/.local/share/dumber/ (data, database)
26+ - ~/.local/state/dumber/ (state, logs)
27+ - ~/.cache/dumber/ (cache)
28+
29+Use --force to skip confirmation prompt.`,
30+ RunE: runPurge,
31+}
32+
33+func init() {
34+ rootCmd.AddCommand(purgeCmd)
35+ purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "skip confirmation prompt")
36+ purgeCmd.Flags().BoolVar(&purgeAll, "all", true, "remove all directories (default)")
37+}
38+
39+func runPurge(cmd *cobra.Command, args []string) error {
40+ home, err := os.UserHomeDir()
41+ if err != nil {
42+ return fmt.Errorf("get home dir: %w", err)
43+ }
44+
45+ // Build list of directories to remove
46+ dirs := []struct {
47+ path string
48+ desc string
49+ }{
50+ {getXDGPath("XDG_CONFIG_HOME", home, ".config", "dumber"), "config"},
51+ {getXDGPath("XDG_DATA_HOME", home, ".local/share", "dumber"), "data"},
52+ {getXDGPath("XDG_STATE_HOME", home, ".local/state", "dumber"), "state"},
53+ {getXDGPath("XDG_CACHE_HOME", home, ".cache", "dumber"), "cache"},
54+ }
55+
56+ // Check which directories exist
57+ var existing []struct {
58+ path string
59+ desc string
60+ size int64
61+ }
62+
63+ for _, d := range dirs {
64+ if info, err := os.Stat(d.path); err == nil && info.IsDir() {
65+ size := getDirSize(d.path)
66+ existing = append(existing, struct {
67+ path string
68+ desc string
69+ size int64
70+ }{d.path, d.desc, size})
71+ }
72+ }
73+
74+ if len(existing) == 0 {
75+ fmt.Println("No dumber directories found.")
76+ return nil
77+ }
78+
79+ // Show what will be deleted
80+ fmt.Println("The following directories will be removed:")
81+ fmt.Println()
82+ for _, d := range existing {
83+ fmt.Printf(" \u2022 %s (%s, %s)\n", d.path, d.desc, formatSize(d.size))
84+ }
85+ fmt.Println()
86+
87+ // Confirm unless --force
88+ if !purgeForce {
89+ fmt.Print("Are you sure? [y/N]: ")
90+ reader := bufio.NewReader(os.Stdin)
91+ response, _ := reader.ReadString('\n')
92+ response = strings.TrimSpace(strings.ToLower(response))
93+
94+ if response != "y" && response != "yes" {
95+ fmt.Println("Aborted.")
96+ return nil
97+ }
98+ }
99+
100+ // Remove directories
101+ var errors []string
102+ for _, d := range existing {
103+ if err := os.RemoveAll(d.path); err != nil {
104+ errors = append(errors, fmt.Sprintf("%s: %v", d.path, err))
105+ } else {
106+ fmt.Printf("\u2713 Removed %s\n", d.path)
107+ }
108+ }
109+
110+ if len(errors) > 0 {
111+ fmt.Println()
112+ fmt.Println("Errors:")
113+ for _, e := range errors {
114+ fmt.Printf(" \u2717 %s\n", e)
115+ }
116+ return fmt.Errorf("failed to remove some directories")
117+ }
118+
119+ fmt.Println()
120+ fmt.Println("Purge complete.")
121+ return nil
122+}
123+
124+// getXDGPath returns the XDG path for dumber.
125+func getXDGPath(envVar, home, defaultSuffix, appName string) string {
126+ base := os.Getenv(envVar)
127+ if base == "" {
128+ base = filepath.Join(home, defaultSuffix)
129+ }
130+ return filepath.Join(base, appName)
131+}
132+
133+// getDirSize calculates the total size of a directory.
134+func getDirSize(path string) int64 {
135+ var size int64
136+ _ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
137+ if err != nil {
138+ return nil
139+ }
140+ if !info.IsDir() {
141+ size += info.Size()
142+ }
143+ return nil
144+ })
145+ return size
146+}
147+
148+// formatSize formats bytes as human-readable size.
149+func formatSize(bytes int64) string {
150+ const unit = 1024
151+ if bytes < unit {
152+ return fmt.Sprintf("%d B", bytes)
153+ }
154+ div, exp := int64(unit), 0
155+ for n := bytes / unit; n >= unit; n /= unit {
156+ div *= unit
157+ exp++
158+ }
159+ return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
160+}
A internal/cli/cmd/root.go +71 -0
@@ -0,0 +1,71 @@
1+// Package cmd provides Cobra CLI commands for dumber.
2+package cmd
3+
4+import (
5+ "fmt"
6+ "os"
7+
8+ "github.com/spf13/cobra"
9+
10+ "github.com/bnema/dumber/internal/cli"
11+)
12+
13+var (
14+ app *cli.App
15+ rootCmd = &cobra.Command{
16+ Use: "dumber",
17+ Short: "A minimal web browser",
18+ Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`,
19+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
20+ // Skip initialization for help commands
21+ if cmd.Name() == "help" || cmd.Name() == "completion" {
22+ return nil
23+ }
24+
25+ var err error
26+ app, err = cli.NewApp()
27+ if err != nil {
28+ return fmt.Errorf("initialize app: %w", err)
29+ }
30+ return nil
31+ },
32+ PersistentPostRun: func(cmd *cobra.Command, args []string) {
33+ if app != nil {
34+ _ = app.Close()
35+ }
36+ },
37+ }
38+)
39+
40+// Execute runs the root command.
41+func Execute() {
42+ if err := rootCmd.Execute(); err != nil {
43+ fmt.Fprintln(os.Stderr, err)
44+ os.Exit(1)
45+ }
46+}
47+
48+// GetApp returns the initialized app (for use by subcommands).
49+func GetApp() *cli.App {
50+ return app
51+}
52+
53+// browseCmd is a placeholder for help - actual execution is in main.go
54+var browseCmd = &cobra.Command{
55+ Use: "browse [url]",
56+ Short: "Launch the graphical browser",
57+ Long: `Launch the GTK4 graphical browser.
58+
59+If a URL is provided, navigate to it. Otherwise, open the homepage.
60+
61+Examples:
62+ dumber browse # Open browser to homepage
63+ dumber browse example.com # Open browser to URL`,
64+ Run: func(cmd *cobra.Command, args []string) {
65+ // This is handled by main.go before cobra runs
66+ },
67+}
68+
69+func init() {
70+ rootCmd.AddCommand(browseCmd)
71+}
@@ -0,0 +1,71 @@
1+// Package cmd provides Cobra CLI commands for dumber.
2+package cmd
3+
4+import (
5+ "fmt"
6+ "os"
7+
8+ "github.com/spf13/cobra"
9+
10+ "github.com/bnema/dumber/internal/cli"
11+)
12+
13+var (
14+ app *cli.App
15+ rootCmd = &cobra.Command{
16+ Use: "dumber",
17+ Short: "A minimal web browser",
18+ Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`,
19+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
20+ // Skip initialization for help commands
21+ if cmd.Name() == "help" || cmd.Name() == "completion" {
22+ return nil
23+ }
24+
25+ var err error
26+ app, err = cli.NewApp()
27+ if err != nil {
28+ return fmt.Errorf("initialize app: %w", err)
29+ }
30+ return nil
31+ },
32+ PersistentPostRun: func(cmd *cobra.Command, args []string) {
33+ if app != nil {
34+ _ = app.Close()
35+ }
36+ },
37+ }
38+)
39+
40+// Execute runs the root command.
41+func Execute() {
42+ if err := rootCmd.Execute(); err != nil {
43+ fmt.Fprintln(os.Stderr, err)
44+ os.Exit(1)
45+ }
46+}
47+
48+// GetApp returns the initialized app (for use by subcommands).
49+func GetApp() *cli.App {
50+ return app
51+}
52+
53+// browseCmd is a placeholder for help - actual execution is in main.go
54+var browseCmd = &cobra.Command{
55+ Use: "browse [url]",
56+ Short: "Launch the graphical browser",
57+ Long: `Launch the GTK4 graphical browser.
58+
59+If a URL is provided, navigate to it. Otherwise, open the homepage.
60+
61+Examples:
62+ dumber browse # Open browser to homepage
63+ dumber browse example.com # Open browser to URL`,
64+ Run: func(cmd *cobra.Command, args []string) {
65+ // This is handled by main.go before cobra runs
66+ },
67+}
68+
69+func init() {
70+ rootCmd.AddCommand(browseCmd)
71+}
A internal/cli/model/cleanup.go +153 -0
@@ -0,0 +1,153 @@
1+package model
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/bnema/dumber/internal/cli/styles"
12+)
13+
14+// CleanupModel wraps styles.CleanupModel for standalone use.
15+type CleanupModel struct {
16+ cleanup styles.CleanupModel
17+ cleaned bool
18+ cleaning bool
19+ err error
20+
21+ historyUC *usecase.SearchHistoryUseCase
22+ theme *styles.Theme
23+}
24+
25+// NewCleanupModel creates a new cleanup command model.
26+func NewCleanupModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) CleanupModel {
27+ return CleanupModel{
28+ cleanup: styles.NewCleanup(theme),
29+ historyUC: historyUC,
30+ theme: theme,
31+ }
32+}
33+
34+// cleanupCompleteMsg is sent when cleanup is done.
35+type cleanupCompleteMsg struct {
36+ err error
37+}
38+
39+// Init implements tea.Model.
40+func (m CleanupModel) Init() tea.Cmd {
41+ return nil
42+}
43+
44+// Update implements tea.Model.
45+func (m CleanupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
46+ switch msg := msg.(type) {
47+ case cleanupCompleteMsg:
48+ m.cleaning = false
49+ if msg.err != nil {
50+ m.err = msg.err
51+ } else {
52+ m.cleaned = true
53+ }
54+ return m, nil
55+
56+ case tea.KeyMsg:
57+ // If cleaned, any key exits
58+ if m.cleaned || m.err != nil {
59+ return m, tea.Quit
60+ }
61+
62+ // If cleaning, ignore input
63+ if m.cleaning {
64+ return m, nil
65+ }
66+ }
67+
68+ // Update inner cleanup model
69+ cleanup, cmd := m.cleanup.Update(msg)
70+ m.cleanup = cleanup
71+
72+ if m.cleanup.Done() {
73+ if m.cleanup.Cancelled {
74+ return m, tea.Quit
75+ }
76+ if m.cleanup.Confirmed {
77+ m.cleaning = true
78+ return m, m.performCleanup(m.cleanup.SelectedRange())
79+ }
80+ }
81+
82+ return m, cmd
83+}
84+
85+// performCleanup executes the cleanup operation.
86+func (m CleanupModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd {
87+ return func() tea.Msg {
88+ ctx := context.Background()
89+ cutoff := cleanupRange.CutoffTime()
90+
91+ var err error
92+ if cutoff.IsZero() {
93+ err = m.historyUC.ClearAll(ctx)
94+ } else {
95+ err = m.historyUC.ClearOlderThan(ctx, cutoff)
96+ }
97+
98+ return cleanupCompleteMsg{err: err}
99+ }
100+}
101+
102+// View implements tea.Model.
103+func (m CleanupModel) View() string {
104+ t := m.theme
105+
106+ if m.cleaning {
107+ spinner := styles.NewLoading(t, "Cleaning up...")
108+ return t.Box.Render(spinner.View())
109+ }
110+
111+ if m.err != nil {
112+ content := lipgloss.JoinVertical(
113+ lipgloss.Left,
114+ t.ErrorStyle.Render("Error: "+m.err.Error()),
115+ "",
116+ t.Subtle.Render("Press any key to exit"),
117+ )
118+ return t.Box.Render(content)
119+ }
120+
121+ if m.cleaned {
122+ content := lipgloss.JoinVertical(
123+ lipgloss.Left,
124+ t.SuccessStyle.Render("History cleaned successfully!"),
125+ "",
126+ t.Subtle.Render("Press any key to exit"),
127+ )
128+ return t.Box.Render(content)
129+ }
130+
131+ return m.cleanup.View()
132+}
133+
134+// Ensure interface compliance.
135+var _ tea.Model = (*CleanupModel)(nil)
136+
137+// rangeLabelFor returns a human-readable label for cleanup range.
138+func rangeLabelFor(r styles.CleanupRange) string {
139+ switch r {
140+ case styles.CleanupRangeLastHour:
141+ return "last hour"
142+ case styles.CleanupRangeLast24Hours:
143+ return "last 24 hours"
144+ case styles.CleanupRangeLast7Days:
145+ return "last 7 days"
146+ case styles.CleanupRangeLast30Days:
147+ return "last 30 days"
148+ case styles.CleanupRangeAll:
149+ return "all time"
150+ default:
151+ return fmt.Sprintf("range %d", r)
152+ }
153+}
@@ -0,0 +1,153 @@
1+package model
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/bnema/dumber/internal/cli/styles"
12+)
13+
14+// CleanupModel wraps styles.CleanupModel for standalone use.
15+type CleanupModel struct {
16+ cleanup styles.CleanupModel
17+ cleaned bool
18+ cleaning bool
19+ err error
20+
21+ historyUC *usecase.SearchHistoryUseCase
22+ theme *styles.Theme
23+}
24+
25+// NewCleanupModel creates a new cleanup command model.
26+func NewCleanupModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) CleanupModel {
27+ return CleanupModel{
28+ cleanup: styles.NewCleanup(theme),
29+ historyUC: historyUC,
30+ theme: theme,
31+ }
32+}
33+
34+// cleanupCompleteMsg is sent when cleanup is done.
35+type cleanupCompleteMsg struct {
36+ err error
37+}
38+
39+// Init implements tea.Model.
40+func (m CleanupModel) Init() tea.Cmd {
41+ return nil
42+}
43+
44+// Update implements tea.Model.
45+func (m CleanupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
46+ switch msg := msg.(type) {
47+ case cleanupCompleteMsg:
48+ m.cleaning = false
49+ if msg.err != nil {
50+ m.err = msg.err
51+ } else {
52+ m.cleaned = true
53+ }
54+ return m, nil
55+
56+ case tea.KeyMsg:
57+ // If cleaned, any key exits
58+ if m.cleaned || m.err != nil {
59+ return m, tea.Quit
60+ }
61+
62+ // If cleaning, ignore input
63+ if m.cleaning {
64+ return m, nil
65+ }
66+ }
67+
68+ // Update inner cleanup model
69+ cleanup, cmd := m.cleanup.Update(msg)
70+ m.cleanup = cleanup
71+
72+ if m.cleanup.Done() {
73+ if m.cleanup.Cancelled {
74+ return m, tea.Quit
75+ }
76+ if m.cleanup.Confirmed {
77+ m.cleaning = true
78+ return m, m.performCleanup(m.cleanup.SelectedRange())
79+ }
80+ }
81+
82+ return m, cmd
83+}
84+
85+// performCleanup executes the cleanup operation.
86+func (m CleanupModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd {
87+ return func() tea.Msg {
88+ ctx := context.Background()
89+ cutoff := cleanupRange.CutoffTime()
90+
91+ var err error
92+ if cutoff.IsZero() {
93+ err = m.historyUC.ClearAll(ctx)
94+ } else {
95+ err = m.historyUC.ClearOlderThan(ctx, cutoff)
96+ }
97+
98+ return cleanupCompleteMsg{err: err}
99+ }
100+}
101+
102+// View implements tea.Model.
103+func (m CleanupModel) View() string {
104+ t := m.theme
105+
106+ if m.cleaning {
107+ spinner := styles.NewLoading(t, "Cleaning up...")
108+ return t.Box.Render(spinner.View())
109+ }
110+
111+ if m.err != nil {
112+ content := lipgloss.JoinVertical(
113+ lipgloss.Left,
114+ t.ErrorStyle.Render("Error: "+m.err.Error()),
115+ "",
116+ t.Subtle.Render("Press any key to exit"),
117+ )
118+ return t.Box.Render(content)
119+ }
120+
121+ if m.cleaned {
122+ content := lipgloss.JoinVertical(
123+ lipgloss.Left,
124+ t.SuccessStyle.Render("History cleaned successfully!"),
125+ "",
126+ t.Subtle.Render("Press any key to exit"),
127+ )
128+ return t.Box.Render(content)
129+ }
130+
131+ return m.cleanup.View()
132+}
133+
134+// Ensure interface compliance.
135+var _ tea.Model = (*CleanupModel)(nil)
136+
137+// rangeLabelFor returns a human-readable label for cleanup range.
138+func rangeLabelFor(r styles.CleanupRange) string {
139+ switch r {
140+ case styles.CleanupRangeLastHour:
141+ return "last hour"
142+ case styles.CleanupRangeLast24Hours:
143+ return "last 24 hours"
144+ case styles.CleanupRangeLast7Days:
145+ return "last 7 days"
146+ case styles.CleanupRangeLast30Days:
147+ return "last 30 days"
148+ case styles.CleanupRangeAll:
149+ return "all time"
150+ default:
151+ return fmt.Sprintf("range %d", r)
152+ }
153+}
A internal/cli/model/dmenu.go +241 -0
@@ -0,0 +1,241 @@
1+package model
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/bubbles/help"
7+ "github.com/charmbracelet/bubbles/key"
8+ "github.com/charmbracelet/bubbles/list"
9+ "github.com/charmbracelet/bubbles/textinput"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/cli/styles"
15+)
16+
17+// DmenuModel is the Bubble Tea model for interactive dmenu.
18+type DmenuModel struct {
19+ // UI components
20+ list list.Model
21+ search textinput.Model
22+ help help.Model
23+ keys styles.DmenuKeyMap
24+
25+ // State
26+ allItems []styles.HistoryItem
27+ selected string // Selected URL
28+ searchQuery string
29+ width int
30+ height int
31+ err error
32+
33+ // Dependencies
34+ historyUC *usecase.SearchHistoryUseCase
35+ theme *styles.Theme
36+}
37+
38+// NewDmenuModel creates a new dmenu model.
39+func NewDmenuModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) DmenuModel {
40+ // Create search input
41+ search := styles.NewSearchInput(theme)
42+ search.Focus()
43+
44+ // Create help
45+ helpModel := styles.NewStyledHelp(theme)
46+
47+ // Create keybindings
48+ keys := styles.DefaultDmenuKeyMap()
49+
50+ return DmenuModel{
51+ search: search,
52+ help: helpModel,
53+ keys: keys,
54+ historyUC: historyUC,
55+ theme: theme,
56+ width: 80,
57+ height: 24,
58+ }
59+}
60+
61+// dmenuLoadedMsg is sent when entries are loaded.
62+type dmenuLoadedMsg struct {
63+ items []styles.HistoryItem
64+ err error
65+}
66+
67+// Init implements tea.Model.
68+func (m DmenuModel) Init() tea.Cmd {
69+ return tea.Batch(
70+ textinput.Blink,
71+ m.loadEntries,
72+ )
73+}
74+
75+// loadEntries loads history entries.
76+func (m DmenuModel) loadEntries() tea.Msg {
77+ ctx := context.Background()
78+
79+ entries, err := m.historyUC.GetRecent(ctx, 500, 0)
80+ if err != nil {
81+ return dmenuLoadedMsg{err: err}
82+ }
83+
84+ items := make([]styles.HistoryItem, len(entries))
85+ for i, e := range entries {
86+ items[i] = styles.HistoryItem{
87+ ID: e.ID,
88+ URL: e.URL,
89+ Title: e.Title,
90+ Domain: getDomain(e.URL),
91+ VisitCount: int(e.VisitCount),
92+ LastVisited: e.LastVisited,
93+ }
94+ }
95+
96+ return dmenuLoadedMsg{items: items}
97+}
98+
99+// Update implements tea.Model.
100+func (m DmenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101+ var cmds []tea.Cmd
102+
103+ switch msg := msg.(type) {
104+ case tea.WindowSizeMsg:
105+ m.width = msg.Width
106+ m.height = msg.Height
107+ m.help.Width = msg.Width
108+ m.updateList()
109+
110+ case tea.KeyMsg:
111+ switch {
112+ case key.Matches(msg, m.keys.Cancel):
113+ return m, tea.Quit
114+
115+ case key.Matches(msg, m.keys.Open):
116+ // Select current item and quit
117+ if item := m.list.SelectedItem(); item != nil {
118+ if hi, ok := item.(styles.HistoryItem); ok {
119+ m.selected = hi.URL
120+ }
121+ }
122+ return m, tea.Quit
123+
124+ case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down):
125+ // Pass to list
126+ var cmd tea.Cmd
127+ m.list, cmd = m.list.Update(msg)
128+ cmds = append(cmds, cmd)
129+
130+ default:
131+ // Update search input
132+ var cmd tea.Cmd
133+ m.search, cmd = m.search.Update(msg)
134+ cmds = append(cmds, cmd)
135+
136+ // Filter list based on search
137+ if m.search.Value() != m.searchQuery {
138+ m.searchQuery = m.search.Value()
139+ m.updateList()
140+ }
141+ }
142+
143+ case dmenuLoadedMsg:
144+ if msg.err != nil {
145+ m.err = msg.err
146+ } else {
147+ m.allItems = msg.items
148+ m.updateList()
149+ }
150+ }
151+
152+ return m, tea.Batch(cmds...)
153+}
154+
155+// updateList updates the list with filtered items.
156+func (m *DmenuModel) updateList() {
157+ items := m.allItems
158+
159+ // Apply search filter
160+ if m.searchQuery != "" {
161+ query := m.searchQuery
162+ filtered := make([]styles.HistoryItem, 0)
163+ for _, item := range items {
164+ // Simple case-insensitive contains matching
165+ if containsIgnoreCase(item.Title, query) || containsIgnoreCase(item.URL, query) {
166+ filtered = append(filtered, item)
167+ }
168+ }
169+ items = filtered
170+ }
171+
172+ // Create list
173+ listHeight := m.height - 6 // Account for search, help
174+ if listHeight < 5 {
175+ listHeight = 5
176+ }
177+
178+ m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight)
179+}
180+
181+// containsIgnoreCase checks if s contains substr (case-insensitive).
182+func containsIgnoreCase(s, substr string) bool {
183+ sLower := toLower(s)
184+ substrLower := toLower(substr)
185+
186+ for i := 0; i <= len(sLower)-len(substrLower); i++ {
187+ if sLower[i:i+len(substrLower)] == substrLower {
188+ return true
189+ }
190+ }
191+ return false
192+}
193+
194+// toLower converts string to lowercase without importing strings.
195+func toLower(s string) string {
196+ b := make([]byte, len(s))
197+ for i := 0; i < len(s); i++ {
198+ c := s[i]
199+ if c >= 'A' && c <= 'Z' {
200+ c += 'a' - 'A'
201+ }
202+ b[i] = c
203+ }
204+ return string(b)
205+}
206+
207+// View implements tea.Model.
208+func (m DmenuModel) View() string {
209+ t := m.theme
210+
211+ // Search bar
212+ searchBar := t.InputFocused.Render(m.search.View())
213+
214+ // List
215+ listView := m.list.View()
216+
217+ if m.err != nil {
218+ listView = t.ErrorStyle.Render("Error: " + m.err.Error())
219+ }
220+
221+ // Help
222+ helpView := m.help.View(m.keys)
223+
224+ // Combine
225+ return lipgloss.JoinVertical(
226+ lipgloss.Left,
227+ searchBar,
228+ "",
229+ listView,
230+ "",
231+ helpView,
232+ )
233+}
234+
235+// SelectedURL returns the URL selected by the user.
236+func (m DmenuModel) SelectedURL() string {
237+ return m.selected
238+}
239+
240+// Ensure interface compliance.
241+var _ tea.Model = (*DmenuModel)(nil)
@@ -0,0 +1,241 @@
1+package model
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/bubbles/help"
7+ "github.com/charmbracelet/bubbles/key"
8+ "github.com/charmbracelet/bubbles/list"
9+ "github.com/charmbracelet/bubbles/textinput"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/cli/styles"
15+)
16+
17+// DmenuModel is the Bubble Tea model for interactive dmenu.
18+type DmenuModel struct {
19+ // UI components
20+ list list.Model
21+ search textinput.Model
22+ help help.Model
23+ keys styles.DmenuKeyMap
24+
25+ // State
26+ allItems []styles.HistoryItem
27+ selected string // Selected URL
28+ searchQuery string
29+ width int
30+ height int
31+ err error
32+
33+ // Dependencies
34+ historyUC *usecase.SearchHistoryUseCase
35+ theme *styles.Theme
36+}
37+
38+// NewDmenuModel creates a new dmenu model.
39+func NewDmenuModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) DmenuModel {
40+ // Create search input
41+ search := styles.NewSearchInput(theme)
42+ search.Focus()
43+
44+ // Create help
45+ helpModel := styles.NewStyledHelp(theme)
46+
47+ // Create keybindings
48+ keys := styles.DefaultDmenuKeyMap()
49+
50+ return DmenuModel{
51+ search: search,
52+ help: helpModel,
53+ keys: keys,
54+ historyUC: historyUC,
55+ theme: theme,
56+ width: 80,
57+ height: 24,
58+ }
59+}
60+
61+// dmenuLoadedMsg is sent when entries are loaded.
62+type dmenuLoadedMsg struct {
63+ items []styles.HistoryItem
64+ err error
65+}
66+
67+// Init implements tea.Model.
68+func (m DmenuModel) Init() tea.Cmd {
69+ return tea.Batch(
70+ textinput.Blink,
71+ m.loadEntries,
72+ )
73+}
74+
75+// loadEntries loads history entries.
76+func (m DmenuModel) loadEntries() tea.Msg {
77+ ctx := context.Background()
78+
79+ entries, err := m.historyUC.GetRecent(ctx, 500, 0)
80+ if err != nil {
81+ return dmenuLoadedMsg{err: err}
82+ }
83+
84+ items := make([]styles.HistoryItem, len(entries))
85+ for i, e := range entries {
86+ items[i] = styles.HistoryItem{
87+ ID: e.ID,
88+ URL: e.URL,
89+ Title: e.Title,
90+ Domain: getDomain(e.URL),
91+ VisitCount: int(e.VisitCount),
92+ LastVisited: e.LastVisited,
93+ }
94+ }
95+
96+ return dmenuLoadedMsg{items: items}
97+}
98+
99+// Update implements tea.Model.
100+func (m DmenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101+ var cmds []tea.Cmd
102+
103+ switch msg := msg.(type) {
104+ case tea.WindowSizeMsg:
105+ m.width = msg.Width
106+ m.height = msg.Height
107+ m.help.Width = msg.Width
108+ m.updateList()
109+
110+ case tea.KeyMsg:
111+ switch {
112+ case key.Matches(msg, m.keys.Cancel):
113+ return m, tea.Quit
114+
115+ case key.Matches(msg, m.keys.Open):
116+ // Select current item and quit
117+ if item := m.list.SelectedItem(); item != nil {
118+ if hi, ok := item.(styles.HistoryItem); ok {
119+ m.selected = hi.URL
120+ }
121+ }
122+ return m, tea.Quit
123+
124+ case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down):
125+ // Pass to list
126+ var cmd tea.Cmd
127+ m.list, cmd = m.list.Update(msg)
128+ cmds = append(cmds, cmd)
129+
130+ default:
131+ // Update search input
132+ var cmd tea.Cmd
133+ m.search, cmd = m.search.Update(msg)
134+ cmds = append(cmds, cmd)
135+
136+ // Filter list based on search
137+ if m.search.Value() != m.searchQuery {
138+ m.searchQuery = m.search.Value()
139+ m.updateList()
140+ }
141+ }
142+
143+ case dmenuLoadedMsg:
144+ if msg.err != nil {
145+ m.err = msg.err
146+ } else {
147+ m.allItems = msg.items
148+ m.updateList()
149+ }
150+ }
151+
152+ return m, tea.Batch(cmds...)
153+}
154+
155+// updateList updates the list with filtered items.
156+func (m *DmenuModel) updateList() {
157+ items := m.allItems
158+
159+ // Apply search filter
160+ if m.searchQuery != "" {
161+ query := m.searchQuery
162+ filtered := make([]styles.HistoryItem, 0)
163+ for _, item := range items {
164+ // Simple case-insensitive contains matching
165+ if containsIgnoreCase(item.Title, query) || containsIgnoreCase(item.URL, query) {
166+ filtered = append(filtered, item)
167+ }
168+ }
169+ items = filtered
170+ }
171+
172+ // Create list
173+ listHeight := m.height - 6 // Account for search, help
174+ if listHeight < 5 {
175+ listHeight = 5
176+ }
177+
178+ m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight)
179+}
180+
181+// containsIgnoreCase checks if s contains substr (case-insensitive).
182+func containsIgnoreCase(s, substr string) bool {
183+ sLower := toLower(s)
184+ substrLower := toLower(substr)
185+
186+ for i := 0; i <= len(sLower)-len(substrLower); i++ {
187+ if sLower[i:i+len(substrLower)] == substrLower {
188+ return true
189+ }
190+ }
191+ return false
192+}
193+
194+// toLower converts string to lowercase without importing strings.
195+func toLower(s string) string {
196+ b := make([]byte, len(s))
197+ for i := 0; i < len(s); i++ {
198+ c := s[i]
199+ if c >= 'A' && c <= 'Z' {
200+ c += 'a' - 'A'
201+ }
202+ b[i] = c
203+ }
204+ return string(b)
205+}
206+
207+// View implements tea.Model.
208+func (m DmenuModel) View() string {
209+ t := m.theme
210+
211+ // Search bar
212+ searchBar := t.InputFocused.Render(m.search.View())
213+
214+ // List
215+ listView := m.list.View()
216+
217+ if m.err != nil {
218+ listView = t.ErrorStyle.Render("Error: " + m.err.Error())
219+ }
220+
221+ // Help
222+ helpView := m.help.View(m.keys)
223+
224+ // Combine
225+ return lipgloss.JoinVertical(
226+ lipgloss.Left,
227+ searchBar,
228+ "",
229+ listView,
230+ "",
231+ helpView,
232+ )
233+}
234+
235+// SelectedURL returns the URL selected by the user.
236+func (m DmenuModel) SelectedURL() string {
237+ return m.selected
238+}
239+
240+// Ensure interface compliance.
241+var _ tea.Model = (*DmenuModel)(nil)
A internal/cli/model/history.go +635 -0
@@ -0,0 +1,635 @@
1+// Package model provides Bubble Tea models for CLI commands.
2+package model
3+
4+import (
5+ "context"
6+ "net/url"
7+ "os/exec"
8+ "time"
9+
10+ "github.com/charmbracelet/bubbles/help"
11+ "github.com/charmbracelet/bubbles/key"
12+ "github.com/charmbracelet/bubbles/list"
13+ "github.com/charmbracelet/bubbles/textinput"
14+ tea "github.com/charmbracelet/bubbletea"
15+ "github.com/charmbracelet/lipgloss"
16+
17+ "github.com/bnema/dumber/internal/application/usecase"
18+ "github.com/bnema/dumber/internal/cli/styles"
19+ "github.com/bnema/dumber/internal/domain/entity"
20+ "github.com/bnema/dumber/internal/logging"
21+)
22+
23+// HistoryModel is the Bubble Tea model for interactive history browser.
24+type HistoryModel struct {
25+ // UI components
26+ tabs styles.TabsModel
27+ list list.Model
28+ search textinput.Model
29+ help help.Model
30+ keys styles.HistoryKeyMap
31+ cleanup *styles.CleanupModel
32+ confirm *styles.ConfirmModel
33+
34+ // State
35+ entries [][]*entity.HistoryEntry // Entries grouped by tab (Today/Yesterday/Week/Older)
36+ topDomains []*entity.DomainStat
37+ domainFilter string
38+ searchMode bool
39+ showHelp bool
40+ width int
41+ height int
42+ err error
43+
44+ // Dependencies
45+ ctx context.Context
46+ historyUC *usecase.SearchHistoryUseCase
47+ theme *styles.Theme
48+}
49+
50+// NewHistoryModel creates a new history browser model.
51+func NewHistoryModel(ctx context.Context, theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) HistoryModel {
52+ log := logging.FromContext(ctx)
53+ log.Debug().Msg("creating history model")
54+
55+ // Create tabs for timeline grouping
56+ tabs := styles.TimelineTabs(theme)
57+
58+ // Create search input
59+ search := styles.NewSearchInput(theme)
60+
61+ // Create help
62+ helpModel := styles.NewStyledHelp(theme)
63+
64+ // Create keybindings
65+ keys := styles.DefaultHistoryKeyMap()
66+
67+ return HistoryModel{
68+ tabs: tabs,
69+ search: search,
70+ help: helpModel,
71+ keys: keys,
72+ entries: make([][]*entity.HistoryEntry, 4), // Today, Yesterday, This Week, Older
73+ ctx: ctx,
74+ historyUC: historyUC,
75+ theme: theme,
76+ width: 80,
77+ height: 24,
78+ }
79+}
80+
81+// Init implements tea.Model.
82+func (m HistoryModel) Init() tea.Cmd {
83+ return tea.Batch(
84+ m.loadHistory,
85+ m.loadDomainStats,
86+ )
87+}
88+
89+// historyLoadedMsg is sent when history entries are loaded.
90+type historyLoadedMsg struct {
91+ entries [][]*entity.HistoryEntry
92+ err error
93+}
94+
95+// domainStatsMsg is sent when domain stats are loaded.
96+type domainStatsMsg struct {
97+ domains []*entity.DomainStat
98+ err error
99+}
100+
101+// historyDeletedMsg is sent when an entry is deleted.
102+type historyDeletedMsg struct {
103+ err error
104+}
105+
106+// historyCleanedMsg is sent when history is cleaned up.
107+type historyCleanedMsg struct {
108+ err error
109+}
110+
111+// loadHistory loads history entries grouped by time category.
112+func (m HistoryModel) loadHistory() tea.Msg {
113+ log := logging.FromContext(m.ctx)
114+ log.Debug().Msg("loading history entries")
115+
116+ // Get recent entries (all)
117+ entries, err := m.historyUC.GetRecent(m.ctx, 1000, 0)
118+ if err != nil {
119+ log.Error().Err(err).Msg("failed to load history")
120+ return historyLoadedMsg{err: err}
121+ }
122+
123+ log.Debug().Int("total_count", len(entries)).Msg("loaded history entries from database")
124+
125+ // Group by time category
126+ grouped := make([][]*entity.HistoryEntry, 4)
127+ for i := range grouped {
128+ grouped[i] = make([]*entity.HistoryEntry, 0)
129+ }
130+
131+ for _, entry := range entries {
132+ cat := styles.GetTimeCategory(entry.LastVisited)
133+ switch cat {
134+ case styles.TimeCategoryToday:
135+ grouped[0] = append(grouped[0], entry)
136+ case styles.TimeCategoryYesterday:
137+ grouped[1] = append(grouped[1], entry)
138+ case styles.TimeCategoryThisWeek:
139+ grouped[2] = append(grouped[2], entry)
140+ case styles.TimeCategoryOlder:
141+ grouped[3] = append(grouped[3], entry)
142+ }
143+ }
144+
145+ log.Debug().
146+ Int("today", len(grouped[0])).
147+ Int("yesterday", len(grouped[1])).
148+ Int("this_week", len(grouped[2])).
149+ Int("older", len(grouped[3])).
150+ Msg("grouped history entries by time category")
151+
152+ return historyLoadedMsg{entries: grouped}
153+}
154+
155+// loadDomainStats loads top domain statistics.
156+func (m HistoryModel) loadDomainStats() tea.Msg {
157+ log := logging.FromContext(m.ctx)
158+ domains, err := m.historyUC.GetDomainStats(m.ctx, 10)
159+ if err != nil {
160+ log.Error().Err(err).Msg("failed to load domain stats")
161+ return domainStatsMsg{err: err}
162+ }
163+ log.Debug().Int("domain_count", len(domains)).Msg("loaded domain statistics")
164+ return domainStatsMsg{domains: domains}
165+}
166+
167+// Update implements tea.Model.
168+func (m HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
169+ var cmds []tea.Cmd
170+
171+ // Handle cleanup modal
172+ if m.cleanup != nil {
173+ cleanup, cmd := m.cleanup.Update(msg)
174+ m.cleanup = &cleanup
175+ if m.cleanup.Done() {
176+ if m.cleanup.Confirmed {
177+ // Perform cleanup
178+ cmds = append(cmds, m.performCleanup(m.cleanup.SelectedRange()))
179+ }
180+ m.cleanup = nil
181+ }
182+ return m, cmd
183+ }
184+
185+ // Handle confirm dialog
186+ if m.confirm != nil {
187+ confirm, cmd := m.confirm.Update(msg)
188+ m.confirm = &confirm
189+ if m.confirm.Done() {
190+ if m.confirm.Result() {
191+ // Perform the confirmed action (delete current entry)
192+ cmds = append(cmds, m.deleteCurrentEntry())
193+ }
194+ m.confirm = nil
195+ }
196+ return m, cmd
197+ }
198+
199+ switch msg := msg.(type) {
200+ case tea.WindowSizeMsg:
201+ m.width = msg.Width
202+ m.height = msg.Height
203+ m.help.Width = msg.Width
204+ m.updateList()
205+
206+ case tea.KeyMsg:
207+ // Search mode handling
208+ if m.searchMode {
209+ switch msg.String() {
210+ case "esc":
211+ m.searchMode = false
212+ m.search.Blur()
213+ return m, nil
214+ case "enter":
215+ m.searchMode = false
216+ m.search.Blur()
217+ // Perform search
218+ return m, m.performSearch(m.search.Value())
219+ default:
220+ var cmd tea.Cmd
221+ m.search, cmd = m.search.Update(msg)
222+ return m, cmd
223+ }
224+ }
225+
226+ // Normal mode handling
227+ switch {
228+ case key.Matches(msg, m.keys.Quit):
229+ return m, tea.Quit
230+
231+ case key.Matches(msg, m.keys.Help):
232+ m.showHelp = !m.showHelp
233+
234+ case key.Matches(msg, m.keys.Search):
235+ m.searchMode = true
236+ m.search.Focus()
237+ return m, textinput.Blink
238+
239+ case key.Matches(msg, m.keys.Tab1):
240+ m.tabs.SetActive(0)
241+ m.updateList()
242+
243+ case key.Matches(msg, m.keys.Tab2):
244+ m.tabs.SetActive(1)
245+ m.updateList()
246+
247+ case key.Matches(msg, m.keys.Tab3):
248+ m.tabs.SetActive(2)
249+ m.updateList()
250+
251+ case key.Matches(msg, m.keys.Tab4):
252+ m.tabs.SetActive(3)
253+ m.updateList()
254+
255+ case key.Matches(msg, m.keys.NextTab):
256+ // Cycle to next tab (wraps around)
257+ next := (m.tabs.Active + 1) % 4
258+ m.tabs.SetActive(next)
259+ m.updateList()
260+
261+ case key.Matches(msg, m.keys.PrevTab):
262+ // Cycle to previous tab (wraps around)
263+ prev := (m.tabs.Active + 3) % 4 // +3 is same as -1 mod 4
264+ m.tabs.SetActive(prev)
265+ m.updateList()
266+
267+ case key.Matches(msg, m.keys.Open):
268+ // Open selected URL in browser
269+ if item := m.list.SelectedItem(); item != nil {
270+ if hi, ok := item.(styles.HistoryItem); ok {
271+ cmds = append(cmds, m.openURL(hi.URL))
272+ }
273+ }
274+
275+ case key.Matches(msg, m.keys.Delete):
276+ // Show delete confirmation
277+ if item := m.list.SelectedItem(); item != nil {
278+ confirm := styles.NewConfirm(m.theme, "Delete this entry?")
279+ m.confirm = &confirm
280+ }
281+
282+ case key.Matches(msg, m.keys.DeleteDomain):
283+ // Delete all from domain
284+ if item := m.list.SelectedItem(); item != nil {
285+ if hi, ok := item.(styles.HistoryItem); ok {
286+ confirm := styles.NewConfirm(m.theme, "Delete all from "+hi.Domain+"?")
287+ m.confirm = &confirm
288+ }
289+ }
290+
291+ case key.Matches(msg, m.keys.Cleanup):
292+ // Show cleanup modal
293+ cleanup := styles.NewCleanup(m.theme)
294+ m.cleanup = &cleanup
295+
296+ case key.Matches(msg, m.keys.Filter):
297+ // Cycle through domain filters
298+ m.cycleDomainFilter()
299+ m.updateList()
300+
301+ default:
302+ // Pass to list
303+ var cmd tea.Cmd
304+ m.list, cmd = m.list.Update(msg)
305+ cmds = append(cmds, cmd)
306+ }
307+
308+ case historyLoadedMsg:
309+ if msg.err != nil {
310+ m.err = msg.err
311+ } else {
312+ m.entries = msg.entries
313+ m.updateList()
314+ }
315+
316+ case domainStatsMsg:
317+ if msg.err != nil {
318+ m.err = msg.err
319+ } else {
320+ m.topDomains = msg.domains
321+ }
322+
323+ case historyDeletedMsg:
324+ if msg.err != nil {
325+ m.err = msg.err
326+ } else {
327+ // Reload history
328+ cmds = append(cmds, m.loadHistory)
329+ }
330+
331+ case historyCleanedMsg:
332+ if msg.err != nil {
333+ m.err = msg.err
334+ } else {
335+ // Reload history
336+ cmds = append(cmds, m.loadHistory)
337+ }
338+ }
339+
340+ return m, tea.Batch(cmds...)
341+}
342+
343+// updateList updates the list with entries for the current tab.
344+func (m *HistoryModel) updateList() {
345+ activeTab := m.tabs.Active
346+ if activeTab < 0 || activeTab >= len(m.entries) {
347+ return
348+ }
349+
350+ entries := m.entries[activeTab]
351+
352+ // Apply domain filter if set
353+ if m.domainFilter != "" {
354+ filtered := make([]*entity.HistoryEntry, 0)
355+ for _, e := range entries {
356+ if getDomain(e.URL) == m.domainFilter {
357+ filtered = append(filtered, e)
358+ }
359+ }
360+ entries = filtered
361+ }
362+
363+ // Convert to list items
364+ items := make([]styles.HistoryItem, len(entries))
365+ for i, e := range entries {
366+ items[i] = styles.HistoryItem{
367+ ID: e.ID,
368+ URL: e.URL,
369+ Title: e.Title,
370+ Domain: getDomain(e.URL),
371+ VisitCount: int(e.VisitCount),
372+ LastVisited: e.LastVisited,
373+ }
374+ }
375+
376+ // Create new list
377+ listHeight := m.height - 8 // Account for tabs, search, help
378+ if listHeight < 5 {
379+ listHeight = 5
380+ }
381+
382+ m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight)
383+}
384+
385+// cycleDomainFilter cycles through domain filters.
386+func (m *HistoryModel) cycleDomainFilter() {
387+ if len(m.topDomains) == 0 {
388+ m.domainFilter = ""
389+ return
390+ }
391+
392+ // Find current filter in list
393+ currentIdx := -1
394+ for i, d := range m.topDomains {
395+ if d.Domain == m.domainFilter {
396+ currentIdx = i
397+ break
398+ }
399+ }
400+
401+ // Move to next, or clear filter
402+ if currentIdx == len(m.topDomains)-1 {
403+ m.domainFilter = "" // Clear filter
404+ } else {
405+ m.domainFilter = m.topDomains[currentIdx+1].Domain
406+ }
407+}
408+
409+// performSearch executes a fuzzy search.
410+func (m HistoryModel) performSearch(query string) tea.Cmd {
411+ return func() tea.Msg {
412+ log := logging.FromContext(m.ctx)
413+ log.Debug().Str("query", query).Msg("performing search")
414+
415+ result, err := m.historyUC.Search(m.ctx, usecase.SearchInput{
416+ Query: query,
417+ Limit: 100,
418+ })
419+ if err != nil {
420+ log.Error().Err(err).Msg("search failed")
421+ return historyLoadedMsg{err: err}
422+ }
423+
424+ log.Debug().Int("matches", len(result.Matches)).Msg("search completed")
425+
426+ // Convert matches to single list (all in "Today" tab for search results)
427+ entries := make([]*entity.HistoryEntry, len(result.Matches))
428+ for i, match := range result.Matches {
429+ entries[i] = match.Entry
430+ }
431+
432+ grouped := make([][]*entity.HistoryEntry, 4)
433+ grouped[0] = entries // Put all results in first tab
434+ for i := 1; i < 4; i++ {
435+ grouped[i] = make([]*entity.HistoryEntry, 0)
436+ }
437+
438+ return historyLoadedMsg{entries: grouped}
439+ }
440+}
441+
442+// deleteCurrentEntry deletes the currently selected entry.
443+func (m HistoryModel) deleteCurrentEntry() tea.Cmd {
444+ return func() tea.Msg {
445+ if item := m.list.SelectedItem(); item != nil {
446+ if hi, ok := item.(styles.HistoryItem); ok {
447+ log := logging.FromContext(m.ctx)
448+ log.Debug().Int64("id", hi.ID).Str("url", hi.URL).Msg("deleting history entry")
449+ err := m.historyUC.Delete(m.ctx, hi.ID)
450+ if err != nil {
451+ log.Error().Err(err).Msg("failed to delete entry")
452+ }
453+ return historyDeletedMsg{err: err}
454+ }
455+ }
456+ return historyDeletedMsg{}
457+ }
458+}
459+
460+// performCleanup cleans up history based on selected range.
461+func (m HistoryModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd {
462+ return func() tea.Msg {
463+ log := logging.FromContext(m.ctx)
464+ cutoff := cleanupRange.CutoffTime()
465+
466+ var err error
467+ if cutoff.IsZero() {
468+ log.Info().Msg("clearing all history")
469+ err = m.historyUC.ClearAll(m.ctx)
470+ } else {
471+ log.Info().Time("cutoff", cutoff).Msg("clearing history older than cutoff")
472+ err = m.historyUC.ClearOlderThan(m.ctx, cutoff)
473+ }
474+
475+ if err != nil {
476+ log.Error().Err(err).Msg("failed to cleanup history")
477+ }
478+ return historyCleanedMsg{err: err}
479+ }
480+}
481+
482+// openURL opens a URL in the default browser.
483+func (m HistoryModel) openURL(urlStr string) tea.Cmd {
484+ return func() tea.Msg {
485+ // Use xdg-open on Linux
486+ _ = exec.Command("xdg-open", urlStr).Start()
487+ return nil
488+ }
489+}
490+
491+// View implements tea.Model.
492+func (m HistoryModel) View() string {
493+ // Handle modals
494+ if m.cleanup != nil {
495+ return m.cleanup.View()
496+ }
497+ if m.confirm != nil {
498+ return m.confirm.View()
499+ }
500+
501+ t := m.theme
502+
503+ // Build tab counts
504+ counts := make([]int, 4)
505+ for i, entries := range m.entries {
506+ counts[i] = len(entries)
507+ }
508+
509+ // Header with tabs
510+ header := m.tabs.ViewWithCounts(counts)
511+
512+ // Search bar
513+ var searchBar string
514+ if m.searchMode {
515+ searchBar = t.InputFocused.Render(m.search.View())
516+ } else {
517+ if m.domainFilter != "" {
518+ searchBar = t.Subtle.Render("Filter: ") + t.Badge.Render(m.domainFilter) + t.Subtle.Render(" (f to cycle)")
519+ } else {
520+ searchBar = t.Subtle.Render("Press / to search, f to filter by domain, Tab/Shift+Tab to switch tabs")
521+ }
522+ }
523+
524+ // List
525+ listView := m.list.View()
526+
527+ // Error display
528+ if m.err != nil {
529+ listView = t.ErrorStyle.Render("Error: " + m.err.Error())
530+ }
531+
532+ // Help
533+ var helpView string
534+ if m.showHelp {
535+ helpView = m.help.View(m.keys)
536+ } else {
537+ helpView = t.Subtle.Render("? for help • q to quit")
538+ }
539+
540+ // Combine all parts
541+ return lipgloss.JoinVertical(
542+ lipgloss.Left,
543+ header,
544+ "",
545+ searchBar,
546+ "",
547+ listView,
548+ "",
549+ helpView,
550+ )
551+}
552+
553+// getDomain extracts domain from URL.
554+func getDomain(urlStr string) string {
555+ u, err := url.Parse(urlStr)
556+ if err != nil {
557+ return ""
558+ }
559+ return u.Host
560+}
561+
562+// HistoryListModel is a simpler non-interactive model for JSON output.
563+type HistoryListModel struct {
564+ entries []*entity.HistoryEntry
565+ max int
566+ err error
567+
568+ ctx context.Context
569+ historyUC *usecase.SearchHistoryUseCase
570+}
571+
572+// NewHistoryListModel creates a model for list output.
573+func NewHistoryListModel(ctx context.Context, historyUC *usecase.SearchHistoryUseCase, maxEntries int) HistoryListModel {
574+ return HistoryListModel{
575+ ctx: ctx,
576+ historyUC: historyUC,
577+ max: maxEntries,
578+ }
579+}
580+
581+// Init implements tea.Model.
582+func (m HistoryListModel) Init() tea.Cmd {
583+ return func() tea.Msg {
584+ log := logging.FromContext(m.ctx)
585+ log.Debug().Int("max", m.max).Msg("loading history for JSON output")
586+
587+ entries, err := m.historyUC.GetRecent(m.ctx, m.max, 0)
588+ if err != nil {
589+ log.Error().Err(err).Msg("failed to load history")
590+ return historyLoadedMsg{err: err}
591+ }
592+
593+ log.Debug().Int("count", len(entries)).Msg("loaded history entries")
594+
595+ grouped := make([][]*entity.HistoryEntry, 1)
596+ grouped[0] = entries
597+ return historyLoadedMsg{entries: grouped}
598+ }
599+}
600+
601+// Update implements tea.Model.
602+func (m HistoryListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
603+ switch msg := msg.(type) {
604+ case historyLoadedMsg:
605+ if msg.err != nil {
606+ m.err = msg.err
607+ } else if len(msg.entries) > 0 {
608+ m.entries = msg.entries[0]
609+ }
610+ return m, tea.Quit
611+ }
612+ return m, nil
613+}
614+
615+// View implements tea.Model.
616+func (m HistoryListModel) View() string {
617+ return "" // Output handled externally
618+}
619+
620+// Entries returns the loaded entries.
621+func (m HistoryListModel) Entries() []*entity.HistoryEntry {
622+ return m.entries
623+}
624+
625+// Error returns any error that occurred.
626+func (m HistoryListModel) Error() error {
627+ return m.err
628+}
629+
630+// Ensure interface compliance at compile time.
631+var _ tea.Model = (*HistoryModel)(nil)
632+var _ tea.Model = (*HistoryListModel)(nil)
633+
634+// Unexported helper to avoid exposing time package in imports when not needed.
635+var _ = time.Now
@@ -0,0 +1,635 @@
1+// Package model provides Bubble Tea models for CLI commands.
2+package model
3+
4+import (
5+ "context"
6+ "net/url"
7+ "os/exec"
8+ "time"
9+
10+ "github.com/charmbracelet/bubbles/help"
11+ "github.com/charmbracelet/bubbles/key"
12+ "github.com/charmbracelet/bubbles/list"
13+ "github.com/charmbracelet/bubbles/textinput"
14+ tea "github.com/charmbracelet/bubbletea"
15+ "github.com/charmbracelet/lipgloss"
16+
17+ "github.com/bnema/dumber/internal/application/usecase"
18+ "github.com/bnema/dumber/internal/cli/styles"
19+ "github.com/bnema/dumber/internal/domain/entity"
20+ "github.com/bnema/dumber/internal/logging"
21+)
22+
23+// HistoryModel is the Bubble Tea model for interactive history browser.
24+type HistoryModel struct {
25+ // UI components
26+ tabs styles.TabsModel
27+ list list.Model
28+ search textinput.Model
29+ help help.Model
30+ keys styles.HistoryKeyMap
31+ cleanup *styles.CleanupModel
32+ confirm *styles.ConfirmModel
33+
34+ // State
35+ entries [][]*entity.HistoryEntry // Entries grouped by tab (Today/Yesterday/Week/Older)
36+ topDomains []*entity.DomainStat
37+ domainFilter string
38+ searchMode bool
39+ showHelp bool
40+ width int
41+ height int
42+ err error
43+
44+ // Dependencies
45+ ctx context.Context
46+ historyUC *usecase.SearchHistoryUseCase
47+ theme *styles.Theme
48+}
49+
50+// NewHistoryModel creates a new history browser model.
51+func NewHistoryModel(ctx context.Context, theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) HistoryModel {
52+ log := logging.FromContext(ctx)
53+ log.Debug().Msg("creating history model")
54+
55+ // Create tabs for timeline grouping
56+ tabs := styles.TimelineTabs(theme)
57+
58+ // Create search input
59+ search := styles.NewSearchInput(theme)
60+
61+ // Create help
62+ helpModel := styles.NewStyledHelp(theme)
63+
64+ // Create keybindings
65+ keys := styles.DefaultHistoryKeyMap()
66+
67+ return HistoryModel{
68+ tabs: tabs,
69+ search: search,
70+ help: helpModel,
71+ keys: keys,
72+ entries: make([][]*entity.HistoryEntry, 4), // Today, Yesterday, This Week, Older
73+ ctx: ctx,
74+ historyUC: historyUC,
75+ theme: theme,
76+ width: 80,
77+ height: 24,
78+ }
79+}
80+
81+// Init implements tea.Model.
82+func (m HistoryModel) Init() tea.Cmd {
83+ return tea.Batch(
84+ m.loadHistory,
85+ m.loadDomainStats,
86+ )
87+}
88+
89+// historyLoadedMsg is sent when history entries are loaded.
90+type historyLoadedMsg struct {
91+ entries [][]*entity.HistoryEntry
92+ err error
93+}
94+
95+// domainStatsMsg is sent when domain stats are loaded.
96+type domainStatsMsg struct {
97+ domains []*entity.DomainStat
98+ err error
99+}
100+
101+// historyDeletedMsg is sent when an entry is deleted.
102+type historyDeletedMsg struct {
103+ err error
104+}
105+
106+// historyCleanedMsg is sent when history is cleaned up.
107+type historyCleanedMsg struct {
108+ err error
109+}
110+
111+// loadHistory loads history entries grouped by time category.
112+func (m HistoryModel) loadHistory() tea.Msg {
113+ log := logging.FromContext(m.ctx)
114+ log.Debug().Msg("loading history entries")
115+
116+ // Get recent entries (all)
117+ entries, err := m.historyUC.GetRecent(m.ctx, 1000, 0)
118+ if err != nil {
119+ log.Error().Err(err).Msg("failed to load history")
120+ return historyLoadedMsg{err: err}
121+ }
122+
123+ log.Debug().Int("total_count", len(entries)).Msg("loaded history entries from database")
124+
125+ // Group by time category
126+ grouped := make([][]*entity.HistoryEntry, 4)
127+ for i := range grouped {
128+ grouped[i] = make([]*entity.HistoryEntry, 0)
129+ }
130+
131+ for _, entry := range entries {
132+ cat := styles.GetTimeCategory(entry.LastVisited)
133+ switch cat {
134+ case styles.TimeCategoryToday:
135+ grouped[0] = append(grouped[0], entry)
136+ case styles.TimeCategoryYesterday:
137+ grouped[1] = append(grouped[1], entry)
138+ case styles.TimeCategoryThisWeek:
139+ grouped[2] = append(grouped[2], entry)
140+ case styles.TimeCategoryOlder:
141+ grouped[3] = append(grouped[3], entry)
142+ }
143+ }
144+
145+ log.Debug().
146+ Int("today", len(grouped[0])).
147+ Int("yesterday", len(grouped[1])).
148+ Int("this_week", len(grouped[2])).
149+ Int("older", len(grouped[3])).
150+ Msg("grouped history entries by time category")
151+
152+ return historyLoadedMsg{entries: grouped}
153+}
154+
155+// loadDomainStats loads top domain statistics.
156+func (m HistoryModel) loadDomainStats() tea.Msg {
157+ log := logging.FromContext(m.ctx)
158+ domains, err := m.historyUC.GetDomainStats(m.ctx, 10)
159+ if err != nil {
160+ log.Error().Err(err).Msg("failed to load domain stats")
161+ return domainStatsMsg{err: err}
162+ }
163+ log.Debug().Int("domain_count", len(domains)).Msg("loaded domain statistics")
164+ return domainStatsMsg{domains: domains}
165+}
166+
167+// Update implements tea.Model.
168+func (m HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
169+ var cmds []tea.Cmd
170+
171+ // Handle cleanup modal
172+ if m.cleanup != nil {
173+ cleanup, cmd := m.cleanup.Update(msg)
174+ m.cleanup = &cleanup
175+ if m.cleanup.Done() {
176+ if m.cleanup.Confirmed {
177+ // Perform cleanup
178+ cmds = append(cmds, m.performCleanup(m.cleanup.SelectedRange()))
179+ }
180+ m.cleanup = nil
181+ }
182+ return m, cmd
183+ }
184+
185+ // Handle confirm dialog
186+ if m.confirm != nil {
187+ confirm, cmd := m.confirm.Update(msg)
188+ m.confirm = &confirm
189+ if m.confirm.Done() {
190+ if m.confirm.Result() {
191+ // Perform the confirmed action (delete current entry)
192+ cmds = append(cmds, m.deleteCurrentEntry())
193+ }
194+ m.confirm = nil
195+ }
196+ return m, cmd
197+ }
198+
199+ switch msg := msg.(type) {
200+ case tea.WindowSizeMsg:
201+ m.width = msg.Width
202+ m.height = msg.Height
203+ m.help.Width = msg.Width
204+ m.updateList()
205+
206+ case tea.KeyMsg:
207+ // Search mode handling
208+ if m.searchMode {
209+ switch msg.String() {
210+ case "esc":
211+ m.searchMode = false
212+ m.search.Blur()
213+ return m, nil
214+ case "enter":
215+ m.searchMode = false
216+ m.search.Blur()
217+ // Perform search
218+ return m, m.performSearch(m.search.Value())
219+ default:
220+ var cmd tea.Cmd
221+ m.search, cmd = m.search.Update(msg)
222+ return m, cmd
223+ }
224+ }
225+
226+ // Normal mode handling
227+ switch {
228+ case key.Matches(msg, m.keys.Quit):
229+ return m, tea.Quit
230+
231+ case key.Matches(msg, m.keys.Help):
232+ m.showHelp = !m.showHelp
233+
234+ case key.Matches(msg, m.keys.Search):
235+ m.searchMode = true
236+ m.search.Focus()
237+ return m, textinput.Blink
238+
239+ case key.Matches(msg, m.keys.Tab1):
240+ m.tabs.SetActive(0)
241+ m.updateList()
242+
243+ case key.Matches(msg, m.keys.Tab2):
244+ m.tabs.SetActive(1)
245+ m.updateList()
246+
247+ case key.Matches(msg, m.keys.Tab3):
248+ m.tabs.SetActive(2)
249+ m.updateList()
250+
251+ case key.Matches(msg, m.keys.Tab4):
252+ m.tabs.SetActive(3)
253+ m.updateList()
254+
255+ case key.Matches(msg, m.keys.NextTab):
256+ // Cycle to next tab (wraps around)
257+ next := (m.tabs.Active + 1) % 4
258+ m.tabs.SetActive(next)
259+ m.updateList()
260+
261+ case key.Matches(msg, m.keys.PrevTab):
262+ // Cycle to previous tab (wraps around)
263+ prev := (m.tabs.Active + 3) % 4 // +3 is same as -1 mod 4
264+ m.tabs.SetActive(prev)
265+ m.updateList()
266+
267+ case key.Matches(msg, m.keys.Open):
268+ // Open selected URL in browser
269+ if item := m.list.SelectedItem(); item != nil {
270+ if hi, ok := item.(styles.HistoryItem); ok {
271+ cmds = append(cmds, m.openURL(hi.URL))
272+ }
273+ }
274+
275+ case key.Matches(msg, m.keys.Delete):
276+ // Show delete confirmation
277+ if item := m.list.SelectedItem(); item != nil {
278+ confirm := styles.NewConfirm(m.theme, "Delete this entry?")
279+ m.confirm = &confirm
280+ }
281+
282+ case key.Matches(msg, m.keys.DeleteDomain):
283+ // Delete all from domain
284+ if item := m.list.SelectedItem(); item != nil {
285+ if hi, ok := item.(styles.HistoryItem); ok {
286+ confirm := styles.NewConfirm(m.theme, "Delete all from "+hi.Domain+"?")
287+ m.confirm = &confirm
288+ }
289+ }
290+
291+ case key.Matches(msg, m.keys.Cleanup):
292+ // Show cleanup modal
293+ cleanup := styles.NewCleanup(m.theme)
294+ m.cleanup = &cleanup
295+
296+ case key.Matches(msg, m.keys.Filter):
297+ // Cycle through domain filters
298+ m.cycleDomainFilter()
299+ m.updateList()
300+
301+ default:
302+ // Pass to list
303+ var cmd tea.Cmd
304+ m.list, cmd = m.list.Update(msg)
305+ cmds = append(cmds, cmd)
306+ }
307+
308+ case historyLoadedMsg:
309+ if msg.err != nil {
310+ m.err = msg.err
311+ } else {
312+ m.entries = msg.entries
313+ m.updateList()
314+ }
315+
316+ case domainStatsMsg:
317+ if msg.err != nil {
318+ m.err = msg.err
319+ } else {
320+ m.topDomains = msg.domains
321+ }
322+
323+ case historyDeletedMsg:
324+ if msg.err != nil {
325+ m.err = msg.err
326+ } else {
327+ // Reload history
328+ cmds = append(cmds, m.loadHistory)
329+ }
330+
331+ case historyCleanedMsg:
332+ if msg.err != nil {
333+ m.err = msg.err
334+ } else {
335+ // Reload history
336+ cmds = append(cmds, m.loadHistory)
337+ }
338+ }
339+
340+ return m, tea.Batch(cmds...)
341+}
342+
343+// updateList updates the list with entries for the current tab.
344+func (m *HistoryModel) updateList() {
345+ activeTab := m.tabs.Active
346+ if activeTab < 0 || activeTab >= len(m.entries) {
347+ return
348+ }
349+
350+ entries := m.entries[activeTab]
351+
352+ // Apply domain filter if set
353+ if m.domainFilter != "" {
354+ filtered := make([]*entity.HistoryEntry, 0)
355+ for _, e := range entries {
356+ if getDomain(e.URL) == m.domainFilter {
357+ filtered = append(filtered, e)
358+ }
359+ }
360+ entries = filtered
361+ }
362+
363+ // Convert to list items
364+ items := make([]styles.HistoryItem, len(entries))
365+ for i, e := range entries {
366+ items[i] = styles.HistoryItem{
367+ ID: e.ID,
368+ URL: e.URL,
369+ Title: e.Title,
370+ Domain: getDomain(e.URL),
371+ VisitCount: int(e.VisitCount),
372+ LastVisited: e.LastVisited,
373+ }
374+ }
375+
376+ // Create new list
377+ listHeight := m.height - 8 // Account for tabs, search, help
378+ if listHeight < 5 {
379+ listHeight = 5
380+ }
381+
382+ m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight)
383+}
384+
385+// cycleDomainFilter cycles through domain filters.
386+func (m *HistoryModel) cycleDomainFilter() {
387+ if len(m.topDomains) == 0 {
388+ m.domainFilter = ""
389+ return
390+ }
391+
392+ // Find current filter in list
393+ currentIdx := -1
394+ for i, d := range m.topDomains {
395+ if d.Domain == m.domainFilter {
396+ currentIdx = i
397+ break
398+ }
399+ }
400+
401+ // Move to next, or clear filter
402+ if currentIdx == len(m.topDomains)-1 {
403+ m.domainFilter = "" // Clear filter
404+ } else {
405+ m.domainFilter = m.topDomains[currentIdx+1].Domain
406+ }
407+}
408+
409+// performSearch executes a fuzzy search.
410+func (m HistoryModel) performSearch(query string) tea.Cmd {
411+ return func() tea.Msg {
412+ log := logging.FromContext(m.ctx)
413+ log.Debug().Str("query", query).Msg("performing search")
414+
415+ result, err := m.historyUC.Search(m.ctx, usecase.SearchInput{
416+ Query: query,
417+ Limit: 100,
418+ })
419+ if err != nil {
420+ log.Error().Err(err).Msg("search failed")
421+ return historyLoadedMsg{err: err}
422+ }
423+
424+ log.Debug().Int("matches", len(result.Matches)).Msg("search completed")
425+
426+ // Convert matches to single list (all in "Today" tab for search results)
427+ entries := make([]*entity.HistoryEntry, len(result.Matches))
428+ for i, match := range result.Matches {
429+ entries[i] = match.Entry
430+ }
431+
432+ grouped := make([][]*entity.HistoryEntry, 4)
433+ grouped[0] = entries // Put all results in first tab
434+ for i := 1; i < 4; i++ {
435+ grouped[i] = make([]*entity.HistoryEntry, 0)
436+ }
437+
438+ return historyLoadedMsg{entries: grouped}
439+ }
440+}
441+
442+// deleteCurrentEntry deletes the currently selected entry.
443+func (m HistoryModel) deleteCurrentEntry() tea.Cmd {
444+ return func() tea.Msg {
445+ if item := m.list.SelectedItem(); item != nil {
446+ if hi, ok := item.(styles.HistoryItem); ok {
447+ log := logging.FromContext(m.ctx)
448+ log.Debug().Int64("id", hi.ID).Str("url", hi.URL).Msg("deleting history entry")
449+ err := m.historyUC.Delete(m.ctx, hi.ID)
450+ if err != nil {
451+ log.Error().Err(err).Msg("failed to delete entry")
452+ }
453+ return historyDeletedMsg{err: err}
454+ }
455+ }
456+ return historyDeletedMsg{}
457+ }
458+}
459+
460+// performCleanup cleans up history based on selected range.
461+func (m HistoryModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd {
462+ return func() tea.Msg {
463+ log := logging.FromContext(m.ctx)
464+ cutoff := cleanupRange.CutoffTime()
465+
466+ var err error
467+ if cutoff.IsZero() {
468+ log.Info().Msg("clearing all history")
469+ err = m.historyUC.ClearAll(m.ctx)
470+ } else {
471+ log.Info().Time("cutoff", cutoff).Msg("clearing history older than cutoff")
472+ err = m.historyUC.ClearOlderThan(m.ctx, cutoff)
473+ }
474+
475+ if err != nil {
476+ log.Error().Err(err).Msg("failed to cleanup history")
477+ }
478+ return historyCleanedMsg{err: err}
479+ }
480+}
481+
482+// openURL opens a URL in the default browser.
483+func (m HistoryModel) openURL(urlStr string) tea.Cmd {
484+ return func() tea.Msg {
485+ // Use xdg-open on Linux
486+ _ = exec.Command("xdg-open", urlStr).Start()
487+ return nil
488+ }
489+}
490+
491+// View implements tea.Model.
492+func (m HistoryModel) View() string {
493+ // Handle modals
494+ if m.cleanup != nil {
495+ return m.cleanup.View()
496+ }
497+ if m.confirm != nil {
498+ return m.confirm.View()
499+ }
500+
501+ t := m.theme
502+
503+ // Build tab counts
504+ counts := make([]int, 4)
505+ for i, entries := range m.entries {
506+ counts[i] = len(entries)
507+ }
508+
509+ // Header with tabs
510+ header := m.tabs.ViewWithCounts(counts)
511+
512+ // Search bar
513+ var searchBar string
514+ if m.searchMode {
515+ searchBar = t.InputFocused.Render(m.search.View())
516+ } else {
517+ if m.domainFilter != "" {
518+ searchBar = t.Subtle.Render("Filter: ") + t.Badge.Render(m.domainFilter) + t.Subtle.Render(" (f to cycle)")
519+ } else {
520+ searchBar = t.Subtle.Render("Press / to search, f to filter by domain, Tab/Shift+Tab to switch tabs")
521+ }
522+ }
523+
524+ // List
525+ listView := m.list.View()
526+
527+ // Error display
528+ if m.err != nil {
529+ listView = t.ErrorStyle.Render("Error: " + m.err.Error())
530+ }
531+
532+ // Help
533+ var helpView string
534+ if m.showHelp {
535+ helpView = m.help.View(m.keys)
536+ } else {
537+ helpView = t.Subtle.Render("? for help • q to quit")
538+ }
539+
540+ // Combine all parts
541+ return lipgloss.JoinVertical(
542+ lipgloss.Left,
543+ header,
544+ "",
545+ searchBar,
546+ "",
547+ listView,
548+ "",
549+ helpView,
550+ )
551+}
552+
553+// getDomain extracts domain from URL.
554+func getDomain(urlStr string) string {
555+ u, err := url.Parse(urlStr)
556+ if err != nil {
557+ return ""
558+ }
559+ return u.Host
560+}
561+
562+// HistoryListModel is a simpler non-interactive model for JSON output.
563+type HistoryListModel struct {
564+ entries []*entity.HistoryEntry
565+ max int
566+ err error
567+
568+ ctx context.Context
569+ historyUC *usecase.SearchHistoryUseCase
570+}
571+
572+// NewHistoryListModel creates a model for list output.
573+func NewHistoryListModel(ctx context.Context, historyUC *usecase.SearchHistoryUseCase, maxEntries int) HistoryListModel {
574+ return HistoryListModel{
575+ ctx: ctx,
576+ historyUC: historyUC,
577+ max: maxEntries,
578+ }
579+}
580+
581+// Init implements tea.Model.
582+func (m HistoryListModel) Init() tea.Cmd {
583+ return func() tea.Msg {
584+ log := logging.FromContext(m.ctx)
585+ log.Debug().Int("max", m.max).Msg("loading history for JSON output")
586+
587+ entries, err := m.historyUC.GetRecent(m.ctx, m.max, 0)
588+ if err != nil {
589+ log.Error().Err(err).Msg("failed to load history")
590+ return historyLoadedMsg{err: err}
591+ }
592+
593+ log.Debug().Int("count", len(entries)).Msg("loaded history entries")
594+
595+ grouped := make([][]*entity.HistoryEntry, 1)
596+ grouped[0] = entries
597+ return historyLoadedMsg{entries: grouped}
598+ }
599+}
600+
601+// Update implements tea.Model.
602+func (m HistoryListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
603+ switch msg := msg.(type) {
604+ case historyLoadedMsg:
605+ if msg.err != nil {
606+ m.err = msg.err
607+ } else if len(msg.entries) > 0 {
608+ m.entries = msg.entries[0]
609+ }
610+ return m, tea.Quit
611+ }
612+ return m, nil
613+}
614+
615+// View implements tea.Model.
616+func (m HistoryListModel) View() string {
617+ return "" // Output handled externally
618+}
619+
620+// Entries returns the loaded entries.
621+func (m HistoryListModel) Entries() []*entity.HistoryEntry {
622+ return m.entries
623+}
624+
625+// Error returns any error that occurred.
626+func (m HistoryListModel) Error() error {
627+ return m.err
628+}
629+
630+// Ensure interface compliance at compile time.
631+var _ tea.Model = (*HistoryModel)(nil)
632+var _ tea.Model = (*HistoryListModel)(nil)
633+
634+// Unexported helper to avoid exposing time package in imports when not needed.
635+var _ = time.Now
A internal/cli/model/stats.go +245 -0
@@ -0,0 +1,245 @@
1+package model
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/charmbracelet/bubbles/table"
8+ tea "github.com/charmbracelet/bubbletea"
9+ "github.com/charmbracelet/lipgloss"
10+
11+ "github.com/bnema/dumber/internal/application/usecase"
12+ "github.com/bnema/dumber/internal/cli/styles"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+// StatsModel displays history analytics.
17+type StatsModel struct {
18+ analytics *entity.HistoryAnalytics
19+ table table.Model
20+ loading bool
21+ err error
22+ width int
23+ height int
24+
25+ historyUC *usecase.SearchHistoryUseCase
26+ theme *styles.Theme
27+}
28+
29+// NewStatsModel creates a new stats display model.
30+func NewStatsModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) StatsModel {
31+ return StatsModel{
32+ historyUC: historyUC,
33+ theme: theme,
34+ loading: true,
35+ width: 80,
36+ height: 24,
37+ }
38+}
39+
40+// analyticsLoadedMsg is sent when analytics are loaded.
41+type analyticsLoadedMsg struct {
42+ analytics *entity.HistoryAnalytics
43+ err error
44+}
45+
46+// Init implements tea.Model.
47+func (m StatsModel) Init() tea.Cmd {
48+ return m.loadAnalytics
49+}
50+
51+// loadAnalytics loads history analytics.
52+func (m StatsModel) loadAnalytics() tea.Msg {
53+ ctx := context.Background()
54+ analytics, err := m.historyUC.GetAnalytics(ctx)
55+ return analyticsLoadedMsg{analytics: analytics, err: err}
56+}
57+
58+// Update implements tea.Model.
59+func (m StatsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60+ switch msg := msg.(type) {
61+ case tea.WindowSizeMsg:
62+ m.width = msg.Width
63+ m.height = msg.Height
64+ m.updateTable()
65+
66+ case tea.KeyMsg:
67+ switch msg.String() {
68+ case "q", "ctrl+c", "esc":
69+ return m, tea.Quit
70+ default:
71+ var cmd tea.Cmd
72+ m.table, cmd = m.table.Update(msg)
73+ return m, cmd
74+ }
75+
76+ case analyticsLoadedMsg:
77+ m.loading = false
78+ if msg.err != nil {
79+ m.err = msg.err
80+ } else {
81+ m.analytics = msg.analytics
82+ m.updateTable()
83+ }
84+ }
85+
86+ return m, nil
87+}
88+
89+// updateTable builds the domain stats table.
90+func (m *StatsModel) updateTable() {
91+ if m.analytics == nil || len(m.analytics.TopDomains) == 0 {
92+ return
93+ }
94+
95+ columns := styles.StatsTableColumns()
96+
97+ rows := make([]table.Row, len(m.analytics.TopDomains))
98+ for i, d := range m.analytics.TopDomains {
99+ rows[i] = table.Row{
100+ d.Domain,
101+ formatNumber(d.TotalVisits),
102+ formatNumber(d.PageCount),
103+ styles.RelativeTime(d.LastVisit),
104+ }
105+ }
106+
107+ tableHeight := len(rows)
108+ if tableHeight > m.height-10 {
109+ tableHeight = m.height - 10
110+ }
111+ if tableHeight < 3 {
112+ tableHeight = 3
113+ }
114+
115+ m.table = styles.NewStyledTable(m.theme, columns, rows, m.width-4, tableHeight)
116+}
117+
118+// View implements tea.Model.
119+func (m StatsModel) View() string {
120+ t := m.theme
121+
122+ if m.loading {
123+ spinner := styles.NewLoading(t, "Loading analytics...")
124+ return t.Box.Render(spinner.View())
125+ }
126+
127+ if m.err != nil {
128+ return t.Box.Render(t.ErrorStyle.Render("Error: " + m.err.Error()))
129+ }
130+
131+ if m.analytics == nil {
132+ return t.Box.Render(t.Subtle.Render("No data available"))
133+ }
134+
135+ // Build header with summary stats
136+ header := lipgloss.JoinVertical(
137+ lipgloss.Left,
138+ t.Title.Render("History Analytics"),
139+ "",
140+ lipgloss.JoinHorizontal(
141+ lipgloss.Top,
142+ t.Badge.Render(fmt.Sprintf("%d entries", m.analytics.TotalEntries)),
143+ " ",
144+ t.Badge.Render(fmt.Sprintf("%d visits", m.analytics.TotalVisits)),
145+ " ",
146+ t.BadgeMuted.Render(fmt.Sprintf("%d days", m.analytics.UniqueDays)),
147+ ),
148+ )
149+
150+ // Domain table
151+ tableHeader := t.Subtitle.Render("Top Domains")
152+ tableView := m.table.View()
153+
154+ // Daily activity (simple bar chart)
155+ var activityView string
156+ if len(m.analytics.DailyVisits) > 0 {
157+ activityView = m.renderDailyActivity()
158+ }
159+
160+ // Help
161+ helpView := t.Subtle.Render("q to quit")
162+
163+ // Combine
164+ content := lipgloss.JoinVertical(
165+ lipgloss.Left,
166+ header,
167+ "",
168+ tableHeader,
169+ tableView,
170+ )
171+
172+ if activityView != "" {
173+ content = lipgloss.JoinVertical(
174+ lipgloss.Left,
175+ content,
176+ "",
177+ t.Subtitle.Render("Daily Activity (Last 30 Days)"),
178+ activityView,
179+ )
180+ }
181+
182+ content = lipgloss.JoinVertical(
183+ lipgloss.Left,
184+ content,
185+ "",
186+ helpView,
187+ )
188+
189+ return content
190+}
191+
192+// renderDailyActivity renders a simple sparkline-style bar chart.
193+func (m StatsModel) renderDailyActivity() string {
194+ if m.analytics == nil || len(m.analytics.DailyVisits) == 0 {
195+ return ""
196+ }
197+
198+ // Find max for scaling
199+ var maxVisits int64
200+ for _, d := range m.analytics.DailyVisits {
201+ if d.Visits > maxVisits {
202+ maxVisits = d.Visits
203+ }
204+ }
205+
206+ if maxVisits == 0 {
207+ return m.theme.Subtle.Render("No activity")
208+ }
209+
210+ // Build sparkline using block characters
211+ blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
212+ var result string
213+
214+ // Show last 30 days or available data
215+ days := m.analytics.DailyVisits
216+ if len(days) > 30 {
217+ days = days[len(days)-30:]
218+ }
219+
220+ for _, d := range days {
221+ // Scale to block index (0-7)
222+ idx := int(float64(d.Visits) / float64(maxVisits) * 7)
223+ if idx > 7 {
224+ idx = 7
225+ }
226+ result += string(blocks[idx])
227+ }
228+
229+ return lipgloss.NewStyle().Foreground(m.theme.Accent).Render(result)
230+}
231+
232+// formatNumber formats a number for display.
233+func formatNumber(n int64) string {
234+ switch {
235+ case n >= 1000000:
236+ return fmt.Sprintf("%.1fM", float64(n)/1000000)
237+ case n >= 1000:
238+ return fmt.Sprintf("%.1fK", float64(n)/1000)
239+ default:
240+ return fmt.Sprintf("%d", n)
241+ }
242+}
243+
244+// Ensure interface compliance.
245+var _ tea.Model = (*StatsModel)(nil)
@@ -0,0 +1,245 @@
1+package model
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/charmbracelet/bubbles/table"
8+ tea "github.com/charmbracelet/bubbletea"
9+ "github.com/charmbracelet/lipgloss"
10+
11+ "github.com/bnema/dumber/internal/application/usecase"
12+ "github.com/bnema/dumber/internal/cli/styles"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+// StatsModel displays history analytics.
17+type StatsModel struct {
18+ analytics *entity.HistoryAnalytics
19+ table table.Model
20+ loading bool
21+ err error
22+ width int
23+ height int
24+
25+ historyUC *usecase.SearchHistoryUseCase
26+ theme *styles.Theme
27+}
28+
29+// NewStatsModel creates a new stats display model.
30+func NewStatsModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) StatsModel {
31+ return StatsModel{
32+ historyUC: historyUC,
33+ theme: theme,
34+ loading: true,
35+ width: 80,
36+ height: 24,
37+ }
38+}
39+
40+// analyticsLoadedMsg is sent when analytics are loaded.
41+type analyticsLoadedMsg struct {
42+ analytics *entity.HistoryAnalytics
43+ err error
44+}
45+
46+// Init implements tea.Model.
47+func (m StatsModel) Init() tea.Cmd {
48+ return m.loadAnalytics
49+}
50+
51+// loadAnalytics loads history analytics.
52+func (m StatsModel) loadAnalytics() tea.Msg {
53+ ctx := context.Background()
54+ analytics, err := m.historyUC.GetAnalytics(ctx)
55+ return analyticsLoadedMsg{analytics: analytics, err: err}
56+}
57+
58+// Update implements tea.Model.
59+func (m StatsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60+ switch msg := msg.(type) {
61+ case tea.WindowSizeMsg:
62+ m.width = msg.Width
63+ m.height = msg.Height
64+ m.updateTable()
65+
66+ case tea.KeyMsg:
67+ switch msg.String() {
68+ case "q", "ctrl+c", "esc":
69+ return m, tea.Quit
70+ default:
71+ var cmd tea.Cmd
72+ m.table, cmd = m.table.Update(msg)
73+ return m, cmd
74+ }
75+
76+ case analyticsLoadedMsg:
77+ m.loading = false
78+ if msg.err != nil {
79+ m.err = msg.err
80+ } else {
81+ m.analytics = msg.analytics
82+ m.updateTable()
83+ }
84+ }
85+
86+ return m, nil
87+}
88+
89+// updateTable builds the domain stats table.
90+func (m *StatsModel) updateTable() {
91+ if m.analytics == nil || len(m.analytics.TopDomains) == 0 {
92+ return
93+ }
94+
95+ columns := styles.StatsTableColumns()
96+
97+ rows := make([]table.Row, len(m.analytics.TopDomains))
98+ for i, d := range m.analytics.TopDomains {
99+ rows[i] = table.Row{
100+ d.Domain,
101+ formatNumber(d.TotalVisits),
102+ formatNumber(d.PageCount),
103+ styles.RelativeTime(d.LastVisit),
104+ }
105+ }
106+
107+ tableHeight := len(rows)
108+ if tableHeight > m.height-10 {
109+ tableHeight = m.height - 10
110+ }
111+ if tableHeight < 3 {
112+ tableHeight = 3
113+ }
114+
115+ m.table = styles.NewStyledTable(m.theme, columns, rows, m.width-4, tableHeight)
116+}
117+
118+// View implements tea.Model.
119+func (m StatsModel) View() string {
120+ t := m.theme
121+
122+ if m.loading {
123+ spinner := styles.NewLoading(t, "Loading analytics...")
124+ return t.Box.Render(spinner.View())
125+ }
126+
127+ if m.err != nil {
128+ return t.Box.Render(t.ErrorStyle.Render("Error: " + m.err.Error()))
129+ }
130+
131+ if m.analytics == nil {
132+ return t.Box.Render(t.Subtle.Render("No data available"))
133+ }
134+
135+ // Build header with summary stats
136+ header := lipgloss.JoinVertical(
137+ lipgloss.Left,
138+ t.Title.Render("History Analytics"),
139+ "",
140+ lipgloss.JoinHorizontal(
141+ lipgloss.Top,
142+ t.Badge.Render(fmt.Sprintf("%d entries", m.analytics.TotalEntries)),
143+ " ",
144+ t.Badge.Render(fmt.Sprintf("%d visits", m.analytics.TotalVisits)),
145+ " ",
146+ t.BadgeMuted.Render(fmt.Sprintf("%d days", m.analytics.UniqueDays)),
147+ ),
148+ )
149+
150+ // Domain table
151+ tableHeader := t.Subtitle.Render("Top Domains")
152+ tableView := m.table.View()
153+
154+ // Daily activity (simple bar chart)
155+ var activityView string
156+ if len(m.analytics.DailyVisits) > 0 {
157+ activityView = m.renderDailyActivity()
158+ }
159+
160+ // Help
161+ helpView := t.Subtle.Render("q to quit")
162+
163+ // Combine
164+ content := lipgloss.JoinVertical(
165+ lipgloss.Left,
166+ header,
167+ "",
168+ tableHeader,
169+ tableView,
170+ )
171+
172+ if activityView != "" {
173+ content = lipgloss.JoinVertical(
174+ lipgloss.Left,
175+ content,
176+ "",
177+ t.Subtitle.Render("Daily Activity (Last 30 Days)"),
178+ activityView,
179+ )
180+ }
181+
182+ content = lipgloss.JoinVertical(
183+ lipgloss.Left,
184+ content,
185+ "",
186+ helpView,
187+ )
188+
189+ return content
190+}
191+
192+// renderDailyActivity renders a simple sparkline-style bar chart.
193+func (m StatsModel) renderDailyActivity() string {
194+ if m.analytics == nil || len(m.analytics.DailyVisits) == 0 {
195+ return ""
196+ }
197+
198+ // Find max for scaling
199+ var maxVisits int64
200+ for _, d := range m.analytics.DailyVisits {
201+ if d.Visits > maxVisits {
202+ maxVisits = d.Visits
203+ }
204+ }
205+
206+ if maxVisits == 0 {
207+ return m.theme.Subtle.Render("No activity")
208+ }
209+
210+ // Build sparkline using block characters
211+ blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
212+ var result string
213+
214+ // Show last 30 days or available data
215+ days := m.analytics.DailyVisits
216+ if len(days) > 30 {
217+ days = days[len(days)-30:]
218+ }
219+
220+ for _, d := range days {
221+ // Scale to block index (0-7)
222+ idx := int(float64(d.Visits) / float64(maxVisits) * 7)
223+ if idx > 7 {
224+ idx = 7
225+ }
226+ result += string(blocks[idx])
227+ }
228+
229+ return lipgloss.NewStyle().Foreground(m.theme.Accent).Render(result)
230+}
231+
232+// formatNumber formats a number for display.
233+func formatNumber(n int64) string {
234+ switch {
235+ case n >= 1000000:
236+ return fmt.Sprintf("%.1fM", float64(n)/1000000)
237+ case n >= 1000:
238+ return fmt.Sprintf("%.1fK", float64(n)/1000)
239+ default:
240+ return fmt.Sprintf("%d", n)
241+ }
242+}
243+
244+// Ensure interface compliance.
245+var _ tea.Model = (*StatsModel)(nil)
A internal/cli/styles/badge.go +141 -0
@@ -0,0 +1,141 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "time"
6+
7+ "github.com/charmbracelet/lipgloss"
8+)
9+
10+// Badge renders styled metadata badges.
11+
12+// VisitBadge renders a visit count badge.
13+func (t *Theme) VisitBadge(count int) string {
14+ text := fmt.Sprintf("%d visits", count)
15+ if count == 1 {
16+ text = "1 visit"
17+ }
18+ return t.BadgeMuted.Render(text)
19+}
20+
21+// TimeBadge renders a relative time badge.
22+func (t *Theme) TimeBadge(tm time.Time) string {
23+ text := RelativeTime(tm)
24+ return t.BadgeMuted.Render(text)
25+}
26+
27+// DomainBadge renders a domain badge.
28+func (t *Theme) DomainBadge(domain string) string {
29+ return t.Badge.Render(domain)
30+}
31+
32+// AccentBadge renders a badge with accent color.
33+func (t *Theme) AccentBadge(text string) string {
34+ return t.Badge.Render(text)
35+}
36+
37+// MutedBadge renders a badge with muted colors.
38+func (t *Theme) MutedBadge(text string) string {
39+ return t.BadgeMuted.Render(text)
40+}
41+
42+// StatusBadge renders a status badge with custom colors.
43+func (t *Theme) StatusBadge(text string, fg, bg lipgloss.Color) string {
44+ style := lipgloss.NewStyle().
45+ Foreground(fg).
46+ Background(bg).
47+ Padding(0, 1)
48+ return style.Render(text)
49+}
50+
51+// RelativeTime formats a time as a human-readable relative string.
52+func RelativeTime(tm time.Time) string {
53+ now := time.Now()
54+ diff := now.Sub(tm)
55+
56+ switch {
57+ case diff < time.Minute:
58+ return "just now"
59+ case diff < time.Hour:
60+ mins := int(diff.Minutes())
61+ if mins == 1 {
62+ return "1m ago"
63+ }
64+ return fmt.Sprintf("%dm ago", mins)
65+ case diff < 24*time.Hour:
66+ hours := int(diff.Hours())
67+ if hours == 1 {
68+ return "1h ago"
69+ }
70+ return fmt.Sprintf("%dh ago", hours)
71+ case diff < 7*24*time.Hour:
72+ days := int(diff.Hours() / 24)
73+ if days == 1 {
74+ return "1d ago"
75+ }
76+ return fmt.Sprintf("%dd ago", days)
77+ case diff < 30*24*time.Hour:
78+ weeks := int(diff.Hours() / (24 * 7))
79+ if weeks == 1 {
80+ return "1w ago"
81+ }
82+ return fmt.Sprintf("%dw ago", weeks)
83+ case diff < 365*24*time.Hour:
84+ months := int(diff.Hours() / (24 * 30))
85+ if months == 1 {
86+ return "1mo ago"
87+ }
88+ return fmt.Sprintf("%dmo ago", months)
89+ default:
90+ years := int(diff.Hours() / (24 * 365))
91+ if years == 1 {
92+ return "1y ago"
93+ }
94+ return fmt.Sprintf("%dy ago", years)
95+ }
96+}
97+
98+// TimeCategory returns which timeline category a time belongs to.
99+type TimeCategory int
100+
101+const (
102+ TimeCategoryToday TimeCategory = iota
103+ TimeCategoryYesterday
104+ TimeCategoryThisWeek
105+ TimeCategoryOlder
106+)
107+
108+// GetTimeCategory returns the category for a given time.
109+func GetTimeCategory(tm time.Time) TimeCategory {
110+ now := time.Now()
111+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
112+ yesterday := today.AddDate(0, 0, -1)
113+ weekAgo := today.AddDate(0, 0, -7)
114+
115+ switch {
116+ case tm.After(today) || tm.Equal(today):
117+ return TimeCategoryToday
118+ case tm.After(yesterday) || tm.Equal(yesterday):
119+ return TimeCategoryYesterday
120+ case tm.After(weekAgo):
121+ return TimeCategoryThisWeek
122+ default:
123+ return TimeCategoryOlder
124+ }
125+}
126+
127+// TimeCategoryLabel returns the display label for a time category.
128+func TimeCategoryLabel(cat TimeCategory) string {
129+ switch cat {
130+ case TimeCategoryToday:
131+ return "Today"
132+ case TimeCategoryYesterday:
133+ return "Yesterday"
134+ case TimeCategoryThisWeek:
135+ return "This Week"
136+ case TimeCategoryOlder:
137+ return "Older"
138+ default:
139+ return "Unknown"
140+ }
141+}
@@ -0,0 +1,141 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "time"
6+
7+ "github.com/charmbracelet/lipgloss"
8+)
9+
10+// Badge renders styled metadata badges.
11+
12+// VisitBadge renders a visit count badge.
13+func (t *Theme) VisitBadge(count int) string {
14+ text := fmt.Sprintf("%d visits", count)
15+ if count == 1 {
16+ text = "1 visit"
17+ }
18+ return t.BadgeMuted.Render(text)
19+}
20+
21+// TimeBadge renders a relative time badge.
22+func (t *Theme) TimeBadge(tm time.Time) string {
23+ text := RelativeTime(tm)
24+ return t.BadgeMuted.Render(text)
25+}
26+
27+// DomainBadge renders a domain badge.
28+func (t *Theme) DomainBadge(domain string) string {
29+ return t.Badge.Render(domain)
30+}
31+
32+// AccentBadge renders a badge with accent color.
33+func (t *Theme) AccentBadge(text string) string {
34+ return t.Badge.Render(text)
35+}
36+
37+// MutedBadge renders a badge with muted colors.
38+func (t *Theme) MutedBadge(text string) string {
39+ return t.BadgeMuted.Render(text)
40+}
41+
42+// StatusBadge renders a status badge with custom colors.
43+func (t *Theme) StatusBadge(text string, fg, bg lipgloss.Color) string {
44+ style := lipgloss.NewStyle().
45+ Foreground(fg).
46+ Background(bg).
47+ Padding(0, 1)
48+ return style.Render(text)
49+}
50+
51+// RelativeTime formats a time as a human-readable relative string.
52+func RelativeTime(tm time.Time) string {
53+ now := time.Now()
54+ diff := now.Sub(tm)
55+
56+ switch {
57+ case diff < time.Minute:
58+ return "just now"
59+ case diff < time.Hour:
60+ mins := int(diff.Minutes())
61+ if mins == 1 {
62+ return "1m ago"
63+ }
64+ return fmt.Sprintf("%dm ago", mins)
65+ case diff < 24*time.Hour:
66+ hours := int(diff.Hours())
67+ if hours == 1 {
68+ return "1h ago"
69+ }
70+ return fmt.Sprintf("%dh ago", hours)
71+ case diff < 7*24*time.Hour:
72+ days := int(diff.Hours() / 24)
73+ if days == 1 {
74+ return "1d ago"
75+ }
76+ return fmt.Sprintf("%dd ago", days)
77+ case diff < 30*24*time.Hour:
78+ weeks := int(diff.Hours() / (24 * 7))
79+ if weeks == 1 {
80+ return "1w ago"
81+ }
82+ return fmt.Sprintf("%dw ago", weeks)
83+ case diff < 365*24*time.Hour:
84+ months := int(diff.Hours() / (24 * 30))
85+ if months == 1 {
86+ return "1mo ago"
87+ }
88+ return fmt.Sprintf("%dmo ago", months)
89+ default:
90+ years := int(diff.Hours() / (24 * 365))
91+ if years == 1 {
92+ return "1y ago"
93+ }
94+ return fmt.Sprintf("%dy ago", years)
95+ }
96+}
97+
98+// TimeCategory returns which timeline category a time belongs to.
99+type TimeCategory int
100+
101+const (
102+ TimeCategoryToday TimeCategory = iota
103+ TimeCategoryYesterday
104+ TimeCategoryThisWeek
105+ TimeCategoryOlder
106+)
107+
108+// GetTimeCategory returns the category for a given time.
109+func GetTimeCategory(tm time.Time) TimeCategory {
110+ now := time.Now()
111+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
112+ yesterday := today.AddDate(0, 0, -1)
113+ weekAgo := today.AddDate(0, 0, -7)
114+
115+ switch {
116+ case tm.After(today) || tm.Equal(today):
117+ return TimeCategoryToday
118+ case tm.After(yesterday) || tm.Equal(yesterday):
119+ return TimeCategoryYesterday
120+ case tm.After(weekAgo):
121+ return TimeCategoryThisWeek
122+ default:
123+ return TimeCategoryOlder
124+ }
125+}
126+
127+// TimeCategoryLabel returns the display label for a time category.
128+func TimeCategoryLabel(cat TimeCategory) string {
129+ switch cat {
130+ case TimeCategoryToday:
131+ return "Today"
132+ case TimeCategoryYesterday:
133+ return "Yesterday"
134+ case TimeCategoryThisWeek:
135+ return "This Week"
136+ case TimeCategoryOlder:
137+ return "Older"
138+ default:
139+ return "Unknown"
140+ }
141+}
A internal/cli/styles/cleanup.go +216 -0
@@ -0,0 +1,216 @@
1+package styles
2+
3+import (
4+ "time"
5+
6+ "github.com/charmbracelet/bubbles/key"
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+)
10+
11+// CleanupRange represents a time range for history cleanup.
12+type CleanupRange int
13+
14+const (
15+ CleanupRangeLastHour CleanupRange = iota
16+ CleanupRangeLast24Hours
17+ CleanupRangeLast7Days
18+ CleanupRangeLast30Days
19+ CleanupRangeAll
20+)
21+
22+// Cursor symbols for list items (using Unicode).
23+const (
24+ cursorEmpty = " "
25+ cursorSelected = "\u25b8 " // ▸ Black right-pointing small triangle
26+)
27+
28+// CleanupRangeInfo holds display info for a cleanup range.
29+type CleanupRangeInfo struct {
30+ Range CleanupRange
31+ Label string
32+ Description string
33+}
34+
35+// AllCleanupRanges returns all available cleanup ranges.
36+func AllCleanupRanges() []CleanupRangeInfo {
37+ return []CleanupRangeInfo{
38+ {CleanupRangeLastHour, "Last hour", "Delete entries from the last hour"},
39+ {CleanupRangeLast24Hours, "Last 24 hours", "Delete entries from the last day"},
40+ {CleanupRangeLast7Days, "Last 7 days", "Delete entries from the last week"},
41+ {CleanupRangeLast30Days, "Last 30 days", "Delete entries from the last month"},
42+ {CleanupRangeAll, "All time", "Delete all history entries"},
43+ }
44+}
45+
46+// CutoffTime returns the cutoff time for this range.
47+func (r CleanupRange) CutoffTime() time.Time {
48+ now := time.Now()
49+ switch r {
50+ case CleanupRangeLastHour:
51+ return now.Add(-1 * time.Hour)
52+ case CleanupRangeLast24Hours:
53+ return now.Add(-24 * time.Hour)
54+ case CleanupRangeLast7Days:
55+ return now.Add(-7 * 24 * time.Hour)
56+ case CleanupRangeLast30Days:
57+ return now.Add(-30 * 24 * time.Hour)
58+ case CleanupRangeAll:
59+ return time.Time{} // Zero time = delete all
60+ default:
61+ return time.Time{}
62+ }
63+}
64+
65+// CleanupModel is a cleanup range selection modal.
66+type CleanupModel struct {
67+ Ranges []CleanupRangeInfo
68+ Selected int
69+ Confirmed bool
70+ Cancelled bool
71+ theme *Theme
72+}
73+
74+// CleanupKeyMap defines keybindings for cleanup modal.
75+type CleanupKeyMap struct {
76+ Up key.Binding
77+ Down key.Binding
78+ Confirm key.Binding
79+ Cancel key.Binding
80+}
81+
82+// DefaultCleanupKeyMap returns default keybindings.
83+func DefaultCleanupKeyMap() CleanupKeyMap {
84+ return CleanupKeyMap{
85+ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
86+ Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
87+ Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
88+ Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
89+ }
90+}
91+
92+// NewCleanup creates a new cleanup modal.
93+func NewCleanup(theme *Theme) CleanupModel {
94+ return CleanupModel{
95+ Ranges: AllCleanupRanges(),
96+ Selected: 0,
97+ theme: theme,
98+ }
99+}
100+
101+// Init implements tea.Model.
102+func (m CleanupModel) Init() tea.Cmd {
103+ return nil
104+}
105+
106+// Update implements tea.Model.
107+func (m CleanupModel) Update(msg tea.Msg) (CleanupModel, tea.Cmd) {
108+ keys := DefaultCleanupKeyMap()
109+
110+ switch msg := msg.(type) {
111+ case tea.KeyMsg:
112+ switch {
113+ case key.Matches(msg, keys.Up):
114+ if m.Selected > 0 {
115+ m.Selected--
116+ }
117+ case key.Matches(msg, keys.Down):
118+ if m.Selected < len(m.Ranges)-1 {
119+ m.Selected++
120+ }
121+ case key.Matches(msg, keys.Confirm):
122+ m.Confirmed = true
123+ case key.Matches(msg, keys.Cancel):
124+ m.Cancelled = true
125+ }
126+ }
127+
128+ return m, nil
129+}
130+
131+// View implements tea.Model.
132+func (m CleanupModel) View() string {
133+ t := m.theme
134+
135+ // Build header
136+ header := t.Title.Copy().
137+ Foreground(t.Error).
138+ Render("Clear History")
139+
140+ // Build range list
141+ var rows []string
142+ for i, r := range m.Ranges {
143+ cursor := cursorEmpty
144+ style := t.ListItem
145+ if i == m.Selected {
146+ cursor = cursorSelected
147+ style = t.ListItemSelected
148+ }
149+
150+ label := style.Render(r.Label)
151+ desc := t.Subtle.Render(r.Description)
152+
153+ row := lipgloss.JoinHorizontal(
154+ lipgloss.Left,
155+ t.Highlight.Render(cursor),
156+ lipgloss.JoinVertical(lipgloss.Left, label, desc),
157+ )
158+ rows = append(rows, row)
159+ }
160+
161+ list := lipgloss.JoinVertical(lipgloss.Left, rows...)
162+
163+ // Warning for "All time"
164+ var warning string
165+ if m.Ranges[m.Selected].Range == CleanupRangeAll {
166+ warning = t.ErrorStyle.Render("⚠ This will delete ALL history entries!")
167+ }
168+
169+ // Help text
170+ help := t.Subtle.Render("↑/↓ to select • enter to confirm • esc to cancel")
171+
172+ // Combine
173+ var content string
174+ if warning != "" {
175+ content = lipgloss.JoinVertical(
176+ lipgloss.Left,
177+ header,
178+ "",
179+ list,
180+ "",
181+ warning,
182+ "",
183+ help,
184+ )
185+ } else {
186+ content = lipgloss.JoinVertical(
187+ lipgloss.Left,
188+ header,
189+ "",
190+ list,
191+ "",
192+ help,
193+ )
194+ }
195+
196+ return t.Box.Render(content)
197+}
198+
199+// Done returns true if the modal is complete.
200+func (m CleanupModel) Done() bool {
201+ return m.Confirmed || m.Cancelled
202+}
203+
204+// SelectedRange returns the selected cleanup range.
205+func (m CleanupModel) SelectedRange() CleanupRange {
206+ if m.Selected >= 0 && m.Selected < len(m.Ranges) {
207+ return m.Ranges[m.Selected].Range
208+ }
209+ return CleanupRangeAll
210+}
211+
212+// CleanupResultMsg is sent when cleanup modal completes.
213+type CleanupResultMsg struct {
214+ Confirmed bool
215+ Range CleanupRange
216+}
@@ -0,0 +1,216 @@
1+package styles
2+
3+import (
4+ "time"
5+
6+ "github.com/charmbracelet/bubbles/key"
7+ tea "github.com/charmbracelet/bubbletea"
8+ "github.com/charmbracelet/lipgloss"
9+)
10+
11+// CleanupRange represents a time range for history cleanup.
12+type CleanupRange int
13+
14+const (
15+ CleanupRangeLastHour CleanupRange = iota
16+ CleanupRangeLast24Hours
17+ CleanupRangeLast7Days
18+ CleanupRangeLast30Days
19+ CleanupRangeAll
20+)
21+
22+// Cursor symbols for list items (using Unicode).
23+const (
24+ cursorEmpty = " "
25+ cursorSelected = "\u25b8 " // ▸ Black right-pointing small triangle
26+)
27+
28+// CleanupRangeInfo holds display info for a cleanup range.
29+type CleanupRangeInfo struct {
30+ Range CleanupRange
31+ Label string
32+ Description string
33+}
34+
35+// AllCleanupRanges returns all available cleanup ranges.
36+func AllCleanupRanges() []CleanupRangeInfo {
37+ return []CleanupRangeInfo{
38+ {CleanupRangeLastHour, "Last hour", "Delete entries from the last hour"},
39+ {CleanupRangeLast24Hours, "Last 24 hours", "Delete entries from the last day"},
40+ {CleanupRangeLast7Days, "Last 7 days", "Delete entries from the last week"},
41+ {CleanupRangeLast30Days, "Last 30 days", "Delete entries from the last month"},
42+ {CleanupRangeAll, "All time", "Delete all history entries"},
43+ }
44+}
45+
46+// CutoffTime returns the cutoff time for this range.
47+func (r CleanupRange) CutoffTime() time.Time {
48+ now := time.Now()
49+ switch r {
50+ case CleanupRangeLastHour:
51+ return now.Add(-1 * time.Hour)
52+ case CleanupRangeLast24Hours:
53+ return now.Add(-24 * time.Hour)
54+ case CleanupRangeLast7Days:
55+ return now.Add(-7 * 24 * time.Hour)
56+ case CleanupRangeLast30Days:
57+ return now.Add(-30 * 24 * time.Hour)
58+ case CleanupRangeAll:
59+ return time.Time{} // Zero time = delete all
60+ default:
61+ return time.Time{}
62+ }
63+}
64+
65+// CleanupModel is a cleanup range selection modal.
66+type CleanupModel struct {
67+ Ranges []CleanupRangeInfo
68+ Selected int
69+ Confirmed bool
70+ Cancelled bool
71+ theme *Theme
72+}
73+
74+// CleanupKeyMap defines keybindings for cleanup modal.
75+type CleanupKeyMap struct {
76+ Up key.Binding
77+ Down key.Binding
78+ Confirm key.Binding
79+ Cancel key.Binding
80+}
81+
82+// DefaultCleanupKeyMap returns default keybindings.
83+func DefaultCleanupKeyMap() CleanupKeyMap {
84+ return CleanupKeyMap{
85+ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
86+ Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
87+ Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
88+ Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
89+ }
90+}
91+
92+// NewCleanup creates a new cleanup modal.
93+func NewCleanup(theme *Theme) CleanupModel {
94+ return CleanupModel{
95+ Ranges: AllCleanupRanges(),
96+ Selected: 0,
97+ theme: theme,
98+ }
99+}
100+
101+// Init implements tea.Model.
102+func (m CleanupModel) Init() tea.Cmd {
103+ return nil
104+}
105+
106+// Update implements tea.Model.
107+func (m CleanupModel) Update(msg tea.Msg) (CleanupModel, tea.Cmd) {
108+ keys := DefaultCleanupKeyMap()
109+
110+ switch msg := msg.(type) {
111+ case tea.KeyMsg:
112+ switch {
113+ case key.Matches(msg, keys.Up):
114+ if m.Selected > 0 {
115+ m.Selected--
116+ }
117+ case key.Matches(msg, keys.Down):
118+ if m.Selected < len(m.Ranges)-1 {
119+ m.Selected++
120+ }
121+ case key.Matches(msg, keys.Confirm):
122+ m.Confirmed = true
123+ case key.Matches(msg, keys.Cancel):
124+ m.Cancelled = true
125+ }
126+ }
127+
128+ return m, nil
129+}
130+
131+// View implements tea.Model.
132+func (m CleanupModel) View() string {
133+ t := m.theme
134+
135+ // Build header
136+ header := t.Title.Copy().
137+ Foreground(t.Error).
138+ Render("Clear History")
139+
140+ // Build range list
141+ var rows []string
142+ for i, r := range m.Ranges {
143+ cursor := cursorEmpty
144+ style := t.ListItem
145+ if i == m.Selected {
146+ cursor = cursorSelected
147+ style = t.ListItemSelected
148+ }
149+
150+ label := style.Render(r.Label)
151+ desc := t.Subtle.Render(r.Description)
152+
153+ row := lipgloss.JoinHorizontal(
154+ lipgloss.Left,
155+ t.Highlight.Render(cursor),
156+ lipgloss.JoinVertical(lipgloss.Left, label, desc),
157+ )
158+ rows = append(rows, row)
159+ }
160+
161+ list := lipgloss.JoinVertical(lipgloss.Left, rows...)
162+
163+ // Warning for "All time"
164+ var warning string
165+ if m.Ranges[m.Selected].Range == CleanupRangeAll {
166+ warning = t.ErrorStyle.Render("⚠ This will delete ALL history entries!")
167+ }
168+
169+ // Help text
170+ help := t.Subtle.Render("↑/↓ to select • enter to confirm • esc to cancel")
171+
172+ // Combine
173+ var content string
174+ if warning != "" {
175+ content = lipgloss.JoinVertical(
176+ lipgloss.Left,
177+ header,
178+ "",
179+ list,
180+ "",
181+ warning,
182+ "",
183+ help,
184+ )
185+ } else {
186+ content = lipgloss.JoinVertical(
187+ lipgloss.Left,
188+ header,
189+ "",
190+ list,
191+ "",
192+ help,
193+ )
194+ }
195+
196+ return t.Box.Render(content)
197+}
198+
199+// Done returns true if the modal is complete.
200+func (m CleanupModel) Done() bool {
201+ return m.Confirmed || m.Cancelled
202+}
203+
204+// SelectedRange returns the selected cleanup range.
205+func (m CleanupModel) SelectedRange() CleanupRange {
206+ if m.Selected >= 0 && m.Selected < len(m.Ranges) {
207+ return m.Ranges[m.Selected].Range
208+ }
209+ return CleanupRangeAll
210+}
211+
212+// CleanupResultMsg is sent when cleanup modal completes.
213+type CleanupResultMsg struct {
214+ Confirmed bool
215+ Range CleanupRange
216+}
A internal/cli/styles/confirm.go +128 -0
@@ -0,0 +1,128 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+// ConfirmModel is a yes/no confirmation dialog.
10+type ConfirmModel struct {
11+ Message string
12+ Yes bool // Current selection
13+ Confirmed bool // User pressed enter
14+ Cancelled bool // User pressed escape
15+ theme *Theme
16+}
17+
18+// ConfirmKeyMap defines keybindings for the confirm dialog.
19+type ConfirmKeyMap struct {
20+ Yes key.Binding
21+ No key.Binding
22+ Left key.Binding
23+ Right key.Binding
24+ Confirm key.Binding
25+ Cancel key.Binding
26+}
27+
28+// DefaultConfirmKeyMap returns the default keybindings.
29+func DefaultConfirmKeyMap() ConfirmKeyMap {
30+ return ConfirmKeyMap{
31+ Yes: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "yes")),
32+ No: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "no")),
33+ Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "no")),
34+ Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "yes")),
35+ Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
36+ Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
37+ }
38+}
39+
40+// NewConfirm creates a new confirmation dialog.
41+func NewConfirm(theme *Theme, message string) ConfirmModel {
42+ return ConfirmModel{
43+ Message: message,
44+ Yes: false, // Default to "No"
45+ theme: theme,
46+ }
47+}
48+
49+// Init implements tea.Model.
50+func (m ConfirmModel) Init() tea.Cmd {
51+ return nil
52+}
53+
54+// Update implements tea.Model.
55+func (m ConfirmModel) Update(msg tea.Msg) (ConfirmModel, tea.Cmd) {
56+ keys := DefaultConfirmKeyMap()
57+
58+ switch msg := msg.(type) {
59+ case tea.KeyMsg:
60+ switch {
61+ case key.Matches(msg, keys.Yes):
62+ m.Yes = true
63+ case key.Matches(msg, keys.No):
64+ m.Yes = false
65+ case key.Matches(msg, keys.Left):
66+ m.Yes = false
67+ case key.Matches(msg, keys.Right):
68+ m.Yes = true
69+ case key.Matches(msg, keys.Confirm):
70+ m.Confirmed = true
71+ case key.Matches(msg, keys.Cancel):
72+ m.Cancelled = true
73+ }
74+ }
75+
76+ return m, nil
77+}
78+
79+// View implements tea.Model.
80+func (m ConfirmModel) View() string {
81+ t := m.theme
82+
83+ // Build buttons
84+ yesStyle := t.InactiveTab
85+ noStyle := t.InactiveTab
86+
87+ if m.Yes {
88+ yesStyle = t.ActiveTab
89+ } else {
90+ noStyle = t.ActiveTab
91+ }
92+
93+ yes := yesStyle.Render(" Yes ")
94+ no := noStyle.Render(" No ")
95+
96+ buttons := lipgloss.JoinHorizontal(lipgloss.Center, no, " ", yes)
97+
98+ // Build dialog box
99+ content := lipgloss.JoinVertical(
100+ lipgloss.Center,
101+ t.Title.Render(m.Message),
102+ "",
103+ buttons,
104+ "",
105+ t.Subtle.Render("y/n or ←/→ to select • enter to confirm • esc to cancel"),
106+ )
107+
108+ return t.Box.Render(content)
109+}
110+
111+// Done returns true if the dialog is complete.
112+func (m ConfirmModel) Done() bool {
113+ return m.Confirmed || m.Cancelled
114+}
115+
116+// Result returns true if user confirmed "Yes".
117+func (m ConfirmModel) Result() bool {
118+ return m.Confirmed && m.Yes
119+}
120+
121+// ConfirmResult wraps the confirm dialog result for parent models.
122+type ConfirmResult struct {
123+ Confirmed bool
124+ Yes bool
125+}
126+
127+// ConfirmResultMsg is sent when the confirm dialog completes.
128+type ConfirmResultMsg ConfirmResult
@@ -0,0 +1,128 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/bubbles/key"
5+ tea "github.com/charmbracelet/bubbletea"
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+// ConfirmModel is a yes/no confirmation dialog.
10+type ConfirmModel struct {
11+ Message string
12+ Yes bool // Current selection
13+ Confirmed bool // User pressed enter
14+ Cancelled bool // User pressed escape
15+ theme *Theme
16+}
17+
18+// ConfirmKeyMap defines keybindings for the confirm dialog.
19+type ConfirmKeyMap struct {
20+ Yes key.Binding
21+ No key.Binding
22+ Left key.Binding
23+ Right key.Binding
24+ Confirm key.Binding
25+ Cancel key.Binding
26+}
27+
28+// DefaultConfirmKeyMap returns the default keybindings.
29+func DefaultConfirmKeyMap() ConfirmKeyMap {
30+ return ConfirmKeyMap{
31+ Yes: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "yes")),
32+ No: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "no")),
33+ Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "no")),
34+ Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "yes")),
35+ Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
36+ Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
37+ }
38+}
39+
40+// NewConfirm creates a new confirmation dialog.
41+func NewConfirm(theme *Theme, message string) ConfirmModel {
42+ return ConfirmModel{
43+ Message: message,
44+ Yes: false, // Default to "No"
45+ theme: theme,
46+ }
47+}
48+
49+// Init implements tea.Model.
50+func (m ConfirmModel) Init() tea.Cmd {
51+ return nil
52+}
53+
54+// Update implements tea.Model.
55+func (m ConfirmModel) Update(msg tea.Msg) (ConfirmModel, tea.Cmd) {
56+ keys := DefaultConfirmKeyMap()
57+
58+ switch msg := msg.(type) {
59+ case tea.KeyMsg:
60+ switch {
61+ case key.Matches(msg, keys.Yes):
62+ m.Yes = true
63+ case key.Matches(msg, keys.No):
64+ m.Yes = false
65+ case key.Matches(msg, keys.Left):
66+ m.Yes = false
67+ case key.Matches(msg, keys.Right):
68+ m.Yes = true
69+ case key.Matches(msg, keys.Confirm):
70+ m.Confirmed = true
71+ case key.Matches(msg, keys.Cancel):
72+ m.Cancelled = true
73+ }
74+ }
75+
76+ return m, nil
77+}
78+
79+// View implements tea.Model.
80+func (m ConfirmModel) View() string {
81+ t := m.theme
82+
83+ // Build buttons
84+ yesStyle := t.InactiveTab
85+ noStyle := t.InactiveTab
86+
87+ if m.Yes {
88+ yesStyle = t.ActiveTab
89+ } else {
90+ noStyle = t.ActiveTab
91+ }
92+
93+ yes := yesStyle.Render(" Yes ")
94+ no := noStyle.Render(" No ")
95+
96+ buttons := lipgloss.JoinHorizontal(lipgloss.Center, no, " ", yes)
97+
98+ // Build dialog box
99+ content := lipgloss.JoinVertical(
100+ lipgloss.Center,
101+ t.Title.Render(m.Message),
102+ "",
103+ buttons,
104+ "",
105+ t.Subtle.Render("y/n or ←/→ to select • enter to confirm • esc to cancel"),
106+ )
107+
108+ return t.Box.Render(content)
109+}
110+
111+// Done returns true if the dialog is complete.
112+func (m ConfirmModel) Done() bool {
113+ return m.Confirmed || m.Cancelled
114+}
115+
116+// Result returns true if user confirmed "Yes".
117+func (m ConfirmModel) Result() bool {
118+ return m.Confirmed && m.Yes
119+}
120+
121+// ConfirmResult wraps the confirm dialog result for parent models.
122+type ConfirmResult struct {
123+ Confirmed bool
124+ Yes bool
125+}
126+
127+// ConfirmResultMsg is sent when the confirm dialog completes.
128+type ConfirmResultMsg ConfirmResult
A internal/cli/styles/help.go +174 -0
@@ -0,0 +1,174 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/bubbles/help"
5+ "github.com/charmbracelet/bubbles/key"
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+// KeyMap defines keybindings that can be rendered as help.
10+type KeyMap interface {
11+ ShortHelp() []key.Binding
12+ FullHelp() [][]key.Binding
13+}
14+
15+// HistoryKeyMap defines keybindings for history browser.
16+type HistoryKeyMap struct {
17+ Up key.Binding
18+ Down key.Binding
19+ Tab1 key.Binding
20+ Tab2 key.Binding
21+ Tab3 key.Binding
22+ Tab4 key.Binding
23+ NextTab key.Binding
24+ PrevTab key.Binding
25+ Search key.Binding
26+ Open key.Binding
27+ Delete key.Binding
28+ DeleteDomain key.Binding
29+ Cleanup key.Binding
30+ Filter key.Binding
31+ Help key.Binding
32+ Quit key.Binding
33+}
34+
35+// ShortHelp returns keybindings to show in compact help.
36+func (k HistoryKeyMap) ShortHelp() []key.Binding {
37+ return []key.Binding{k.Up, k.Down, k.Open, k.Search, k.Help, k.Quit}
38+}
39+
40+// FullHelp returns keybindings for expanded help.
41+func (k HistoryKeyMap) FullHelp() [][]key.Binding {
42+ return [][]key.Binding{
43+ {k.Up, k.Down, k.Open},
44+ {k.NextTab, k.PrevTab, k.Tab1, k.Tab2, k.Tab3, k.Tab4},
45+ {k.Search, k.Filter},
46+ {k.Delete, k.DeleteDomain, k.Cleanup},
47+ {k.Help, k.Quit},
48+ }
49+}
50+
51+// DefaultHistoryKeyMap returns the default history keybindings.
52+func DefaultHistoryKeyMap() HistoryKeyMap {
53+ return HistoryKeyMap{
54+ Up: key.NewBinding(
55+ key.WithKeys("up", "k"),
56+ key.WithHelp("↑/k", "up"),
57+ ),
58+ Down: key.NewBinding(
59+ key.WithKeys("down", "j"),
60+ key.WithHelp("↓/j", "down"),
61+ ),
62+ Tab1: key.NewBinding(
63+ key.WithKeys("1"),
64+ key.WithHelp("1", "today"),
65+ ),
66+ Tab2: key.NewBinding(
67+ key.WithKeys("2"),
68+ key.WithHelp("2", "yesterday"),
69+ ),
70+ Tab3: key.NewBinding(
71+ key.WithKeys("3"),
72+ key.WithHelp("3", "this week"),
73+ ),
74+ Tab4: key.NewBinding(
75+ key.WithKeys("4"),
76+ key.WithHelp("4", "older"),
77+ ),
78+ NextTab: key.NewBinding(
79+ key.WithKeys("tab"),
80+ key.WithHelp("tab", "next tab"),
81+ ),
82+ PrevTab: key.NewBinding(
83+ key.WithKeys("shift+tab"),
84+ key.WithHelp("S-tab", "prev tab"),
85+ ),
86+ Search: key.NewBinding(
87+ key.WithKeys("/"),
88+ key.WithHelp("/", "search"),
89+ ),
90+ Open: key.NewBinding(
91+ key.WithKeys("enter"),
92+ key.WithHelp("enter", "open"),
93+ ),
94+ Delete: key.NewBinding(
95+ key.WithKeys("d"),
96+ key.WithHelp("d", "delete"),
97+ ),
98+ DeleteDomain: key.NewBinding(
99+ key.WithKeys("D"),
100+ key.WithHelp("D", "delete domain"),
101+ ),
102+ Cleanup: key.NewBinding(
103+ key.WithKeys("c"),
104+ key.WithHelp("c", "cleanup"),
105+ ),
106+ Filter: key.NewBinding(
107+ key.WithKeys("f"),
108+ key.WithHelp("f", "filter domain"),
109+ ),
110+ Help: key.NewBinding(
111+ key.WithKeys("?"),
112+ key.WithHelp("?", "help"),
113+ ),
114+ Quit: key.NewBinding(
115+ key.WithKeys("q", "ctrl+c"),
116+ key.WithHelp("q", "quit"),
117+ ),
118+ }
119+}
120+
121+// NewStyledHelp creates a themed help model.
122+func NewStyledHelp(theme *Theme) help.Model {
123+ h := help.New()
124+ h.Styles.ShortKey = lipgloss.NewStyle().Foreground(theme.Accent)
125+ h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(theme.Muted)
126+ h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(theme.Border)
127+ h.Styles.FullKey = lipgloss.NewStyle().Foreground(theme.Accent)
128+ h.Styles.FullDesc = lipgloss.NewStyle().Foreground(theme.Text)
129+ h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(theme.Border)
130+ return h
131+}
132+
133+// DmenuKeyMap defines keybindings for dmenu browser.
134+type DmenuKeyMap struct {
135+ Up key.Binding
136+ Down key.Binding
137+ Open key.Binding
138+ Cancel key.Binding
139+}
140+
141+// ShortHelp returns keybindings to show in compact help.
142+func (k DmenuKeyMap) ShortHelp() []key.Binding {
143+ return []key.Binding{k.Up, k.Down, k.Open, k.Cancel}
144+}
145+
146+// FullHelp returns keybindings for expanded help.
147+func (k DmenuKeyMap) FullHelp() [][]key.Binding {
148+ return [][]key.Binding{
149+ {k.Up, k.Down},
150+ {k.Open, k.Cancel},
151+ }
152+}
153+
154+// DefaultDmenuKeyMap returns the default dmenu keybindings.
155+func DefaultDmenuKeyMap() DmenuKeyMap {
156+ return DmenuKeyMap{
157+ Up: key.NewBinding(
158+ key.WithKeys("up", "k"),
159+ key.WithHelp("↑/k", "up"),
160+ ),
161+ Down: key.NewBinding(
162+ key.WithKeys("down", "j"),
163+ key.WithHelp("↓/j", "down"),
164+ ),
165+ Open: key.NewBinding(
166+ key.WithKeys("enter"),
167+ key.WithHelp("enter", "open"),
168+ ),
169+ Cancel: key.NewBinding(
170+ key.WithKeys("esc", "ctrl+c"),
171+ key.WithHelp("esc", "cancel"),
172+ ),
173+ }
174+}
@@ -0,0 +1,174 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/bubbles/help"
5+ "github.com/charmbracelet/bubbles/key"
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+// KeyMap defines keybindings that can be rendered as help.
10+type KeyMap interface {
11+ ShortHelp() []key.Binding
12+ FullHelp() [][]key.Binding
13+}
14+
15+// HistoryKeyMap defines keybindings for history browser.
16+type HistoryKeyMap struct {
17+ Up key.Binding
18+ Down key.Binding
19+ Tab1 key.Binding
20+ Tab2 key.Binding
21+ Tab3 key.Binding
22+ Tab4 key.Binding
23+ NextTab key.Binding
24+ PrevTab key.Binding
25+ Search key.Binding
26+ Open key.Binding
27+ Delete key.Binding
28+ DeleteDomain key.Binding
29+ Cleanup key.Binding
30+ Filter key.Binding
31+ Help key.Binding
32+ Quit key.Binding
33+}
34+
35+// ShortHelp returns keybindings to show in compact help.
36+func (k HistoryKeyMap) ShortHelp() []key.Binding {
37+ return []key.Binding{k.Up, k.Down, k.Open, k.Search, k.Help, k.Quit}
38+}
39+
40+// FullHelp returns keybindings for expanded help.
41+func (k HistoryKeyMap) FullHelp() [][]key.Binding {
42+ return [][]key.Binding{
43+ {k.Up, k.Down, k.Open},
44+ {k.NextTab, k.PrevTab, k.Tab1, k.Tab2, k.Tab3, k.Tab4},
45+ {k.Search, k.Filter},
46+ {k.Delete, k.DeleteDomain, k.Cleanup},
47+ {k.Help, k.Quit},
48+ }
49+}
50+
51+// DefaultHistoryKeyMap returns the default history keybindings.
52+func DefaultHistoryKeyMap() HistoryKeyMap {
53+ return HistoryKeyMap{
54+ Up: key.NewBinding(
55+ key.WithKeys("up", "k"),
56+ key.WithHelp("↑/k", "up"),
57+ ),
58+ Down: key.NewBinding(
59+ key.WithKeys("down", "j"),
60+ key.WithHelp("↓/j", "down"),
61+ ),
62+ Tab1: key.NewBinding(
63+ key.WithKeys("1"),
64+ key.WithHelp("1", "today"),
65+ ),
66+ Tab2: key.NewBinding(
67+ key.WithKeys("2"),
68+ key.WithHelp("2", "yesterday"),
69+ ),
70+ Tab3: key.NewBinding(
71+ key.WithKeys("3"),
72+ key.WithHelp("3", "this week"),
73+ ),
74+ Tab4: key.NewBinding(
75+ key.WithKeys("4"),
76+ key.WithHelp("4", "older"),
77+ ),
78+ NextTab: key.NewBinding(
79+ key.WithKeys("tab"),
80+ key.WithHelp("tab", "next tab"),
81+ ),
82+ PrevTab: key.NewBinding(
83+ key.WithKeys("shift+tab"),
84+ key.WithHelp("S-tab", "prev tab"),
85+ ),
86+ Search: key.NewBinding(
87+ key.WithKeys("/"),
88+ key.WithHelp("/", "search"),
89+ ),
90+ Open: key.NewBinding(
91+ key.WithKeys("enter"),
92+ key.WithHelp("enter", "open"),
93+ ),
94+ Delete: key.NewBinding(
95+ key.WithKeys("d"),
96+ key.WithHelp("d", "delete"),
97+ ),
98+ DeleteDomain: key.NewBinding(
99+ key.WithKeys("D"),
100+ key.WithHelp("D", "delete domain"),
101+ ),
102+ Cleanup: key.NewBinding(
103+ key.WithKeys("c"),
104+ key.WithHelp("c", "cleanup"),
105+ ),
106+ Filter: key.NewBinding(
107+ key.WithKeys("f"),
108+ key.WithHelp("f", "filter domain"),
109+ ),
110+ Help: key.NewBinding(
111+ key.WithKeys("?"),
112+ key.WithHelp("?", "help"),
113+ ),
114+ Quit: key.NewBinding(
115+ key.WithKeys("q", "ctrl+c"),
116+ key.WithHelp("q", "quit"),
117+ ),
118+ }
119+}
120+
121+// NewStyledHelp creates a themed help model.
122+func NewStyledHelp(theme *Theme) help.Model {
123+ h := help.New()
124+ h.Styles.ShortKey = lipgloss.NewStyle().Foreground(theme.Accent)
125+ h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(theme.Muted)
126+ h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(theme.Border)
127+ h.Styles.FullKey = lipgloss.NewStyle().Foreground(theme.Accent)
128+ h.Styles.FullDesc = lipgloss.NewStyle().Foreground(theme.Text)
129+ h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(theme.Border)
130+ return h
131+}
132+
133+// DmenuKeyMap defines keybindings for dmenu browser.
134+type DmenuKeyMap struct {
135+ Up key.Binding
136+ Down key.Binding
137+ Open key.Binding
138+ Cancel key.Binding
139+}
140+
141+// ShortHelp returns keybindings to show in compact help.
142+func (k DmenuKeyMap) ShortHelp() []key.Binding {
143+ return []key.Binding{k.Up, k.Down, k.Open, k.Cancel}
144+}
145+
146+// FullHelp returns keybindings for expanded help.
147+func (k DmenuKeyMap) FullHelp() [][]key.Binding {
148+ return [][]key.Binding{
149+ {k.Up, k.Down},
150+ {k.Open, k.Cancel},
151+ }
152+}
153+
154+// DefaultDmenuKeyMap returns the default dmenu keybindings.
155+func DefaultDmenuKeyMap() DmenuKeyMap {
156+ return DmenuKeyMap{
157+ Up: key.NewBinding(
158+ key.WithKeys("up", "k"),
159+ key.WithHelp("↑/k", "up"),
160+ ),
161+ Down: key.NewBinding(
162+ key.WithKeys("down", "j"),
163+ key.WithHelp("↓/j", "down"),
164+ ),
165+ Open: key.NewBinding(
166+ key.WithKeys("enter"),
167+ key.WithHelp("enter", "open"),
168+ ),
169+ Cancel: key.NewBinding(
170+ key.WithKeys("esc", "ctrl+c"),
171+ key.WithHelp("esc", "cancel"),
172+ ),
173+ }
174+}
A internal/cli/styles/input.go +43 -0
@@ -0,0 +1,43 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/bubbles/textinput"
5+ "github.com/charmbracelet/lipgloss"
6+)
7+
8+// NewStyledInput creates a themed text input.
9+func NewStyledInput(theme *Theme, placeholder string) textinput.Model {
10+ ti := textinput.New()
11+ ti.Placeholder = placeholder
12+ ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(theme.Muted)
13+ ti.TextStyle = lipgloss.NewStyle().Foreground(theme.Text)
14+ ti.Cursor.Style = lipgloss.NewStyle().Foreground(theme.Accent)
15+ ti.PromptStyle = lipgloss.NewStyle().Foreground(theme.Accent)
16+ ti.Prompt = "/ "
17+ return ti
18+}
19+
20+// NewSearchInput creates a search-specific input.
21+func NewSearchInput(theme *Theme) textinput.Model {
22+ ti := NewStyledInput(theme, "Search...")
23+ ti.Prompt = "🔍 "
24+ ti.CharLimit = 256
25+ return ti
26+}
27+
28+// NewURLInput creates a URL input field.
29+func NewURLInput(theme *Theme) textinput.Model {
30+ ti := NewStyledInput(theme, "Enter URL or search query...")
31+ ti.Prompt = "→ "
32+ ti.CharLimit = 2048
33+ return ti
34+}
35+
36+// InputBox wraps a text input in a styled box.
37+func (t *Theme) InputBox(input string, focused bool) string {
38+ style := t.Input
39+ if focused {
40+ style = t.InputFocused
41+ }
42+ return style.Render(input)
43+}
@@ -0,0 +1,43 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/bubbles/textinput"
5+ "github.com/charmbracelet/lipgloss"
6+)
7+
8+// NewStyledInput creates a themed text input.
9+func NewStyledInput(theme *Theme, placeholder string) textinput.Model {
10+ ti := textinput.New()
11+ ti.Placeholder = placeholder
12+ ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(theme.Muted)
13+ ti.TextStyle = lipgloss.NewStyle().Foreground(theme.Text)
14+ ti.Cursor.Style = lipgloss.NewStyle().Foreground(theme.Accent)
15+ ti.PromptStyle = lipgloss.NewStyle().Foreground(theme.Accent)
16+ ti.Prompt = "/ "
17+ return ti
18+}
19+
20+// NewSearchInput creates a search-specific input.
21+func NewSearchInput(theme *Theme) textinput.Model {
22+ ti := NewStyledInput(theme, "Search...")
23+ ti.Prompt = "🔍 "
24+ ti.CharLimit = 256
25+ return ti
26+}
27+
28+// NewURLInput creates a URL input field.
29+func NewURLInput(theme *Theme) textinput.Model {
30+ ti := NewStyledInput(theme, "Enter URL or search query...")
31+ ti.Prompt = "→ "
32+ ti.CharLimit = 2048
33+ return ti
34+}
35+
36+// InputBox wraps a text input in a styled box.
37+func (t *Theme) InputBox(input string, focused bool) string {
38+ style := t.Input
39+ if focused {
40+ style = t.InputFocused
41+ }
42+ return style.Render(input)
43+}
A internal/cli/styles/list.go +228 -0
@@ -0,0 +1,228 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "io"
6+ "strings"
7+ "time"
8+
9+ "github.com/charmbracelet/bubbles/list"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+)
13+
14+// HistoryItem represents a history entry for the list.
15+type HistoryItem struct {
16+ ID int64
17+ URL string
18+ Title string
19+ Domain string
20+ VisitCount int
21+ LastVisited time.Time
22+}
23+
24+// FilterValue implements list.Item.
25+func (i HistoryItem) FilterValue() string {
26+ return i.Title + " " + i.URL
27+}
28+
29+// Title implements list.DefaultItem.
30+func (i HistoryItem) TitleValue() string {
31+ if i.Title != "" {
32+ return i.Title
33+ }
34+ return i.URL
35+}
36+
37+// Description implements list.DefaultItem.
38+func (i HistoryItem) DescriptionValue() string {
39+ return i.URL
40+}
41+
42+// HistoryDelegate renders history items with theme styling.
43+type HistoryDelegate struct {
44+ Theme *Theme
45+ ShowDomain bool
46+}
47+
48+// NewHistoryDelegate creates a themed history list delegate.
49+func NewHistoryDelegate(theme *Theme) HistoryDelegate {
50+ return HistoryDelegate{
51+ Theme: theme,
52+ ShowDomain: true,
53+ }
54+}
55+
56+// Height returns the height of each item.
57+func (d HistoryDelegate) Height() int {
58+ return 2
59+}
60+
61+// Spacing returns the spacing between items.
62+func (d HistoryDelegate) Spacing() int {
63+ return 0
64+}
65+
66+// Update handles item-level events.
67+func (d HistoryDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd {
68+ return nil
69+}
70+
71+// Render renders a single list item.
72+func (d HistoryDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
73+ hi, ok := item.(HistoryItem)
74+ if !ok {
75+ return
76+ }
77+
78+ t := d.Theme
79+ isSelected := index == m.Index()
80+
81+ // Title line
82+ title := hi.TitleValue()
83+ if len(title) > 60 {
84+ title = title[:57] + "..."
85+ }
86+
87+ // URL/description line
88+ url := hi.URL
89+ if len(url) > 50 {
90+ url = url[:47] + "..."
91+ }
92+
93+ // Metadata badges
94+ visitBadge := t.MutedBadge(fmt.Sprintf("%d", hi.VisitCount))
95+ timeBadge := t.MutedBadge(RelativeTime(hi.LastVisited))
96+
97+ // Build cursor
98+ cursor := cursorEmpty
99+ if isSelected {
100+ cursor = cursorSelected
101+ }
102+
103+ // Apply styles
104+ cursorStyle := t.Highlight
105+ titleStyle := t.ListItemTitle
106+ urlStyle := t.ListItemDesc
107+
108+ if isSelected {
109+ titleStyle = titleStyle.Copy().Foreground(t.Accent).Bold(true)
110+ urlStyle = urlStyle.Copy().Foreground(t.Text)
111+ }
112+
113+ // First line: cursor + title
114+ line1 := lipgloss.JoinHorizontal(
115+ lipgloss.Left,
116+ cursorStyle.Render(cursor),
117+ titleStyle.Render(title),
118+ )
119+
120+ // Second line: url + badges
121+ meta := lipgloss.JoinHorizontal(
122+ lipgloss.Left,
123+ visitBadge,
124+ " ",
125+ timeBadge,
126+ )
127+
128+ line2 := lipgloss.JoinHorizontal(
129+ lipgloss.Left,
130+ strings.Repeat(" ", 3), // Indent under cursor
131+ urlStyle.Render(url),
132+ " ",
133+ meta,
134+ )
135+
136+ _, _ = fmt.Fprintf(w, "%s\n%s", line1, line2)
137+}
138+
139+// NewHistoryList creates a themed list for history items.
140+func NewHistoryList(theme *Theme, items []HistoryItem, width, height int) list.Model {
141+ // Convert to list.Item slice
142+ listItems := make([]list.Item, len(items))
143+ for i, item := range items {
144+ listItems[i] = item
145+ }
146+
147+ delegate := NewHistoryDelegate(theme)
148+
149+ l := list.New(listItems, delegate, width, height)
150+ l.SetShowTitle(false)
151+ l.SetShowStatusBar(false)
152+ l.SetShowFilter(false)
153+ l.SetShowHelp(false)
154+ l.SetShowPagination(true)
155+
156+ // Apply theme colors to pagination
157+ l.Styles.PaginationStyle = lipgloss.NewStyle().Foreground(theme.Muted)
158+ l.Styles.ActivePaginationDot = lipgloss.NewStyle().Foreground(theme.Accent)
159+ l.Styles.InactivePaginationDot = lipgloss.NewStyle().Foreground(theme.Muted)
160+
161+ return l
162+}
163+
164+// SimpleItem is a basic list item with title and description.
165+type SimpleItem struct {
166+ TitleText string
167+ DescText string
168+}
169+
170+// FilterValue implements list.Item.
171+func (i SimpleItem) FilterValue() string {
172+ return i.TitleText + " " + i.DescText
173+}
174+
175+// SimpleDelegate renders simple items.
176+type SimpleDelegate struct {
177+ Theme *Theme
178+}
179+
180+// Height returns the height of each item.
181+func (d SimpleDelegate) Height() int {
182+ return 1
183+}
184+
185+// Spacing returns the spacing between items.
186+func (d SimpleDelegate) Spacing() int {
187+ return 0
188+}
189+
190+// Update handles item-level events.
191+func (d SimpleDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd {
192+ return nil
193+}
194+
195+// Render renders a single list item.
196+func (d SimpleDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
197+ si, ok := item.(SimpleItem)
198+ if !ok {
199+ return
200+ }
201+
202+ t := d.Theme
203+ isSelected := index == m.Index()
204+
205+ cursor := cursorEmpty
206+ style := t.ListItem
207+ if isSelected {
208+ cursor = cursorSelected
209+ style = t.ListItemSelected
210+ }
211+
212+ line := lipgloss.JoinHorizontal(
213+ lipgloss.Left,
214+ t.Highlight.Render(cursor),
215+ style.Render(si.TitleText),
216+ )
217+
218+ if si.DescText != "" {
219+ line = lipgloss.JoinHorizontal(
220+ lipgloss.Left,
221+ line,
222+ " ",
223+ t.Subtle.Render(si.DescText),
224+ )
225+ }
226+
227+ _, _ = fmt.Fprint(w, line)
228+}
@@ -0,0 +1,228 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "io"
6+ "strings"
7+ "time"
8+
9+ "github.com/charmbracelet/bubbles/list"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+)
13+
14+// HistoryItem represents a history entry for the list.
15+type HistoryItem struct {
16+ ID int64
17+ URL string
18+ Title string
19+ Domain string
20+ VisitCount int
21+ LastVisited time.Time
22+}
23+
24+// FilterValue implements list.Item.
25+func (i HistoryItem) FilterValue() string {
26+ return i.Title + " " + i.URL
27+}
28+
29+// Title implements list.DefaultItem.
30+func (i HistoryItem) TitleValue() string {
31+ if i.Title != "" {
32+ return i.Title
33+ }
34+ return i.URL
35+}
36+
37+// Description implements list.DefaultItem.
38+func (i HistoryItem) DescriptionValue() string {
39+ return i.URL
40+}
41+
42+// HistoryDelegate renders history items with theme styling.
43+type HistoryDelegate struct {
44+ Theme *Theme
45+ ShowDomain bool
46+}
47+
48+// NewHistoryDelegate creates a themed history list delegate.
49+func NewHistoryDelegate(theme *Theme) HistoryDelegate {
50+ return HistoryDelegate{
51+ Theme: theme,
52+ ShowDomain: true,
53+ }
54+}
55+
56+// Height returns the height of each item.
57+func (d HistoryDelegate) Height() int {
58+ return 2
59+}
60+
61+// Spacing returns the spacing between items.
62+func (d HistoryDelegate) Spacing() int {
63+ return 0
64+}
65+
66+// Update handles item-level events.
67+func (d HistoryDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd {
68+ return nil
69+}
70+
71+// Render renders a single list item.
72+func (d HistoryDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
73+ hi, ok := item.(HistoryItem)
74+ if !ok {
75+ return
76+ }
77+
78+ t := d.Theme
79+ isSelected := index == m.Index()
80+
81+ // Title line
82+ title := hi.TitleValue()
83+ if len(title) > 60 {
84+ title = title[:57] + "..."
85+ }
86+
87+ // URL/description line
88+ url := hi.URL
89+ if len(url) > 50 {
90+ url = url[:47] + "..."
91+ }
92+
93+ // Metadata badges
94+ visitBadge := t.MutedBadge(fmt.Sprintf("%d", hi.VisitCount))
95+ timeBadge := t.MutedBadge(RelativeTime(hi.LastVisited))
96+
97+ // Build cursor
98+ cursor := cursorEmpty
99+ if isSelected {
100+ cursor = cursorSelected
101+ }
102+
103+ // Apply styles
104+ cursorStyle := t.Highlight
105+ titleStyle := t.ListItemTitle
106+ urlStyle := t.ListItemDesc
107+
108+ if isSelected {
109+ titleStyle = titleStyle.Copy().Foreground(t.Accent).Bold(true)
110+ urlStyle = urlStyle.Copy().Foreground(t.Text)
111+ }
112+
113+ // First line: cursor + title
114+ line1 := lipgloss.JoinHorizontal(
115+ lipgloss.Left,
116+ cursorStyle.Render(cursor),
117+ titleStyle.Render(title),
118+ )
119+
120+ // Second line: url + badges
121+ meta := lipgloss.JoinHorizontal(
122+ lipgloss.Left,
123+ visitBadge,
124+ " ",
125+ timeBadge,
126+ )
127+
128+ line2 := lipgloss.JoinHorizontal(
129+ lipgloss.Left,
130+ strings.Repeat(" ", 3), // Indent under cursor
131+ urlStyle.Render(url),
132+ " ",
133+ meta,
134+ )
135+
136+ _, _ = fmt.Fprintf(w, "%s\n%s", line1, line2)
137+}
138+
139+// NewHistoryList creates a themed list for history items.
140+func NewHistoryList(theme *Theme, items []HistoryItem, width, height int) list.Model {
141+ // Convert to list.Item slice
142+ listItems := make([]list.Item, len(items))
143+ for i, item := range items {
144+ listItems[i] = item
145+ }
146+
147+ delegate := NewHistoryDelegate(theme)
148+
149+ l := list.New(listItems, delegate, width, height)
150+ l.SetShowTitle(false)
151+ l.SetShowStatusBar(false)
152+ l.SetShowFilter(false)
153+ l.SetShowHelp(false)
154+ l.SetShowPagination(true)
155+
156+ // Apply theme colors to pagination
157+ l.Styles.PaginationStyle = lipgloss.NewStyle().Foreground(theme.Muted)
158+ l.Styles.ActivePaginationDot = lipgloss.NewStyle().Foreground(theme.Accent)
159+ l.Styles.InactivePaginationDot = lipgloss.NewStyle().Foreground(theme.Muted)
160+
161+ return l
162+}
163+
164+// SimpleItem is a basic list item with title and description.
165+type SimpleItem struct {
166+ TitleText string
167+ DescText string
168+}
169+
170+// FilterValue implements list.Item.
171+func (i SimpleItem) FilterValue() string {
172+ return i.TitleText + " " + i.DescText
173+}
174+
175+// SimpleDelegate renders simple items.
176+type SimpleDelegate struct {
177+ Theme *Theme
178+}
179+
180+// Height returns the height of each item.
181+func (d SimpleDelegate) Height() int {
182+ return 1
183+}
184+
185+// Spacing returns the spacing between items.
186+func (d SimpleDelegate) Spacing() int {
187+ return 0
188+}
189+
190+// Update handles item-level events.
191+func (d SimpleDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd {
192+ return nil
193+}
194+
195+// Render renders a single list item.
196+func (d SimpleDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
197+ si, ok := item.(SimpleItem)
198+ if !ok {
199+ return
200+ }
201+
202+ t := d.Theme
203+ isSelected := index == m.Index()
204+
205+ cursor := cursorEmpty
206+ style := t.ListItem
207+ if isSelected {
208+ cursor = cursorSelected
209+ style = t.ListItemSelected
210+ }
211+
212+ line := lipgloss.JoinHorizontal(
213+ lipgloss.Left,
214+ t.Highlight.Render(cursor),
215+ style.Render(si.TitleText),
216+ )
217+
218+ if si.DescText != "" {
219+ line = lipgloss.JoinHorizontal(
220+ lipgloss.Left,
221+ line,
222+ " ",
223+ t.Subtle.Render(si.DescText),
224+ )
225+ }
226+
227+ _, _ = fmt.Fprint(w, line)
228+}
A internal/cli/styles/spinner.go +83 -0
@@ -0,0 +1,83 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/bubbles/spinner"
5+ "github.com/charmbracelet/lipgloss"
6+)
7+
8+// SpinnerType defines available spinner styles.
9+type SpinnerType int
10+
11+const (
12+ SpinnerDots SpinnerType = iota
13+ SpinnerLine
14+ SpinnerMiniDot
15+ SpinnerJump
16+ SpinnerPulse
17+ SpinnerPoints
18+ SpinnerGlobe
19+ SpinnerMoon
20+ SpinnerMonkey
21+)
22+
23+// NewStyledSpinner creates a themed spinner.
24+func NewStyledSpinner(theme *Theme, spinnerType SpinnerType) spinner.Model {
25+ s := spinner.New()
26+ s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
27+
28+ switch spinnerType {
29+ case SpinnerDots:
30+ s.Spinner = spinner.Dot
31+ case SpinnerLine:
32+ s.Spinner = spinner.Line
33+ case SpinnerMiniDot:
34+ s.Spinner = spinner.MiniDot
35+ case SpinnerJump:
36+ s.Spinner = spinner.Jump
37+ case SpinnerPulse:
38+ s.Spinner = spinner.Pulse
39+ case SpinnerPoints:
40+ s.Spinner = spinner.Points
41+ case SpinnerGlobe:
42+ s.Spinner = spinner.Globe
43+ case SpinnerMoon:
44+ s.Spinner = spinner.Moon
45+ case SpinnerMonkey:
46+ s.Spinner = spinner.Monkey
47+ default:
48+ s.Spinner = spinner.Dot
49+ }
50+
51+ return s
52+}
53+
54+// NewDefaultSpinner creates the default themed spinner.
55+func NewDefaultSpinner(theme *Theme) spinner.Model {
56+ return NewStyledSpinner(theme, SpinnerDots)
57+}
58+
59+// LoadingModel wraps a spinner with a message.
60+type LoadingModel struct {
61+ Spinner spinner.Model
62+ Message string
63+ theme *Theme
64+}
65+
66+// NewLoading creates a loading indicator with message.
67+func NewLoading(theme *Theme, message string) LoadingModel {
68+ return LoadingModel{
69+ Spinner: NewDefaultSpinner(theme),
70+ Message: message,
71+ theme: theme,
72+ }
73+}
74+
75+// View renders the loading indicator.
76+func (m LoadingModel) View() string {
77+ return lipgloss.JoinHorizontal(
78+ lipgloss.Center,
79+ m.Spinner.View(),
80+ " ",
81+ m.theme.Subtle.Render(m.Message),
82+ )
83+}
@@ -0,0 +1,83 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/bubbles/spinner"
5+ "github.com/charmbracelet/lipgloss"
6+)
7+
8+// SpinnerType defines available spinner styles.
9+type SpinnerType int
10+
11+const (
12+ SpinnerDots SpinnerType = iota
13+ SpinnerLine
14+ SpinnerMiniDot
15+ SpinnerJump
16+ SpinnerPulse
17+ SpinnerPoints
18+ SpinnerGlobe
19+ SpinnerMoon
20+ SpinnerMonkey
21+)
22+
23+// NewStyledSpinner creates a themed spinner.
24+func NewStyledSpinner(theme *Theme, spinnerType SpinnerType) spinner.Model {
25+ s := spinner.New()
26+ s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
27+
28+ switch spinnerType {
29+ case SpinnerDots:
30+ s.Spinner = spinner.Dot
31+ case SpinnerLine:
32+ s.Spinner = spinner.Line
33+ case SpinnerMiniDot:
34+ s.Spinner = spinner.MiniDot
35+ case SpinnerJump:
36+ s.Spinner = spinner.Jump
37+ case SpinnerPulse:
38+ s.Spinner = spinner.Pulse
39+ case SpinnerPoints:
40+ s.Spinner = spinner.Points
41+ case SpinnerGlobe:
42+ s.Spinner = spinner.Globe
43+ case SpinnerMoon:
44+ s.Spinner = spinner.Moon
45+ case SpinnerMonkey:
46+ s.Spinner = spinner.Monkey
47+ default:
48+ s.Spinner = spinner.Dot
49+ }
50+
51+ return s
52+}
53+
54+// NewDefaultSpinner creates the default themed spinner.
55+func NewDefaultSpinner(theme *Theme) spinner.Model {
56+ return NewStyledSpinner(theme, SpinnerDots)
57+}
58+
59+// LoadingModel wraps a spinner with a message.
60+type LoadingModel struct {
61+ Spinner spinner.Model
62+ Message string
63+ theme *Theme
64+}
65+
66+// NewLoading creates a loading indicator with message.
67+func NewLoading(theme *Theme, message string) LoadingModel {
68+ return LoadingModel{
69+ Spinner: NewDefaultSpinner(theme),
70+ Message: message,
71+ theme: theme,
72+ }
73+}
74+
75+// View renders the loading indicator.
76+func (m LoadingModel) View() string {
77+ return lipgloss.JoinHorizontal(
78+ lipgloss.Center,
79+ m.Spinner.View(),
80+ " ",
81+ m.theme.Subtle.Render(m.Message),
82+ )
83+}
A internal/cli/styles/table.go +113 -0
@@ -0,0 +1,113 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/bubbles/table"
5+ "github.com/charmbracelet/lipgloss"
6+)
7+
8+// NewStyledTable creates a themed table model.
9+func NewStyledTable(theme *Theme, columns []table.Column, rows []table.Row, width, height int) table.Model {
10+ t := table.New(
11+ table.WithColumns(columns),
12+ table.WithRows(rows),
13+ table.WithFocused(true),
14+ table.WithHeight(height),
15+ table.WithWidth(width),
16+ )
17+
18+ // Apply theme styles
19+ s := table.DefaultStyles()
20+ s.Header = s.Header.
21+ BorderStyle(lipgloss.NormalBorder()).
22+ BorderForeground(theme.Border).
23+ BorderBottom(true).
24+ Foreground(theme.Accent).
25+ Bold(true)
26+ s.Selected = s.Selected.
27+ Foreground(theme.Text).
28+ Background(theme.SurfaceVariant).
29+ Bold(true)
30+ s.Cell = s.Cell.
31+ Foreground(theme.Text)
32+
33+ t.SetStyles(s)
34+ return t
35+}
36+
37+// StatsTableColumns returns columns for domain stats table.
38+func StatsTableColumns() []table.Column {
39+ return []table.Column{
40+ {Title: "Domain", Width: 30},
41+ {Title: "Visits", Width: 10},
42+ {Title: "Entries", Width: 10},
43+ {Title: "Last Visit", Width: 15},
44+ }
45+}
46+
47+// HistoryTableColumns returns columns for history list table.
48+func HistoryTableColumns() []table.Column {
49+ return []table.Column{
50+ {Title: "Title", Width: 40},
51+ {Title: "URL", Width: 30},
52+ {Title: "Visits", Width: 8},
53+ {Title: "Last Visit", Width: 12},
54+ }
55+}
56+
57+// DomainStatsRow converts domain stats to table row.
58+type DomainStatsRow struct {
59+ Domain string
60+ Visits int
61+ Entries int
62+ LastVisit string
63+}
64+
65+// ToRow converts to table.Row.
66+func (d DomainStatsRow) ToRow() table.Row {
67+ return table.Row{d.Domain, formatInt(d.Visits), formatInt(d.Entries), d.LastVisit}
68+}
69+
70+// formatInt formats an integer for display.
71+func formatInt(n int) string {
72+ switch {
73+ case n >= 1000000:
74+ return formatFloat(float64(n)/1000000) + "M"
75+ case n >= 1000:
76+ return formatFloat(float64(n)/1000) + "K"
77+ default:
78+ return intToString(n)
79+ }
80+}
81+
82+// formatFloat formats a float with one decimal.
83+func formatFloat(f float64) string {
84+ i := int(f * 10)
85+ whole := i / 10
86+ dec := i % 10
87+ if dec == 0 {
88+ return intToString(whole)
89+ }
90+ return intToString(whole) + "." + intToString(dec)
91+}
92+
93+// intToString converts int to string without fmt.
94+func intToString(n int) string {
95+ if n == 0 {
96+ return "0"
97+ }
98+ if n < 0 {
99+ return "-" + intToString(-n)
100+ }
101+
102+ digits := make([]byte, 0, 10)
103+ for n > 0 {
104+ digits = append(digits, byte('0'+n%10))
105+ n /= 10
106+ }
107+
108+ // Reverse
109+ for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 {
110+ digits[i], digits[j] = digits[j], digits[i]
111+ }
112+ return string(digits)
113+}
@@ -0,0 +1,113 @@
1+package styles
2+
3+import (
4+ "github.com/charmbracelet/bubbles/table"
5+ "github.com/charmbracelet/lipgloss"
6+)
7+
8+// NewStyledTable creates a themed table model.
9+func NewStyledTable(theme *Theme, columns []table.Column, rows []table.Row, width, height int) table.Model {
10+ t := table.New(
11+ table.WithColumns(columns),
12+ table.WithRows(rows),
13+ table.WithFocused(true),
14+ table.WithHeight(height),
15+ table.WithWidth(width),
16+ )
17+
18+ // Apply theme styles
19+ s := table.DefaultStyles()
20+ s.Header = s.Header.
21+ BorderStyle(lipgloss.NormalBorder()).
22+ BorderForeground(theme.Border).
23+ BorderBottom(true).
24+ Foreground(theme.Accent).
25+ Bold(true)
26+ s.Selected = s.Selected.
27+ Foreground(theme.Text).
28+ Background(theme.SurfaceVariant).
29+ Bold(true)
30+ s.Cell = s.Cell.
31+ Foreground(theme.Text)
32+
33+ t.SetStyles(s)
34+ return t
35+}
36+
37+// StatsTableColumns returns columns for domain stats table.
38+func StatsTableColumns() []table.Column {
39+ return []table.Column{
40+ {Title: "Domain", Width: 30},
41+ {Title: "Visits", Width: 10},
42+ {Title: "Entries", Width: 10},
43+ {Title: "Last Visit", Width: 15},
44+ }
45+}
46+
47+// HistoryTableColumns returns columns for history list table.
48+func HistoryTableColumns() []table.Column {
49+ return []table.Column{
50+ {Title: "Title", Width: 40},
51+ {Title: "URL", Width: 30},
52+ {Title: "Visits", Width: 8},
53+ {Title: "Last Visit", Width: 12},
54+ }
55+}
56+
57+// DomainStatsRow converts domain stats to table row.
58+type DomainStatsRow struct {
59+ Domain string
60+ Visits int
61+ Entries int
62+ LastVisit string
63+}
64+
65+// ToRow converts to table.Row.
66+func (d DomainStatsRow) ToRow() table.Row {
67+ return table.Row{d.Domain, formatInt(d.Visits), formatInt(d.Entries), d.LastVisit}
68+}
69+
70+// formatInt formats an integer for display.
71+func formatInt(n int) string {
72+ switch {
73+ case n >= 1000000:
74+ return formatFloat(float64(n)/1000000) + "M"
75+ case n >= 1000:
76+ return formatFloat(float64(n)/1000) + "K"
77+ default:
78+ return intToString(n)
79+ }
80+}
81+
82+// formatFloat formats a float with one decimal.
83+func formatFloat(f float64) string {
84+ i := int(f * 10)
85+ whole := i / 10
86+ dec := i % 10
87+ if dec == 0 {
88+ return intToString(whole)
89+ }
90+ return intToString(whole) + "." + intToString(dec)
91+}
92+
93+// intToString converts int to string without fmt.
94+func intToString(n int) string {
95+ if n == 0 {
96+ return "0"
97+ }
98+ if n < 0 {
99+ return "-" + intToString(-n)
100+ }
101+
102+ digits := make([]byte, 0, 10)
103+ for n > 0 {
104+ digits = append(digits, byte('0'+n%10))
105+ n /= 10
106+ }
107+
108+ // Reverse
109+ for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 {
110+ digits[i], digits[j] = digits[j], digits[i]
111+ }
112+ return string(digits)
113+}
A internal/cli/styles/tabs.go +145 -0
@@ -0,0 +1,145 @@
1+package styles
2+
3+import (
4+ "fmt"
5+
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+// TabsModel represents a horizontal tab bar.
10+type TabsModel struct {
11+ Tabs []string
12+ Active int
13+ theme *Theme
14+}
15+
16+// NewTabs creates a new tab bar with the given labels.
17+func NewTabs(theme *Theme, tabs ...string) TabsModel {
18+ return TabsModel{
19+ Tabs: tabs,
20+ Active: 0,
21+ theme: theme,
22+ }
23+}
24+
25+// SetActive sets the active tab index.
26+func (m *TabsModel) SetActive(index int) {
27+ if index >= 0 && index < len(m.Tabs) {
28+ m.Active = index
29+ }
30+}
31+
32+// Next moves to the next tab.
33+func (m *TabsModel) Next() {
34+ m.Active = (m.Active + 1) % len(m.Tabs)
35+}
36+
37+// Prev moves to the previous tab.
38+func (m *TabsModel) Prev() {
39+ m.Active = (m.Active - 1 + len(m.Tabs)) % len(m.Tabs)
40+}
41+
42+// View renders the tab bar.
43+func (m TabsModel) View() string {
44+ var tabs []string
45+
46+ for i, tab := range m.Tabs {
47+ style := m.theme.InactiveTab
48+ if i == m.Active {
49+ style = m.theme.ActiveTab
50+ }
51+ tabs = append(tabs, style.Render(tab))
52+ }
53+
54+ // Join tabs with separator
55+ gap := lipgloss.NewStyle().
56+ Foreground(m.theme.Border).
57+ Render(" │ ")
58+
59+ row := ""
60+ for i, tab := range tabs {
61+ if i > 0 {
62+ row += gap
63+ }
64+ row += tab
65+ }
66+
67+ return m.theme.TabBar.Width(80).Render(row)
68+}
69+
70+// ViewCompact renders a compact tab bar without separators.
71+func (m TabsModel) ViewCompact() string {
72+ var tabs []string
73+
74+ for i, tab := range m.Tabs {
75+ style := m.theme.InactiveTab
76+ if i == m.Active {
77+ style = m.theme.ActiveTab
78+ }
79+ tabs = append(tabs, style.Render(tab))
80+ }
81+
82+ return lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
83+}
84+
85+// TimelineTabs creates tabs for history timeline view.
86+func TimelineTabs(theme *Theme) TabsModel {
87+ return NewTabs(theme, "Today", "Yesterday", "This Week", "Older")
88+}
89+
90+// ViewWithCounts renders tabs with item counts.
91+func (m TabsModel) ViewWithCounts(counts []int) string {
92+ var tabs []string
93+
94+ for i, tab := range m.Tabs {
95+ count := 0
96+ if i < len(counts) {
97+ count = counts[i]
98+ }
99+
100+ label := tab
101+ if count > 0 {
102+ label = lipgloss.JoinHorizontal(
103+ lipgloss.Center,
104+ tab,
105+ " ",
106+ m.theme.BadgeMuted.Render(formatCount(count)),
107+ )
108+ }
109+
110+ style := m.theme.InactiveTab
111+ if i == m.Active {
112+ style = m.theme.ActiveTab
113+ }
114+ tabs = append(tabs, style.Render(label))
115+ }
116+
117+ gap := lipgloss.NewStyle().
118+ Foreground(m.theme.Border).
119+ Render(" ")
120+
121+ return lipgloss.JoinHorizontal(lipgloss.Top, join(tabs, gap)...)
122+}
123+
124+// join inserts a separator between items.
125+func join(items []string, sep string) []string {
126+ if len(items) == 0 {
127+ return items
128+ }
129+ result := make([]string, 0, len(items)*2-1)
130+ for i, item := range items {
131+ if i > 0 {
132+ result = append(result, sep)
133+ }
134+ result = append(result, item)
135+ }
136+ return result
137+}
138+
139+// formatCount formats a count for display.
140+func formatCount(n int) string {
141+ if n >= 1000 {
142+ return "999+"
143+ }
144+ return fmt.Sprintf("%d", n)
145+}
@@ -0,0 +1,145 @@
1+package styles
2+
3+import (
4+ "fmt"
5+
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+// TabsModel represents a horizontal tab bar.
10+type TabsModel struct {
11+ Tabs []string
12+ Active int
13+ theme *Theme
14+}
15+
16+// NewTabs creates a new tab bar with the given labels.
17+func NewTabs(theme *Theme, tabs ...string) TabsModel {
18+ return TabsModel{
19+ Tabs: tabs,
20+ Active: 0,
21+ theme: theme,
22+ }
23+}
24+
25+// SetActive sets the active tab index.
26+func (m *TabsModel) SetActive(index int) {
27+ if index >= 0 && index < len(m.Tabs) {
28+ m.Active = index
29+ }
30+}
31+
32+// Next moves to the next tab.
33+func (m *TabsModel) Next() {
34+ m.Active = (m.Active + 1) % len(m.Tabs)
35+}
36+
37+// Prev moves to the previous tab.
38+func (m *TabsModel) Prev() {
39+ m.Active = (m.Active - 1 + len(m.Tabs)) % len(m.Tabs)
40+}
41+
42+// View renders the tab bar.
43+func (m TabsModel) View() string {
44+ var tabs []string
45+
46+ for i, tab := range m.Tabs {
47+ style := m.theme.InactiveTab
48+ if i == m.Active {
49+ style = m.theme.ActiveTab
50+ }
51+ tabs = append(tabs, style.Render(tab))
52+ }
53+
54+ // Join tabs with separator
55+ gap := lipgloss.NewStyle().
56+ Foreground(m.theme.Border).
57+ Render(" │ ")
58+
59+ row := ""
60+ for i, tab := range tabs {
61+ if i > 0 {
62+ row += gap
63+ }
64+ row += tab
65+ }
66+
67+ return m.theme.TabBar.Width(80).Render(row)
68+}
69+
70+// ViewCompact renders a compact tab bar without separators.
71+func (m TabsModel) ViewCompact() string {
72+ var tabs []string
73+
74+ for i, tab := range m.Tabs {
75+ style := m.theme.InactiveTab
76+ if i == m.Active {
77+ style = m.theme.ActiveTab
78+ }
79+ tabs = append(tabs, style.Render(tab))
80+ }
81+
82+ return lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
83+}
84+
85+// TimelineTabs creates tabs for history timeline view.
86+func TimelineTabs(theme *Theme) TabsModel {
87+ return NewTabs(theme, "Today", "Yesterday", "This Week", "Older")
88+}
89+
90+// ViewWithCounts renders tabs with item counts.
91+func (m TabsModel) ViewWithCounts(counts []int) string {
92+ var tabs []string
93+
94+ for i, tab := range m.Tabs {
95+ count := 0
96+ if i < len(counts) {
97+ count = counts[i]
98+ }
99+
100+ label := tab
101+ if count > 0 {
102+ label = lipgloss.JoinHorizontal(
103+ lipgloss.Center,
104+ tab,
105+ " ",
106+ m.theme.BadgeMuted.Render(formatCount(count)),
107+ )
108+ }
109+
110+ style := m.theme.InactiveTab
111+ if i == m.Active {
112+ style = m.theme.ActiveTab
113+ }
114+ tabs = append(tabs, style.Render(label))
115+ }
116+
117+ gap := lipgloss.NewStyle().
118+ Foreground(m.theme.Border).
119+ Render(" ")
120+
121+ return lipgloss.JoinHorizontal(lipgloss.Top, join(tabs, gap)...)
122+}
123+
124+// join inserts a separator between items.
125+func join(items []string, sep string) []string {
126+ if len(items) == 0 {
127+ return items
128+ }
129+ result := make([]string, 0, len(items)*2-1)
130+ for i, item := range items {
131+ if i > 0 {
132+ result = append(result, sep)
133+ }
134+ result = append(result, item)
135+ }
136+ return result
137+}
138+
139+// formatCount formats a count for display.
140+func formatCount(n int) string {
141+ if n >= 1000 {
142+ return "999+"
143+ }
144+ return fmt.Sprintf("%d", n)
145+}
A internal/cli/styles/theme.go +222 -0
@@ -0,0 +1,222 @@
1+// Package styles provides reusable lipgloss-based TUI components.
2+package styles
3+
4+import (
5+ "github.com/charmbracelet/lipgloss"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+)
9+
10+// Theme holds lipgloss colors and styles derived from config.
11+type Theme struct {
12+ // Base colors (from config.ColorPalette)
13+ Background lipgloss.Color
14+ Surface lipgloss.Color
15+ SurfaceVariant lipgloss.Color
16+ Text lipgloss.Color
17+ Muted lipgloss.Color
18+ Accent lipgloss.Color
19+ Border lipgloss.Color
20+
21+ // Additional semantic colors
22+ Error lipgloss.Color
23+ Warning lipgloss.Color
24+ Success lipgloss.Color
25+
26+ // Pre-built styles
27+ Title lipgloss.Style
28+ Subtitle lipgloss.Style
29+ Normal lipgloss.Style
30+ Subtle lipgloss.Style
31+ Highlight lipgloss.Style
32+ ErrorStyle lipgloss.Style
33+ WarningStyle lipgloss.Style
34+ SuccessStyle lipgloss.Style
35+
36+ // Component styles
37+ ActiveTab lipgloss.Style
38+ InactiveTab lipgloss.Style
39+ TabBar lipgloss.Style
40+
41+ ListItem lipgloss.Style
42+ ListItemSelected lipgloss.Style
43+ ListItemTitle lipgloss.Style
44+ ListItemDesc lipgloss.Style
45+
46+ Badge lipgloss.Style
47+ BadgeMuted lipgloss.Style
48+
49+ Input lipgloss.Style
50+ InputFocused lipgloss.Style
51+
52+ HelpKey lipgloss.Style
53+ HelpDesc lipgloss.Style
54+
55+ Box lipgloss.Style
56+ BoxHeader lipgloss.Style
57+}
58+
59+// DefaultDarkPalette returns hardcoded dark theme colors.
60+func DefaultDarkPalette() config.ColorPalette {
61+ return config.ColorPalette{
62+ Background: "#0a0a0b",
63+ Surface: "#1a1a1b",
64+ SurfaceVariant: "#2d2d2d",
65+ Text: "#ffffff",
66+ Muted: "#909090",
67+ Accent: "#4ade80",
68+ Border: "#333333",
69+ }
70+}
71+
72+// NewTheme creates a Theme from config, using dark palette only.
73+func NewTheme(cfg *config.Config) *Theme {
74+ var p config.ColorPalette
75+ if cfg != nil && cfg.Appearance.DarkPalette.Background != "" {
76+ p = cfg.Appearance.DarkPalette
77+ } else {
78+ p = DefaultDarkPalette()
79+ }
80+
81+ return NewThemeFromPalette(p)
82+}
83+
84+// NewThemeFromPalette creates a Theme from a ColorPalette.
85+func NewThemeFromPalette(p config.ColorPalette) *Theme {
86+ t := &Theme{
87+ Background: lipgloss.Color(p.Background),
88+ Surface: lipgloss.Color(p.Surface),
89+ SurfaceVariant: lipgloss.Color(p.SurfaceVariant),
90+ Text: lipgloss.Color(p.Text),
91+ Muted: lipgloss.Color(p.Muted),
92+ Accent: lipgloss.Color(p.Accent),
93+ Border: lipgloss.Color(p.Border),
94+
95+ // Semantic colors (not in config, use sensible defaults)
96+ Error: lipgloss.Color("#ef4444"),
97+ Warning: lipgloss.Color("#f59e0b"),
98+ Success: lipgloss.Color(p.Accent), // Use accent as success
99+ }
100+
101+ // Build derived styles
102+ t.buildStyles()
103+ return t
104+}
105+
106+// buildStyles creates all derived lipgloss styles.
107+func (t *Theme) buildStyles() {
108+ // Text styles
109+ t.Title = lipgloss.NewStyle().
110+ Foreground(t.Text).
111+ Bold(true)
112+
113+ t.Subtitle = lipgloss.NewStyle().
114+ Foreground(t.Muted).
115+ Bold(true)
116+
117+ t.Normal = lipgloss.NewStyle().
118+ Foreground(t.Text)
119+
120+ t.Subtle = lipgloss.NewStyle().
121+ Foreground(t.Muted)
122+
123+ t.Highlight = lipgloss.NewStyle().
124+ Foreground(t.Accent).
125+ Bold(true)
126+
127+ t.ErrorStyle = lipgloss.NewStyle().
128+ Foreground(t.Error)
129+
130+ t.WarningStyle = lipgloss.NewStyle().
131+ Foreground(t.Warning)
132+
133+ t.SuccessStyle = lipgloss.NewStyle().
134+ Foreground(t.Success)
135+
136+ // Tab styles
137+ t.ActiveTab = lipgloss.NewStyle().
138+ Foreground(t.Background).
139+ Background(t.Accent).
140+ Padding(0, 2).
141+ Bold(true)
142+
143+ t.InactiveTab = lipgloss.NewStyle().
144+ Foreground(t.Muted).
145+ Background(t.Surface).
146+ Padding(0, 2)
147+
148+ t.TabBar = lipgloss.NewStyle().
149+ Background(t.Surface).
150+ BorderStyle(lipgloss.NormalBorder()).
151+ BorderBottom(true).
152+ BorderForeground(t.Border)
153+
154+ // List item styles
155+ t.ListItem = lipgloss.NewStyle().
156+ Foreground(t.Text).
157+ PaddingLeft(2)
158+
159+ t.ListItemSelected = lipgloss.NewStyle().
160+ Foreground(t.Accent).
161+ Background(t.SurfaceVariant).
162+ PaddingLeft(2).
163+ Bold(true)
164+
165+ t.ListItemTitle = lipgloss.NewStyle().
166+ Foreground(t.Text)
167+
168+ t.ListItemDesc = lipgloss.NewStyle().
169+ Foreground(t.Muted)
170+
171+ // Badge styles
172+ t.Badge = lipgloss.NewStyle().
173+ Foreground(t.Background).
174+ Background(t.Accent).
175+ Padding(0, 1)
176+
177+ t.BadgeMuted = lipgloss.NewStyle().
178+ Foreground(t.Text).
179+ Background(t.SurfaceVariant).
180+ Padding(0, 1)
181+
182+ // Input styles
183+ t.Input = lipgloss.NewStyle().
184+ Foreground(t.Text).
185+ Background(t.Surface).
186+ BorderStyle(lipgloss.RoundedBorder()).
187+ BorderForeground(t.Border).
188+ Padding(0, 1)
189+
190+ t.InputFocused = lipgloss.NewStyle().
191+ Foreground(t.Text).
192+ Background(t.Surface).
193+ BorderStyle(lipgloss.RoundedBorder()).
194+ BorderForeground(t.Accent).
195+ Padding(0, 1)
196+
197+ // Help styles
198+ t.HelpKey = lipgloss.NewStyle().
199+ Foreground(t.Accent)
200+
201+ t.HelpDesc = lipgloss.NewStyle().
202+ Foreground(t.Muted)
203+
204+ // Box/container styles
205+ t.Box = lipgloss.NewStyle().
206+ BorderStyle(lipgloss.RoundedBorder()).
207+ BorderForeground(t.Border).
208+ Padding(1, 2)
209+
210+ t.BoxHeader = lipgloss.NewStyle().
211+ Foreground(t.Text).
212+ Bold(true).
213+ BorderStyle(lipgloss.NormalBorder()).
214+ BorderBottom(true).
215+ BorderForeground(t.Border).
216+ MarginBottom(1)
217+}
218+
219+// Renderer returns a lipgloss renderer for the current output.
220+func (t *Theme) Renderer() *lipgloss.Renderer {
221+ return lipgloss.DefaultRenderer()
222+}
@@ -0,0 +1,222 @@
1+// Package styles provides reusable lipgloss-based TUI components.
2+package styles
3+
4+import (
5+ "github.com/charmbracelet/lipgloss"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+)
9+
10+// Theme holds lipgloss colors and styles derived from config.
11+type Theme struct {
12+ // Base colors (from config.ColorPalette)
13+ Background lipgloss.Color
14+ Surface lipgloss.Color
15+ SurfaceVariant lipgloss.Color
16+ Text lipgloss.Color
17+ Muted lipgloss.Color
18+ Accent lipgloss.Color
19+ Border lipgloss.Color
20+
21+ // Additional semantic colors
22+ Error lipgloss.Color
23+ Warning lipgloss.Color
24+ Success lipgloss.Color
25+
26+ // Pre-built styles
27+ Title lipgloss.Style
28+ Subtitle lipgloss.Style
29+ Normal lipgloss.Style
30+ Subtle lipgloss.Style
31+ Highlight lipgloss.Style
32+ ErrorStyle lipgloss.Style
33+ WarningStyle lipgloss.Style
34+ SuccessStyle lipgloss.Style
35+
36+ // Component styles
37+ ActiveTab lipgloss.Style
38+ InactiveTab lipgloss.Style
39+ TabBar lipgloss.Style
40+
41+ ListItem lipgloss.Style
42+ ListItemSelected lipgloss.Style
43+ ListItemTitle lipgloss.Style
44+ ListItemDesc lipgloss.Style
45+
46+ Badge lipgloss.Style
47+ BadgeMuted lipgloss.Style
48+
49+ Input lipgloss.Style
50+ InputFocused lipgloss.Style
51+
52+ HelpKey lipgloss.Style
53+ HelpDesc lipgloss.Style
54+
55+ Box lipgloss.Style
56+ BoxHeader lipgloss.Style
57+}
58+
59+// DefaultDarkPalette returns hardcoded dark theme colors.
60+func DefaultDarkPalette() config.ColorPalette {
61+ return config.ColorPalette{
62+ Background: "#0a0a0b",
63+ Surface: "#1a1a1b",
64+ SurfaceVariant: "#2d2d2d",
65+ Text: "#ffffff",
66+ Muted: "#909090",
67+ Accent: "#4ade80",
68+ Border: "#333333",
69+ }
70+}
71+
72+// NewTheme creates a Theme from config, using dark palette only.
73+func NewTheme(cfg *config.Config) *Theme {
74+ var p config.ColorPalette
75+ if cfg != nil && cfg.Appearance.DarkPalette.Background != "" {
76+ p = cfg.Appearance.DarkPalette
77+ } else {
78+ p = DefaultDarkPalette()
79+ }
80+
81+ return NewThemeFromPalette(p)
82+}
83+
84+// NewThemeFromPalette creates a Theme from a ColorPalette.
85+func NewThemeFromPalette(p config.ColorPalette) *Theme {
86+ t := &Theme{
87+ Background: lipgloss.Color(p.Background),
88+ Surface: lipgloss.Color(p.Surface),
89+ SurfaceVariant: lipgloss.Color(p.SurfaceVariant),
90+ Text: lipgloss.Color(p.Text),
91+ Muted: lipgloss.Color(p.Muted),
92+ Accent: lipgloss.Color(p.Accent),
93+ Border: lipgloss.Color(p.Border),
94+
95+ // Semantic colors (not in config, use sensible defaults)
96+ Error: lipgloss.Color("#ef4444"),
97+ Warning: lipgloss.Color("#f59e0b"),
98+ Success: lipgloss.Color(p.Accent), // Use accent as success
99+ }
100+
101+ // Build derived styles
102+ t.buildStyles()
103+ return t
104+}
105+
106+// buildStyles creates all derived lipgloss styles.
107+func (t *Theme) buildStyles() {
108+ // Text styles
109+ t.Title = lipgloss.NewStyle().
110+ Foreground(t.Text).
111+ Bold(true)
112+
113+ t.Subtitle = lipgloss.NewStyle().
114+ Foreground(t.Muted).
115+ Bold(true)
116+
117+ t.Normal = lipgloss.NewStyle().
118+ Foreground(t.Text)
119+
120+ t.Subtle = lipgloss.NewStyle().
121+ Foreground(t.Muted)
122+
123+ t.Highlight = lipgloss.NewStyle().
124+ Foreground(t.Accent).
125+ Bold(true)
126+
127+ t.ErrorStyle = lipgloss.NewStyle().
128+ Foreground(t.Error)
129+
130+ t.WarningStyle = lipgloss.NewStyle().
131+ Foreground(t.Warning)
132+
133+ t.SuccessStyle = lipgloss.NewStyle().
134+ Foreground(t.Success)
135+
136+ // Tab styles
137+ t.ActiveTab = lipgloss.NewStyle().
138+ Foreground(t.Background).
139+ Background(t.Accent).
140+ Padding(0, 2).
141+ Bold(true)
142+
143+ t.InactiveTab = lipgloss.NewStyle().
144+ Foreground(t.Muted).
145+ Background(t.Surface).
146+ Padding(0, 2)
147+
148+ t.TabBar = lipgloss.NewStyle().
149+ Background(t.Surface).
150+ BorderStyle(lipgloss.NormalBorder()).
151+ BorderBottom(true).
152+ BorderForeground(t.Border)
153+
154+ // List item styles
155+ t.ListItem = lipgloss.NewStyle().
156+ Foreground(t.Text).
157+ PaddingLeft(2)
158+
159+ t.ListItemSelected = lipgloss.NewStyle().
160+ Foreground(t.Accent).
161+ Background(t.SurfaceVariant).
162+ PaddingLeft(2).
163+ Bold(true)
164+
165+ t.ListItemTitle = lipgloss.NewStyle().
166+ Foreground(t.Text)
167+
168+ t.ListItemDesc = lipgloss.NewStyle().
169+ Foreground(t.Muted)
170+
171+ // Badge styles
172+ t.Badge = lipgloss.NewStyle().
173+ Foreground(t.Background).
174+ Background(t.Accent).
175+ Padding(0, 1)
176+
177+ t.BadgeMuted = lipgloss.NewStyle().
178+ Foreground(t.Text).
179+ Background(t.SurfaceVariant).
180+ Padding(0, 1)
181+
182+ // Input styles
183+ t.Input = lipgloss.NewStyle().
184+ Foreground(t.Text).
185+ Background(t.Surface).
186+ BorderStyle(lipgloss.RoundedBorder()).
187+ BorderForeground(t.Border).
188+ Padding(0, 1)
189+
190+ t.InputFocused = lipgloss.NewStyle().
191+ Foreground(t.Text).
192+ Background(t.Surface).
193+ BorderStyle(lipgloss.RoundedBorder()).
194+ BorderForeground(t.Accent).
195+ Padding(0, 1)
196+
197+ // Help styles
198+ t.HelpKey = lipgloss.NewStyle().
199+ Foreground(t.Accent)
200+
201+ t.HelpDesc = lipgloss.NewStyle().
202+ Foreground(t.Muted)
203+
204+ // Box/container styles
205+ t.Box = lipgloss.NewStyle().
206+ BorderStyle(lipgloss.RoundedBorder()).
207+ BorderForeground(t.Border).
208+ Padding(1, 2)
209+
210+ t.BoxHeader = lipgloss.NewStyle().
211+ Foreground(t.Text).
212+ Bold(true).
213+ BorderStyle(lipgloss.NormalBorder()).
214+ BorderBottom(true).
215+ BorderForeground(t.Border).
216+ MarginBottom(1)
217+}
218+
219+// Renderer returns a lipgloss renderer for the current output.
220+func (t *Theme) Renderer() *lipgloss.Renderer {
221+ return lipgloss.DefaultRenderer()
222+}
A internal/logging/session.go +51 -0
@@ -0,0 +1,51 @@
1+package logging
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "time"
7+)
8+
9+// GenerateSessionID creates a unique session identifier.
10+// Format: YYYYMMDD_HHMMSS_xxxx (timestamp + 4 random hex chars)
11+// Example: 20251217_205106_a7b3
12+func GenerateSessionID() string {
13+ now := time.Now()
14+ random := make([]byte, 2)
15+ _, _ = rand.Read(random)
16+ return now.Format("20060102_150405") + "_" + hex.EncodeToString(random)
17+}
18+
19+// ShortSessionID extracts the short ID (last 4 hex chars) from a full session ID.
20+// Example: "20251217_205106_a7b3" -> "a7b3"
21+func ShortSessionID(sessionID string) string {
22+ if len(sessionID) < 4 {
23+ return sessionID
24+ }
25+ return sessionID[len(sessionID)-4:]
26+}
27+
28+// ParseSessionFilename extracts session info from a log filename.
29+// Example: "session_20251217_205106_a7b3.log" -> "20251217_205106_a7b3", true
30+func ParseSessionFilename(filename string) (sessionID string, ok bool) {
31+ const prefix = "session_"
32+ const suffix = ".log"
33+
34+ if len(filename) < len(prefix)+len(suffix) {
35+ return "", false
36+ }
37+ if filename[:len(prefix)] != prefix {
38+ return "", false
39+ }
40+ if filename[len(filename)-len(suffix):] != suffix {
41+ return "", false
42+ }
43+
44+ return filename[len(prefix) : len(filename)-len(suffix)], true
45+}
46+
47+// SessionFilename generates the log filename for a session ID.
48+// Example: "20251217_205106_a7b3" -> "session_20251217_205106_a7b3.log"
49+func SessionFilename(sessionID string) string {
50+ return "session_" + sessionID + ".log"
51+}
@@ -0,0 +1,51 @@
1+package logging
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "time"
7+)
8+
9+// GenerateSessionID creates a unique session identifier.
10+// Format: YYYYMMDD_HHMMSS_xxxx (timestamp + 4 random hex chars)
11+// Example: 20251217_205106_a7b3
12+func GenerateSessionID() string {
13+ now := time.Now()
14+ random := make([]byte, 2)
15+ _, _ = rand.Read(random)
16+ return now.Format("20060102_150405") + "_" + hex.EncodeToString(random)
17+}
18+
19+// ShortSessionID extracts the short ID (last 4 hex chars) from a full session ID.
20+// Example: "20251217_205106_a7b3" -> "a7b3"
21+func ShortSessionID(sessionID string) string {
22+ if len(sessionID) < 4 {
23+ return sessionID
24+ }
25+ return sessionID[len(sessionID)-4:]
26+}
27+
28+// ParseSessionFilename extracts session info from a log filename.
29+// Example: "session_20251217_205106_a7b3.log" -> "20251217_205106_a7b3", true
30+func ParseSessionFilename(filename string) (sessionID string, ok bool) {
31+ const prefix = "session_"
32+ const suffix = ".log"
33+
34+ if len(filename) < len(prefix)+len(suffix) {
35+ return "", false
36+ }
37+ if filename[:len(prefix)] != prefix {
38+ return "", false
39+ }
40+ if filename[len(filename)-len(suffix):] != suffix {
41+ return "", false
42+ }
43+
44+ return filename[len(prefix) : len(filename)-len(suffix)], true
45+}
46+
47+// SessionFilename generates the log filename for a session ID.
48+// Example: "20251217_205106_a7b3" -> "session_20251217_205106_a7b3.log"
49+func SessionFilename(sessionID string) string {
50+ return "session_" + sessionID + ".log"
51+}

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