detached f6e095c

cli: align config/crashes/sessions subcommands with styles

commit
f6e095c1ef17944741417c15ca1ff79ef0cda183
author
brice · · 2026-02-10 06:17 UTC
parent
517f612
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +253 -51
M internal/cli/cmd/config.go +21 -4
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "os"
77 "os/exec"
8+ "strings"
89
910 "github.com/charmbracelet/bubbles/spinner"
1011 tea "github.com/charmbracelet/bubbletea"
@@ -108,27 +109,43 @@ func runConfigSchema(_ *cobra.Command, _ []string) error {
108109
109110 // runConfigOpen opens the config file in the user's preferred editor.
110111 func runConfigOpen(_ *cobra.Command, _ []string) error {
112+ app := GetApp()
113+ if app == nil {
114+ return fmt.Errorf("app not initialized")
115+ }
116+
111117 configFile, err := config.GetConfigFile()
112118 if err != nil {
113- return fmt.Errorf("get config file: %w", err)
119+ renderer := styles.NewConfigRenderer(app.Theme)
120+ return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("get config file: %w", err))))
114121 }
115122
116123 // Check if config file exists
117124 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
118- return fmt.Errorf("config file does not exist: %s", configFile)
125+ renderer := styles.NewConfigRenderer(app.Theme)
126+ return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("config file does not exist: %s", configFile))))
119127 }
120128
121129 editor := getEditor()
122130 if editor == "" {
123- return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable")
131+ renderer := styles.NewConfigRenderer(app.Theme)
132+ const msg = "no editor found: set $EDITOR or $VISUAL environment variable"
133+ return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("%s", msg))))
124134 }
125135
136+ // Keep this a simple, non-interactive message; the actual editor takes over the terminal.
137+ fmt.Println(styles.NewConfigRenderer(app.Theme).RenderOpening(configFile, editor))
138+
126139 cmd := exec.Command(editor, configFile)
127140 cmd.Stdin = os.Stdin
128141 cmd.Stdout = os.Stdout
129142 cmd.Stderr = os.Stderr
130143
131- return cmd.Run()
144+ if err := cmd.Run(); err != nil {
145+ renderer := styles.NewConfigRenderer(app.Theme)
146+ return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("open config: %w", err))))
147+ }
148+ return nil
132149 }
133150
134151 // getEditor returns the user's preferred editor.
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "os/exec"7 "os/exec"
8+ "strings"
8 9
9 "github.com/charmbracelet/bubbles/spinner"10 "github.com/charmbracelet/bubbles/spinner"
10 tea "github.com/charmbracelet/bubbletea"11 tea "github.com/charmbracelet/bubbletea"
@@ -108,27 +109,43 @@ func runConfigSchema(_ *cobra.Command, _ []string) error {
108 109
109 // runConfigOpen opens the config file in the user's preferred editor.110 // runConfigOpen opens the config file in the user's preferred editor.
110 func runConfigOpen(_ *cobra.Command, _ []string) error {111 func runConfigOpen(_ *cobra.Command, _ []string) error {
112+ app := GetApp()
113+ if app == nil {
114+ return fmt.Errorf("app not initialized")
115+ }
116+
111 configFile, err := config.GetConfigFile()117 configFile, err := config.GetConfigFile()
112 if err != nil {118 if err != nil {
113- return fmt.Errorf("get config file: %w", err)119+ renderer := styles.NewConfigRenderer(app.Theme)
120+ return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("get config file: %w", err))))
114 }121 }
115 122
116 // Check if config file exists123 // Check if config file exists
117 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {124 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
118- return fmt.Errorf("config file does not exist: %s", configFile)125+ renderer := styles.NewConfigRenderer(app.Theme)
126+ return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("config file does not exist: %s", configFile))))
119 }127 }
120 128
121 editor := getEditor()129 editor := getEditor()
122 if editor == "" {130 if editor == "" {
123- return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable")131+ renderer := styles.NewConfigRenderer(app.Theme)
132+ const msg = "no editor found: set $EDITOR or $VISUAL environment variable"
133+ return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("%s", msg))))
124 }134 }
125 135
136+ // Keep this a simple, non-interactive message; the actual editor takes over the terminal.
137+ fmt.Println(styles.NewConfigRenderer(app.Theme).RenderOpening(configFile, editor))
138+
126 cmd := exec.Command(editor, configFile)139 cmd := exec.Command(editor, configFile)
127 cmd.Stdin = os.Stdin140 cmd.Stdin = os.Stdin
128 cmd.Stdout = os.Stdout141 cmd.Stdout = os.Stdout
129 cmd.Stderr = os.Stderr142 cmd.Stderr = os.Stderr
130 143
131- return cmd.Run()144+ if err := cmd.Run(); err != nil {
145+ renderer := styles.NewConfigRenderer(app.Theme)
146+ return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("open config: %w", err))))
147+ }
148+ return nil
132 }149 }
133 150
134 // getEditor returns the user's preferred editor.151 // getEditor returns the user's preferred editor.
M internal/cli/cmd/crashes.go +7 -4
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/spf13/cobra"
1313
14+ "github.com/bnema/dumber/internal/cli/styles"
1415 "github.com/bnema/dumber/internal/infrastructure/config"
1516 )
1617
@@ -95,15 +96,16 @@ func runCrashesShow(_ *cobra.Command, args []string) error {
9596 if app == nil {
9697 return fmt.Errorf("app not initialized")
9798 }
99+ renderer := styles.NewCrashesCLIRenderer(app.Theme)
98100
99101 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
100102 if err != nil {
101- return err
103+ return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList())
102104 }
103105
104106 body, err := os.ReadFile(report.MarkdownPath)
105107 if err != nil {
106- return fmt.Errorf("read crash report markdown: %w", err)
108+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
107109 }
108110 fmt.Print(string(body))
109111 return nil
@@ -114,15 +116,16 @@ func runCrashesIssue(_ *cobra.Command, args []string) error {
114116 if app == nil {
115117 return fmt.Errorf("app not initialized")
116118 }
119+ renderer := styles.NewCrashesCLIRenderer(app.Theme)
117120
118121 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
119122 if err != nil {
120- return err
123+ return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList())
121124 }
122125
123126 body, err := os.ReadFile(report.MarkdownPath)
124127 if err != nil {
125- return fmt.Errorf("read crash report markdown: %w", err)
128+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
126129 }
127130 all := string(body)
128131 start := strings.Index(all, "## GitHub Issue Template")
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
13 13
14+ "github.com/bnema/dumber/internal/cli/styles"
14 "github.com/bnema/dumber/internal/infrastructure/config"15 "github.com/bnema/dumber/internal/infrastructure/config"
15 )16 )
16 17
@@ -95,15 +96,16 @@ func runCrashesShow(_ *cobra.Command, args []string) error {
95 if app == nil {96 if app == nil {
96 return fmt.Errorf("app not initialized")97 return fmt.Errorf("app not initialized")
97 }98 }
99+ renderer := styles.NewCrashesCLIRenderer(app.Theme)
98 100
99 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])101 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
100 if err != nil {102 if err != nil {
101- return err103+ return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList())
102 }104 }
103 105
104 body, err := os.ReadFile(report.MarkdownPath)106 body, err := os.ReadFile(report.MarkdownPath)
105 if err != nil {107 if err != nil {
106- return fmt.Errorf("read crash report markdown: %w", err)108+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
107 }109 }
108 fmt.Print(string(body))110 fmt.Print(string(body))
109 return nil111 return nil
@@ -114,15 +116,16 @@ func runCrashesIssue(_ *cobra.Command, args []string) error {
114 if app == nil {116 if app == nil {
115 return fmt.Errorf("app not initialized")117 return fmt.Errorf("app not initialized")
116 }118 }
119+ renderer := styles.NewCrashesCLIRenderer(app.Theme)
117 120
118 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])121 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
119 if err != nil {122 if err != nil {
120- return err123+ return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList())
121 }124 }
122 125
123 body, err := os.ReadFile(report.MarkdownPath)126 body, err := os.ReadFile(report.MarkdownPath)
124 if err != nil {127 if err != nil {
125- return fmt.Errorf("read crash report markdown: %w", err)128+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
126 }129 }
127 all := string(body)130 all := string(body)
128 start := strings.Index(all, "## GitHub Issue Template")131 start := strings.Index(all, "## GitHub Issue Template")
M internal/cli/cmd/sessions.go +16 -43
@@ -5,13 +5,13 @@ import (
55 "fmt"
66 "os"
77 "strings"
8- "text/tabwriter"
98
109 tea "github.com/charmbracelet/bubbletea"
1110 "github.com/spf13/cobra"
1211
1312 "github.com/bnema/dumber/internal/application/usecase"
1413 "github.com/bnema/dumber/internal/cli/model"
14+ "github.com/bnema/dumber/internal/cli/styles"
1515 "github.com/bnema/dumber/internal/domain/entity"
1616 "github.com/bnema/dumber/internal/infrastructure/desktop"
1717 )
@@ -93,9 +93,10 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
9393 if app == nil {
9494 return fmt.Errorf("app not initialized")
9595 }
96+ renderer := styles.NewSessionsCLIRenderer(app.Theme)
9697
9798 if app.ListSessionsUC == nil {
98- return fmt.Errorf("session management not available")
99+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session management not available")))
99100 }
100101
101102 // Get current session ID (empty if not running as browser)
@@ -113,7 +114,8 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
113114 return outputSessionsJSON(output.Sessions)
114115 }
115116
116- return outputSessionsTable(output.Sessions)
117+ fmt.Println(renderer.RenderList(output.Sessions, sessionsLimit))
118+ return nil
117119 }
118120
119121 func outputSessionsJSON(sessions []entity.SessionInfo) error {
@@ -122,37 +124,6 @@ func outputSessionsJSON(sessions []entity.SessionInfo) error {
122124 return enc.Encode(sessions)
123125 }
124126
125-func outputSessionsTable(sessions []entity.SessionInfo) error {
126- if len(sessions) == 0 {
127- fmt.Println("No saved sessions found.")
128- return nil
129- }
130-
131- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
132- _, _ = fmt.Fprintln(w, "STATUS\tSESSION ID\tTABS\tPANES\tLAST UPDATED")
133-
134- for _, info := range sessions {
135- status := " "
136- switch {
137- case info.IsCurrent:
138- status = "●"
139- case info.IsActive:
140- status = "○"
141- }
142-
143- relTime := usecase.GetRelativeTime(info.UpdatedAt)
144- _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
145- status,
146- info.Session.ID,
147- info.TabCount,
148- info.PaneCount,
149- relTime,
150- )
151- }
152-
153- return w.Flush()
154-}
155-
156127 // sessions restore <id>
157128 var sessionsRestoreCmd = &cobra.Command{
158129 Use: "restore <session-id>",
@@ -180,15 +151,16 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
180151 if app == nil {
181152 return fmt.Errorf("app not initialized")
182153 }
154+ renderer := styles.NewSessionsCLIRenderer(app.Theme)
183155
184156 if app.RestoreUC == nil || app.ListSessionsUC == nil {
185- return fmt.Errorf("session restoration not available")
157+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session restoration not available")))
186158 }
187159
188160 // Find session by ID or suffix
189161 sessionInfo, err := findSessionByIDOrSuffix(args[0])
190162 if err != nil {
191- return err
163+ return fmt.Errorf("%s", renderer.RenderError(err))
192164 }
193165
194166 sessionID := sessionInfo.Session.ID
@@ -196,16 +168,16 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
196168 // Validate the session has restorable state
197169 _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})
198170 if err != nil {
199- return fmt.Errorf("restore session: %w", err)
171+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("restore session: %w", err)))
200172 }
201173
202174 // Spawn a new dumber instance with the session
203175 spawner := desktop.NewSessionSpawner(app.Ctx())
204176 if err := spawner.SpawnWithSession(sessionID); err != nil {
205- return fmt.Errorf("spawn browser: %w", err)
177+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("spawn browser: %w", err)))
206178 }
207179
208- fmt.Printf("Restoring session %s...\n", sessionID)
180+ fmt.Println(renderer.RenderRestoreStarted(sessionID))
209181 return nil
210182 }
211183
@@ -236,15 +208,16 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
236208 if app == nil {
237209 return fmt.Errorf("app not initialized")
238210 }
211+ renderer := styles.NewSessionsCLIRenderer(app.Theme)
239212
240213 if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {
241- return fmt.Errorf("session management not available")
214+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session management not available")))
242215 }
243216
244217 // Find session by ID or suffix
245218 sessionInfo, err := findSessionByIDOrSuffix(args[0])
246219 if err != nil {
247- return err
220+ return fmt.Errorf("%s", renderer.RenderError(err))
248221 }
249222
250223 // Get current session ID
@@ -258,10 +231,10 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
258231 SessionID: sessionInfo.Session.ID,
259232 CurrentSessionID: currentSessionID,
260233 }); err != nil {
261- return fmt.Errorf("delete session: %w", err)
234+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("delete session: %w", err)))
262235 }
263236
264- fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID)
237+ fmt.Println(renderer.RenderDeleted(sessionInfo.Session.ID))
265238 return nil
266239 }
267240
@@ -5,13 +5,13 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "strings"7 "strings"
8- "text/tabwriter"
9 8
10 tea "github.com/charmbracelet/bubbletea"9 tea "github.com/charmbracelet/bubbletea"
11 "github.com/spf13/cobra"10 "github.com/spf13/cobra"
12 11
13 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
14 "github.com/bnema/dumber/internal/cli/model"13 "github.com/bnema/dumber/internal/cli/model"
14+ "github.com/bnema/dumber/internal/cli/styles"
15 "github.com/bnema/dumber/internal/domain/entity"15 "github.com/bnema/dumber/internal/domain/entity"
16 "github.com/bnema/dumber/internal/infrastructure/desktop"16 "github.com/bnema/dumber/internal/infrastructure/desktop"
17 )17 )
@@ -93,9 +93,10 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
93 if app == nil {93 if app == nil {
94 return fmt.Errorf("app not initialized")94 return fmt.Errorf("app not initialized")
95 }95 }
96+ renderer := styles.NewSessionsCLIRenderer(app.Theme)
96 97
97 if app.ListSessionsUC == nil {98 if app.ListSessionsUC == nil {
98- return fmt.Errorf("session management not available")99+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session management not available")))
99 }100 }
100 101
101 // Get current session ID (empty if not running as browser)102 // Get current session ID (empty if not running as browser)
@@ -113,7 +114,8 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
113 return outputSessionsJSON(output.Sessions)114 return outputSessionsJSON(output.Sessions)
114 }115 }
115 116
116- return outputSessionsTable(output.Sessions)117+ fmt.Println(renderer.RenderList(output.Sessions, sessionsLimit))
118+ return nil
117 }119 }
118 120
119 func outputSessionsJSON(sessions []entity.SessionInfo) error {121 func outputSessionsJSON(sessions []entity.SessionInfo) error {
@@ -122,37 +124,6 @@ func outputSessionsJSON(sessions []entity.SessionInfo) error {
122 return enc.Encode(sessions)124 return enc.Encode(sessions)
123 }125 }
124 126
125-func outputSessionsTable(sessions []entity.SessionInfo) error {
126- if len(sessions) == 0 {
127- fmt.Println("No saved sessions found.")
128- return nil
129- }
130-
131- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
132- _, _ = fmt.Fprintln(w, "STATUS\tSESSION ID\tTABS\tPANES\tLAST UPDATED")
133-
134- for _, info := range sessions {
135- status := " "
136- switch {
137- case info.IsCurrent:
138- status = "●"
139- case info.IsActive:
140- status = "○"
141- }
142-
143- relTime := usecase.GetRelativeTime(info.UpdatedAt)
144- _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
145- status,
146- info.Session.ID,
147- info.TabCount,
148- info.PaneCount,
149- relTime,
150- )
151- }
152-
153- return w.Flush()
154-}
155-
156 // sessions restore <id>127 // sessions restore <id>
157 var sessionsRestoreCmd = &cobra.Command{128 var sessionsRestoreCmd = &cobra.Command{
158 Use: "restore <session-id>",129 Use: "restore <session-id>",
@@ -180,15 +151,16 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
180 if app == nil {151 if app == nil {
181 return fmt.Errorf("app not initialized")152 return fmt.Errorf("app not initialized")
182 }153 }
154+ renderer := styles.NewSessionsCLIRenderer(app.Theme)
183 155
184 if app.RestoreUC == nil || app.ListSessionsUC == nil {156 if app.RestoreUC == nil || app.ListSessionsUC == nil {
185- return fmt.Errorf("session restoration not available")157+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session restoration not available")))
186 }158 }
187 159
188 // Find session by ID or suffix160 // Find session by ID or suffix
189 sessionInfo, err := findSessionByIDOrSuffix(args[0])161 sessionInfo, err := findSessionByIDOrSuffix(args[0])
190 if err != nil {162 if err != nil {
191- return err163+ return fmt.Errorf("%s", renderer.RenderError(err))
192 }164 }
193 165
194 sessionID := sessionInfo.Session.ID166 sessionID := sessionInfo.Session.ID
@@ -196,16 +168,16 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
196 // Validate the session has restorable state168 // Validate the session has restorable state
197 _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})169 _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})
198 if err != nil {170 if err != nil {
199- return fmt.Errorf("restore session: %w", err)171+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("restore session: %w", err)))
200 }172 }
201 173
202 // Spawn a new dumber instance with the session174 // Spawn a new dumber instance with the session
203 spawner := desktop.NewSessionSpawner(app.Ctx())175 spawner := desktop.NewSessionSpawner(app.Ctx())
204 if err := spawner.SpawnWithSession(sessionID); err != nil {176 if err := spawner.SpawnWithSession(sessionID); err != nil {
205- return fmt.Errorf("spawn browser: %w", err)177+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("spawn browser: %w", err)))
206 }178 }
207 179
208- fmt.Printf("Restoring session %s...\n", sessionID)180+ fmt.Println(renderer.RenderRestoreStarted(sessionID))
209 return nil181 return nil
210 }182 }
211 183
@@ -236,15 +208,16 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
236 if app == nil {208 if app == nil {
237 return fmt.Errorf("app not initialized")209 return fmt.Errorf("app not initialized")
238 }210 }
211+ renderer := styles.NewSessionsCLIRenderer(app.Theme)
239 212
240 if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {213 if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {
241- return fmt.Errorf("session management not available")214+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session management not available")))
242 }215 }
243 216
244 // Find session by ID or suffix217 // Find session by ID or suffix
245 sessionInfo, err := findSessionByIDOrSuffix(args[0])218 sessionInfo, err := findSessionByIDOrSuffix(args[0])
246 if err != nil {219 if err != nil {
247- return err220+ return fmt.Errorf("%s", renderer.RenderError(err))
248 }221 }
249 222
250 // Get current session ID223 // Get current session ID
@@ -258,10 +231,10 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
258 SessionID: sessionInfo.Session.ID,231 SessionID: sessionInfo.Session.ID,
259 CurrentSessionID: currentSessionID,232 CurrentSessionID: currentSessionID,
260 }); err != nil {233 }); err != nil {
261- return fmt.Errorf("delete session: %w", err)234+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("delete session: %w", err)))
262 }235 }
263 236
264- fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID)237+ fmt.Println(renderer.RenderDeleted(sessionInfo.Session.ID))
265 return nil238 return nil
266 }239 }
267 240
M internal/cli/styles/config.go +14 -0
@@ -246,3 +246,17 @@ func (r *ConfigRenderer) RenderNoConfigFile(path string) string {
246246 hintStyle.Render("Config file will be created on first run with all defaults."),
247247 )
248248 }
249+
250+// RenderOpening renders a brief "opening config" message for `dumber config open`.
251+func (r *ConfigRenderer) RenderOpening(path, editor string) string {
252+ iconStyle := r.theme.Subtle
253+ pathStyle := r.theme.Highlight
254+ editorStyle := r.theme.Subtle
255+
256+ return fmt.Sprintf(
257+ "%s Opening %s with %s",
258+ iconStyle.Render(IconCursor),
259+ pathStyle.Render(path),
260+ editorStyle.Render(editor),
261+ )
262+}
@@ -246,3 +246,17 @@ func (r *ConfigRenderer) RenderNoConfigFile(path string) string {
246 hintStyle.Render("Config file will be created on first run with all defaults."),246 hintStyle.Render("Config file will be created on first run with all defaults."),
247 )247 )
248 }248 }
249+
250+// RenderOpening renders a brief "opening config" message for `dumber config open`.
251+func (r *ConfigRenderer) RenderOpening(path, editor string) string {
252+ iconStyle := r.theme.Subtle
253+ pathStyle := r.theme.Highlight
254+ editorStyle := r.theme.Subtle
255+
256+ return fmt.Sprintf(
257+ "%s Opening %s with %s",
258+ iconStyle.Render(IconCursor),
259+ pathStyle.Render(path),
260+ editorStyle.Render(editor),
261+ )
262+}
A internal/cli/styles/config_test.go +20 -0
@@ -0,0 +1,20 @@
1+package styles_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/dumber/internal/cli/styles"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+)
11+
12+func TestConfigRenderer_RenderOpening(t *testing.T) {
13+ theme := styles.NewTheme(config.DefaultConfig())
14+ r := styles.NewConfigRenderer(theme)
15+
16+ out := r.RenderOpening("/tmp/dumber/config.toml", "vim")
17+ require.Contains(t, out, "Opening")
18+ require.Contains(t, out, "config.toml")
19+ require.Contains(t, out, "vim")
20+}
@@ -0,0 +1,20 @@
1+package styles_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/dumber/internal/cli/styles"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+)
11+
12+func TestConfigRenderer_RenderOpening(t *testing.T) {
13+ theme := styles.NewTheme(config.DefaultConfig())
14+ r := styles.NewConfigRenderer(theme)
15+
16+ out := r.RenderOpening("/tmp/dumber/config.toml", "vim")
17+ require.Contains(t, out, "Opening")
18+ require.Contains(t, out, "config.toml")
19+ require.Contains(t, out, "vim")
20+}
A internal/cli/styles/crashes_cli.go +23 -0
@@ -0,0 +1,23 @@
1+package styles
2+
3+import (
4+ "fmt"
5+)
6+
7+// CrashesCLIRenderer renders non-interactive CLI output for crashes subcommands
8+// that otherwise print raw crash markdown to stdout.
9+type CrashesCLIRenderer struct {
10+ theme *Theme
11+}
12+
13+func NewCrashesCLIRenderer(theme *Theme) *CrashesCLIRenderer {
14+ return &CrashesCLIRenderer{theme: theme}
15+}
16+
17+func (r *CrashesCLIRenderer) RenderError(err error) string {
18+ return fmt.Sprintf("%s %v", r.theme.ErrorStyle.Render(IconX), err)
19+}
20+
21+func (r *CrashesCLIRenderer) RenderHintList() string {
22+ return r.theme.Subtle.Render("Hint: run `dumber crashes` to list available reports.")
23+}
@@ -0,0 +1,23 @@
1+package styles
2+
3+import (
4+ "fmt"
5+)
6+
7+// CrashesCLIRenderer renders non-interactive CLI output for crashes subcommands
8+// that otherwise print raw crash markdown to stdout.
9+type CrashesCLIRenderer struct {
10+ theme *Theme
11+}
12+
13+func NewCrashesCLIRenderer(theme *Theme) *CrashesCLIRenderer {
14+ return &CrashesCLIRenderer{theme: theme}
15+}
16+
17+func (r *CrashesCLIRenderer) RenderError(err error) string {
18+ return fmt.Sprintf("%s %v", r.theme.ErrorStyle.Render(IconX), err)
19+}
20+
21+func (r *CrashesCLIRenderer) RenderHintList() string {
22+ return r.theme.Subtle.Render("Hint: run `dumber crashes` to list available reports.")
23+}
A internal/cli/styles/crashes_cli_test.go +22 -0
@@ -0,0 +1,22 @@
1+package styles_test
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/cli/styles"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+)
12+
13+func TestCrashesCLIRenderer(t *testing.T) {
14+ theme := styles.NewTheme(config.DefaultConfig())
15+ r := styles.NewCrashesCLIRenderer(theme)
16+
17+ out := r.RenderError(errors.New("nope"))
18+ require.Contains(t, out, "nope")
19+
20+ h := r.RenderHintList()
21+ require.Contains(t, h, "dumber crashes")
22+}
@@ -0,0 +1,22 @@
1+package styles_test
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/cli/styles"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+)
12+
13+func TestCrashesCLIRenderer(t *testing.T) {
14+ theme := styles.NewTheme(config.DefaultConfig())
15+ r := styles.NewCrashesCLIRenderer(theme)
16+
17+ out := r.RenderError(errors.New("nope"))
18+ require.Contains(t, out, "nope")
19+
20+ h := r.RenderHintList()
21+ require.Contains(t, h, "dumber crashes")
22+}
A internal/cli/styles/sessions_cli.go +90 -0
@@ -0,0 +1,90 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// SessionsCLIRenderer renders non-interactive CLI output for sessions subcommands
12+// (e.g. `dumber sessions list`, `restore`, `delete`).
13+type SessionsCLIRenderer struct {
14+ theme *Theme
15+}
16+
17+func NewSessionsCLIRenderer(theme *Theme) *SessionsCLIRenderer {
18+ return &SessionsCLIRenderer{theme: theme}
19+}
20+
21+func (r *SessionsCLIRenderer) RenderEmptyList() string {
22+ return r.theme.Subtle.Render("No saved sessions found.")
23+}
24+
25+func (r *SessionsCLIRenderer) RenderList(items []entity.SessionInfo, limit int) string {
26+ if len(items) == 0 {
27+ return r.RenderEmptyList()
28+ }
29+
30+ var b strings.Builder
31+ title := fmt.Sprintf("%s %s", r.theme.Highlight.Render(IconSessionStack), r.theme.Title.Render("Sessions"))
32+ b.WriteString(title)
33+ if limit > 0 {
34+ b.WriteString(r.theme.Subtle.Render(fmt.Sprintf(" (showing up to %d)", limit)))
35+ }
36+ b.WriteString("\n\n")
37+
38+ for _, s := range items {
39+ b.WriteString(r.renderOne(s))
40+ b.WriteString("\n")
41+ }
42+
43+ b.WriteString("\n")
44+ b.WriteString(r.theme.Subtle.Render("Tip: use `dumber sessions` for the interactive browser."))
45+ return b.String()
46+}
47+
48+func (r *SessionsCLIRenderer) renderOne(info entity.SessionInfo) string {
49+ status := " "
50+ statusStyle := r.theme.Subtle
51+ switch {
52+ case info.IsCurrent:
53+ status = "●"
54+ statusStyle = r.theme.Highlight
55+ case info.IsActive:
56+ status = "○"
57+ statusStyle = r.theme.Subtle
58+ }
59+
60+ id := r.theme.Highlight.Render(string(info.Session.ID))
61+ tabs := r.theme.BadgeMuted.Render(fmt.Sprintf("%d tabs", info.TabCount))
62+ panes := r.theme.BadgeMuted.Render(fmt.Sprintf("%d panes", info.PaneCount))
63+ updated := r.theme.Subtle.Render(usecase.GetRelativeTime(info.UpdatedAt))
64+
65+ return fmt.Sprintf("%s %s %s %s %s",
66+ statusStyle.Render(status),
67+ id,
68+ tabs,
69+ panes,
70+ updated,
71+ )
72+}
73+
74+func (r *SessionsCLIRenderer) RenderRestoreStarted(sessionID entity.SessionID) string {
75+ return fmt.Sprintf("%s Restoring session %s...",
76+ r.theme.SuccessStyle.Render(IconRestore),
77+ r.theme.Highlight.Render(string(sessionID)),
78+ )
79+}
80+
81+func (r *SessionsCLIRenderer) RenderDeleted(sessionID entity.SessionID) string {
82+ return fmt.Sprintf("%s Session %s deleted.",
83+ r.theme.SuccessStyle.Render(IconCheck),
84+ r.theme.Highlight.Render(string(sessionID)),
85+ )
86+}
87+
88+func (r *SessionsCLIRenderer) RenderError(err error) string {
89+ return fmt.Sprintf("%s %v", r.theme.ErrorStyle.Render(IconX), err)
90+}
@@ -0,0 +1,90 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// SessionsCLIRenderer renders non-interactive CLI output for sessions subcommands
12+// (e.g. `dumber sessions list`, `restore`, `delete`).
13+type SessionsCLIRenderer struct {
14+ theme *Theme
15+}
16+
17+func NewSessionsCLIRenderer(theme *Theme) *SessionsCLIRenderer {
18+ return &SessionsCLIRenderer{theme: theme}
19+}
20+
21+func (r *SessionsCLIRenderer) RenderEmptyList() string {
22+ return r.theme.Subtle.Render("No saved sessions found.")
23+}
24+
25+func (r *SessionsCLIRenderer) RenderList(items []entity.SessionInfo, limit int) string {
26+ if len(items) == 0 {
27+ return r.RenderEmptyList()
28+ }
29+
30+ var b strings.Builder
31+ title := fmt.Sprintf("%s %s", r.theme.Highlight.Render(IconSessionStack), r.theme.Title.Render("Sessions"))
32+ b.WriteString(title)
33+ if limit > 0 {
34+ b.WriteString(r.theme.Subtle.Render(fmt.Sprintf(" (showing up to %d)", limit)))
35+ }
36+ b.WriteString("\n\n")
37+
38+ for _, s := range items {
39+ b.WriteString(r.renderOne(s))
40+ b.WriteString("\n")
41+ }
42+
43+ b.WriteString("\n")
44+ b.WriteString(r.theme.Subtle.Render("Tip: use `dumber sessions` for the interactive browser."))
45+ return b.String()
46+}
47+
48+func (r *SessionsCLIRenderer) renderOne(info entity.SessionInfo) string {
49+ status := " "
50+ statusStyle := r.theme.Subtle
51+ switch {
52+ case info.IsCurrent:
53+ status = "●"
54+ statusStyle = r.theme.Highlight
55+ case info.IsActive:
56+ status = "○"
57+ statusStyle = r.theme.Subtle
58+ }
59+
60+ id := r.theme.Highlight.Render(string(info.Session.ID))
61+ tabs := r.theme.BadgeMuted.Render(fmt.Sprintf("%d tabs", info.TabCount))
62+ panes := r.theme.BadgeMuted.Render(fmt.Sprintf("%d panes", info.PaneCount))
63+ updated := r.theme.Subtle.Render(usecase.GetRelativeTime(info.UpdatedAt))
64+
65+ return fmt.Sprintf("%s %s %s %s %s",
66+ statusStyle.Render(status),
67+ id,
68+ tabs,
69+ panes,
70+ updated,
71+ )
72+}
73+
74+func (r *SessionsCLIRenderer) RenderRestoreStarted(sessionID entity.SessionID) string {
75+ return fmt.Sprintf("%s Restoring session %s...",
76+ r.theme.SuccessStyle.Render(IconRestore),
77+ r.theme.Highlight.Render(string(sessionID)),
78+ )
79+}
80+
81+func (r *SessionsCLIRenderer) RenderDeleted(sessionID entity.SessionID) string {
82+ return fmt.Sprintf("%s Session %s deleted.",
83+ r.theme.SuccessStyle.Render(IconCheck),
84+ r.theme.Highlight.Render(string(sessionID)),
85+ )
86+}
87+
88+func (r *SessionsCLIRenderer) RenderError(err error) string {
89+ return fmt.Sprintf("%s %v", r.theme.ErrorStyle.Render(IconX), err)
90+}
A internal/cli/styles/sessions_cli_test.go +40 -0
@@ -0,0 +1,40 @@
1+package styles_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/cli/styles"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/infrastructure/config"
13+)
14+
15+func TestSessionsCLIRenderer(t *testing.T) {
16+ theme := styles.NewTheme(config.DefaultConfig())
17+ r := styles.NewSessionsCLIRenderer(theme)
18+
19+ out := r.RenderEmptyList()
20+ require.Contains(t, out, "No saved sessions found.")
21+
22+ now := time.Now().UTC()
23+ items := []entity.SessionInfo{
24+ {
25+ Session: &entity.Session{ID: entity.SessionID("20260210_120000_abcd")},
26+ TabCount: 3,
27+ PaneCount: 5,
28+ IsCurrent: true,
29+ UpdatedAt: now,
30+ },
31+ }
32+ out = r.RenderList(items, 20)
33+ require.Contains(t, out, "Sessions")
34+ require.Contains(t, out, "20260210_120000_abcd")
35+ require.Contains(t, out, "3 tabs")
36+ require.Contains(t, out, "5 panes")
37+
38+ errOut := r.RenderError(errors.New("boom"))
39+ require.Contains(t, errOut, "boom")
40+}
@@ -0,0 +1,40 @@
1+package styles_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/cli/styles"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/infrastructure/config"
13+)
14+
15+func TestSessionsCLIRenderer(t *testing.T) {
16+ theme := styles.NewTheme(config.DefaultConfig())
17+ r := styles.NewSessionsCLIRenderer(theme)
18+
19+ out := r.RenderEmptyList()
20+ require.Contains(t, out, "No saved sessions found.")
21+
22+ now := time.Now().UTC()
23+ items := []entity.SessionInfo{
24+ {
25+ Session: &entity.Session{ID: entity.SessionID("20260210_120000_abcd")},
26+ TabCount: 3,
27+ PaneCount: 5,
28+ IsCurrent: true,
29+ UpdatedAt: now,
30+ },
31+ }
32+ out = r.RenderList(items, 20)
33+ require.Contains(t, out, "Sessions")
34+ require.Contains(t, out, "20260210_120000_abcd")
35+ require.Contains(t, out, "3 tabs")
36+ require.Contains(t, out, "5 panes")
37+
38+ errOut := r.RenderError(errors.New("boom"))
39+ require.Contains(t, errOut, "boom")
40+}

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