detached 96b6ff4

Merge pull request #166 from bnema/feature/cli-align-styles-missing

cli: align non-TUI subcommands with internal/cli/styles
commit
96b6ff402e4e647f6ab949075a471554186f1314
author
bnema · · 2026-02-11 19:57 UTC
committer
GitHub · · 2026-02-11 19:57 UTC
merge of
7576c27 + f13a4f4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
13 files · +565 -83
A docs/plans/2026-02-10-cli-style-alignment-missing-commands.md +96 -0
@@ -0,0 +1,96 @@
1+# CLI Style Alignment Plan (Missing Commands)
2+
3+This plan targets CLI commands/subcommands that currently do not use `internal/cli/styles` renderers and also do not use Charmbracelet TUI packages (`lipgloss`, `bubbles`, `bubbletea`) in their execution path.
4+
5+Excluded by request: `help`, `completion`, `gen-docs`, `browse`.
6+
7+## Scope
8+
9+Commands/subcommands to align:
10+
11+- `dumber config open`
12+- `dumber crashes show <report|latest>`
13+- `dumber crashes issue <report|latest>`
14+- `dumber sessions list`
15+- `dumber sessions restore <session-id>`
16+- `dumber sessions delete <session-id>`
17+
18+Non-goals:
19+
20+- No changes to use cases, repositories, or adapters.
21+- No changes to output formats for `--json` flags.
22+
23+## Approach
24+
25+1. Add small, focused renderers under `internal/cli/styles/` for these command outputs.
26+2. Update the Cobra handlers in `internal/cli/cmd/` to use those renderers for:
27+ - Success messages
28+ - “not found / nothing to do” messages
29+ - User-facing errors (print styled message and return `nil`, consistent with existing commands like `config status`)
30+3. Keep “raw content” outputs raw where users likely pipe/copy them (crash report markdown), and only style surrounding framing text.
31+
32+## Proposed Renderer Additions
33+
34+- `internal/cli/styles/sessions_cli.go`
35+ - `type SessionsCLIRenderer struct { theme *Theme }`
36+ - `RenderListHeader(limit int) string`
37+ - `RenderListEmpty() string`
38+ - `RenderListRow(info entity.SessionInfo) string` (status icon + counts + relative time styling)
39+ - `RenderRestoreStarted(sessionID string) string`
40+ - `RenderDeleteOK(sessionID string) string`
41+ - `RenderError(err error) string`
42+
43+- `internal/cli/styles/crashes_cli.go`
44+ - `type CrashesCLIRenderer struct { theme *Theme }`
45+ - `RenderShowHeader(id string, generatedAt string) string` (optional)
46+ - `RenderIssueHeader(id string) string` (optional, default off if it would break copy/paste)
47+ - `RenderError(err error) string`
48+
49+- `internal/cli/styles/config_cli.go` (or extend existing `config.go`)
50+ - Add `RenderOpening(path, editor string) string`
51+ - Add `RenderOpenHint(path string) string` (e.g., when file missing)
52+ - Reuse `RenderError(err)` from `ConfigRenderer`
53+
54+## Command Changes
55+
56+- `internal/cli/cmd/config.go`
57+ - In `runConfigOpen`:
58+ - Use `styles.NewConfigRenderer(app.Theme)` (requires `GetApp()`/theme).
59+ - Print styled “opening …” message (optional).
60+ - For user-facing failures (missing config, missing editor), print renderer error/hint and return `nil` so Cobra doesn’t print a second unstyled error.
61+
62+- `internal/cli/cmd/crashes.go`
63+ - In `runCrashesShow` / `runCrashesIssue`:
64+ - Use a new `CrashesCLIRenderer` for framing/errors.
65+ - Keep the markdown body as-is on stdout.
66+
67+- `internal/cli/cmd/sessions.go`
68+ - In `runSessionsList`:
69+ - Keep `--json` identical.
70+ - Replace plain tabwriter output with themed lines (or a boxed, fixed-width table) produced by `SessionsCLIRenderer`.
71+ - In `runSessionsRestore` / `runSessionsDelete`:
72+ - Replace plain `fmt.Printf` success lines with themed status lines using icons from `internal/cli/styles/icons.go`.
73+ - Style user-facing errors similarly (print and return `nil`).
74+
75+## Tests
76+
77+Add renderer unit tests (no bubbletea runtime needed):
78+
79+- `internal/cli/styles/sessions_cli_test.go`
80+- `internal/cli/styles/crashes_cli_test.go`
81+- `internal/cli/styles/config_cli_test.go` (if adding new config-open rendering)
82+
83+Test strategy:
84+
85+- Use `styles.NewTheme(config.DefaultConfig())` to build a theme.
86+- Assert key substrings are present (icons, command nouns, session IDs), avoiding strict ANSI comparisons.
87+
88+## Verification
89+
90+- `go test ./...`
91+- `make lint`
92+- Manual smoke:
93+ - `go run ./cmd/dumber sessions list`
94+ - `go run ./cmd/dumber sessions delete <id>`
95+ - `go run ./cmd/dumber crashes show latest`
96+
@@ -0,0 +1,96 @@
1+# CLI Style Alignment Plan (Missing Commands)
2+
3+This plan targets CLI commands/subcommands that currently do not use `internal/cli/styles` renderers and also do not use Charmbracelet TUI packages (`lipgloss`, `bubbles`, `bubbletea`) in their execution path.
4+
5+Excluded by request: `help`, `completion`, `gen-docs`, `browse`.
6+
7+## Scope
8+
9+Commands/subcommands to align:
10+
11+- `dumber config open`
12+- `dumber crashes show <report|latest>`
13+- `dumber crashes issue <report|latest>`
14+- `dumber sessions list`
15+- `dumber sessions restore <session-id>`
16+- `dumber sessions delete <session-id>`
17+
18+Non-goals:
19+
20+- No changes to use cases, repositories, or adapters.
21+- No changes to output formats for `--json` flags.
22+
23+## Approach
24+
25+1. Add small, focused renderers under `internal/cli/styles/` for these command outputs.
26+2. Update the Cobra handlers in `internal/cli/cmd/` to use those renderers for:
27+ - Success messages
28+ - “not found / nothing to do” messages
29+ - User-facing errors (print styled message and return `nil`, consistent with existing commands like `config status`)
30+3. Keep “raw content” outputs raw where users likely pipe/copy them (crash report markdown), and only style surrounding framing text.
31+
32+## Proposed Renderer Additions
33+
34+- `internal/cli/styles/sessions_cli.go`
35+ - `type SessionsCLIRenderer struct { theme *Theme }`
36+ - `RenderListHeader(limit int) string`
37+ - `RenderListEmpty() string`
38+ - `RenderListRow(info entity.SessionInfo) string` (status icon + counts + relative time styling)
39+ - `RenderRestoreStarted(sessionID string) string`
40+ - `RenderDeleteOK(sessionID string) string`
41+ - `RenderError(err error) string`
42+
43+- `internal/cli/styles/crashes_cli.go`
44+ - `type CrashesCLIRenderer struct { theme *Theme }`
45+ - `RenderShowHeader(id string, generatedAt string) string` (optional)
46+ - `RenderIssueHeader(id string) string` (optional, default off if it would break copy/paste)
47+ - `RenderError(err error) string`
48+
49+- `internal/cli/styles/config_cli.go` (or extend existing `config.go`)
50+ - Add `RenderOpening(path, editor string) string`
51+ - Add `RenderOpenHint(path string) string` (e.g., when file missing)
52+ - Reuse `RenderError(err)` from `ConfigRenderer`
53+
54+## Command Changes
55+
56+- `internal/cli/cmd/config.go`
57+ - In `runConfigOpen`:
58+ - Use `styles.NewConfigRenderer(app.Theme)` (requires `GetApp()`/theme).
59+ - Print styled “opening …” message (optional).
60+ - For user-facing failures (missing config, missing editor), print renderer error/hint and return `nil` so Cobra doesn’t print a second unstyled error.
61+
62+- `internal/cli/cmd/crashes.go`
63+ - In `runCrashesShow` / `runCrashesIssue`:
64+ - Use a new `CrashesCLIRenderer` for framing/errors.
65+ - Keep the markdown body as-is on stdout.
66+
67+- `internal/cli/cmd/sessions.go`
68+ - In `runSessionsList`:
69+ - Keep `--json` identical.
70+ - Replace plain tabwriter output with themed lines (or a boxed, fixed-width table) produced by `SessionsCLIRenderer`.
71+ - In `runSessionsRestore` / `runSessionsDelete`:
72+ - Replace plain `fmt.Printf` success lines with themed status lines using icons from `internal/cli/styles/icons.go`.
73+ - Style user-facing errors similarly (print and return `nil`).
74+
75+## Tests
76+
77+Add renderer unit tests (no bubbletea runtime needed):
78+
79+- `internal/cli/styles/sessions_cli_test.go`
80+- `internal/cli/styles/crashes_cli_test.go`
81+- `internal/cli/styles/config_cli_test.go` (if adding new config-open rendering)
82+
83+Test strategy:
84+
85+- Use `styles.NewTheme(config.DefaultConfig())` to build a theme.
86+- Assert key substrings are present (icons, command nouns, session IDs), avoiding strict ANSI comparisons.
87+
88+## Verification
89+
90+- `go test ./...`
91+- `make lint`
92+- Manual smoke:
93+ - `go run ./cmd/dumber sessions list`
94+ - `go run ./cmd/dumber sessions delete <id>`
95+ - `go run ./cmd/dumber crashes show latest`
96+
M internal/cli/cmd/config.go +27 -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,49 @@ 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+
117+ renderer := styles.NewConfigRenderer(app.Theme)
118+
111119 configFile, err := config.GetConfigFile()
112120 if err != nil {
113- return fmt.Errorf("get config file: %w", err)
121+ wrappedErr := fmt.Errorf("get config file: %w", err)
122+ fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(wrappedErr), "\n"))
123+ return wrapPrintedError(wrappedErr)
114124 }
115125
116126 // Check if config file exists
117127 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
118- return fmt.Errorf("config file does not exist: %s", configFile)
128+ err := fmt.Errorf("config file does not exist: %s", configFile)
129+ fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(err), "\n"))
130+ return wrapPrintedError(err)
119131 }
120132
121133 editor := getEditor()
122134 if editor == "" {
123- return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable")
135+ const msg = "no editor found: set $EDITOR or $VISUAL environment variable"
136+ err := fmt.Errorf("%s", msg)
137+ fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(err), "\n"))
138+ return wrapPrintedError(err)
124139 }
125140
141+ // Keep this a simple, non-interactive message; the actual editor takes over the terminal.
142+ fmt.Println(renderer.RenderOpening(configFile, editor))
143+
126144 cmd := exec.Command(editor, configFile)
127145 cmd.Stdin = os.Stdin
128146 cmd.Stdout = os.Stdout
129147 cmd.Stderr = os.Stderr
130148
131- return cmd.Run()
149+ if err := cmd.Run(); err != nil {
150+ wrappedErr := fmt.Errorf("open config: %w", err)
151+ fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(wrappedErr), "\n"))
152+ return wrapPrintedError(wrappedErr)
153+ }
154+ return nil
132155 }
133156
134157 // 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,49 @@ 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+
117+ renderer := styles.NewConfigRenderer(app.Theme)
118+
111 configFile, err := config.GetConfigFile()119 configFile, err := config.GetConfigFile()
112 if err != nil {120 if err != nil {
113- return fmt.Errorf("get config file: %w", err)121+ wrappedErr := fmt.Errorf("get config file: %w", err)
122+ fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(wrappedErr), "\n"))
123+ return wrapPrintedError(wrappedErr)
114 }124 }
115 125
116 // Check if config file exists126 // Check if config file exists
117 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {127 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
118- return fmt.Errorf("config file does not exist: %s", configFile)128+ err := fmt.Errorf("config file does not exist: %s", configFile)
129+ fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(err), "\n"))
130+ return wrapPrintedError(err)
119 }131 }
120 132
121 editor := getEditor()133 editor := getEditor()
122 if editor == "" {134 if editor == "" {
123- return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable")135+ const msg = "no editor found: set $EDITOR or $VISUAL environment variable"
136+ err := fmt.Errorf("%s", msg)
137+ fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(err), "\n"))
138+ return wrapPrintedError(err)
124 }139 }
125 140
141+ // Keep this a simple, non-interactive message; the actual editor takes over the terminal.
142+ fmt.Println(renderer.RenderOpening(configFile, editor))
143+
126 cmd := exec.Command(editor, configFile)144 cmd := exec.Command(editor, configFile)
127 cmd.Stdin = os.Stdin145 cmd.Stdin = os.Stdin
128 cmd.Stdout = os.Stdout146 cmd.Stdout = os.Stdout
129 cmd.Stderr = os.Stderr147 cmd.Stderr = os.Stderr
130 148
131- return cmd.Run()149+ if err := cmd.Run(); err != nil {
150+ wrappedErr := fmt.Errorf("open config: %w", err)
151+ fmt.Fprintln(os.Stderr, strings.TrimRight(renderer.RenderError(wrappedErr), "\n"))
152+ return wrapPrintedError(wrappedErr)
153+ }
154+ return nil
132 }155 }
133 156
134 // getEditor returns the user's preferred editor.157 // getEditor returns the user's preferred editor.
M internal/cli/cmd/crashes.go +11 -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,18 @@ 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+ _, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
104+ return wrapPrintedError(fmt.Errorf("resolve crash report: %w", err))
102105 }
103106
104107 body, err := os.ReadFile(report.MarkdownPath)
105108 if err != nil {
106- return fmt.Errorf("read crash report markdown: %w", err)
109+ _, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
110+ return wrapPrintedError(fmt.Errorf("read crash report markdown: %w", err))
107111 }
108112 fmt.Print(string(body))
109113 return nil
@@ -114,15 +118,18 @@ func runCrashesIssue(_ *cobra.Command, args []string) error {
114118 if app == nil {
115119 return fmt.Errorf("app not initialized")
116120 }
121+ renderer := styles.NewCrashesCLIRenderer(app.Theme)
117122
118123 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
119124 if err != nil {
120- return err
125+ _, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
126+ return wrapPrintedError(fmt.Errorf("resolve crash report: %w", err))
121127 }
122128
123129 body, err := os.ReadFile(report.MarkdownPath)
124130 if err != nil {
125- return fmt.Errorf("read crash report markdown: %w", err)
131+ _, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
132+ return wrapPrintedError(fmt.Errorf("read crash report markdown: %w", err))
126133 }
127134 all := string(body)
128135 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,18 @@ 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+ _, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
104+ return wrapPrintedError(fmt.Errorf("resolve crash report: %w", err))
102 }105 }
103 106
104 body, err := os.ReadFile(report.MarkdownPath)107 body, err := os.ReadFile(report.MarkdownPath)
105 if err != nil {108 if err != nil {
106- return fmt.Errorf("read crash report markdown: %w", err)109+ _, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
110+ return wrapPrintedError(fmt.Errorf("read crash report markdown: %w", err))
107 }111 }
108 fmt.Print(string(body))112 fmt.Print(string(body))
109 return nil113 return nil
@@ -114,15 +118,18 @@ func runCrashesIssue(_ *cobra.Command, args []string) error {
114 if app == nil {118 if app == nil {
115 return fmt.Errorf("app not initialized")119 return fmt.Errorf("app not initialized")
116 }120 }
121+ renderer := styles.NewCrashesCLIRenderer(app.Theme)
117 122
118 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])123 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
119 if err != nil {124 if err != nil {
120- return err125+ _, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
126+ return wrapPrintedError(fmt.Errorf("resolve crash report: %w", err))
121 }127 }
122 128
123 body, err := os.ReadFile(report.MarkdownPath)129 body, err := os.ReadFile(report.MarkdownPath)
124 if err != nil {130 if err != nil {
125- return fmt.Errorf("read crash report markdown: %w", err)131+ _, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
132+ return wrapPrintedError(fmt.Errorf("read crash report markdown: %w", err))
126 }133 }
127 all := string(body)134 all := string(body)
128 start := strings.Index(all, "## GitHub Issue Template")135 start := strings.Index(all, "## GitHub Issue Template")
M internal/cli/cmd/root.go +34 -2
@@ -2,6 +2,7 @@
22 package cmd
33
44 import (
5+ "errors"
56 "fmt"
67 "os"
78
@@ -15,8 +16,10 @@ var (
1516 app *cli.App
1617 buildInfo build.Info
1718 rootCmd = &cobra.Command{
18- Use: "dumber",
19- Short: "A fully unfeatured unbloated browser for tiling WMs",
19+ Use: "dumber",
20+ Short: "A fully unfeatured unbloated browser for tiling WMs",
21+ SilenceErrors: true,
22+ SilenceUsage: true,
2023 Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer.
2124
2225 A fully unfeatured unbloated browser for tiling WMs, built with GTK4 and WebKitGTK.
@@ -61,11 +64,40 @@ management.`,
6164 // Execute runs the root command.
6265 func Execute() {
6366 if err := rootCmd.Execute(); err != nil {
67+ var printedErr *printedError
68+ if errors.As(err, &printedErr) {
69+ os.Exit(1)
70+ }
6471 fmt.Fprintln(os.Stderr, err)
6572 os.Exit(1)
6673 }
6774 }
6875
76+type printedError struct {
77+ err error
78+}
79+
80+func (e *printedError) Error() string {
81+ if e == nil || e.err == nil {
82+ return ""
83+ }
84+ return e.err.Error()
85+}
86+
87+func (e *printedError) Unwrap() error {
88+ if e == nil {
89+ return nil
90+ }
91+ return e.err
92+}
93+
94+func wrapPrintedError(err error) error {
95+ if err == nil {
96+ return nil
97+ }
98+ return &printedError{err: err}
99+}
100+
69101 // GetApp returns the initialized app (for use by subcommands).
70102 func GetApp() *cli.App {
71103 return app
@@ -2,6 +2,7 @@
2 package cmd2 package cmd
3 3
4 import (4 import (
5+ "errors"
5 "fmt"6 "fmt"
6 "os"7 "os"
7 8
@@ -15,8 +16,10 @@ var (
15 app *cli.App16 app *cli.App
16 buildInfo build.Info17 buildInfo build.Info
17 rootCmd = &cobra.Command{18 rootCmd = &cobra.Command{
18- Use: "dumber",19+ Use: "dumber",
19- Short: "A fully unfeatured unbloated browser for tiling WMs",20+ Short: "A fully unfeatured unbloated browser for tiling WMs",
21+ SilenceErrors: true,
22+ SilenceUsage: true,
20 Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer.23 Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer.
21 24
22 A fully unfeatured unbloated browser for tiling WMs, built with GTK4 and WebKitGTK.25 A fully unfeatured unbloated browser for tiling WMs, built with GTK4 and WebKitGTK.
@@ -61,11 +64,40 @@ management.`,
61 // Execute runs the root command.64 // Execute runs the root command.
62 func Execute() {65 func Execute() {
63 if err := rootCmd.Execute(); err != nil {66 if err := rootCmd.Execute(); err != nil {
67+ var printedErr *printedError
68+ if errors.As(err, &printedErr) {
69+ os.Exit(1)
70+ }
64 fmt.Fprintln(os.Stderr, err)71 fmt.Fprintln(os.Stderr, err)
65 os.Exit(1)72 os.Exit(1)
66 }73 }
67 }74 }
68 75
76+type printedError struct {
77+ err error
78+}
79+
80+func (e *printedError) Error() string {
81+ if e == nil || e.err == nil {
82+ return ""
83+ }
84+ return e.err.Error()
85+}
86+
87+func (e *printedError) Unwrap() error {
88+ if e == nil {
89+ return nil
90+ }
91+ return e.err
92+}
93+
94+func wrapPrintedError(err error) error {
95+ if err == nil {
96+ return nil
97+ }
98+ return &printedError{err: err}
99+}
100+
69 // GetApp returns the initialized app (for use by subcommands).101 // GetApp returns the initialized app (for use by subcommands).
70 func GetApp() *cli.App {102 func GetApp() *cli.App {
71 return app103 return app
M internal/cli/cmd/sessions.go +33 -44
@@ -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,12 @@ 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+ err := fmt.Errorf("session management not available")
100+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
101+ return wrapPrintedError(err)
99102 }
100103
101104 // Get current session ID (empty if not running as browser)
@@ -106,14 +109,17 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
106109
107110 output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)
108111 if err != nil {
109- return fmt.Errorf("list sessions: %w", err)
112+ wrappedErr := fmt.Errorf("list sessions: %w", err)
113+ fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
114+ return wrapPrintedError(wrappedErr)
110115 }
111116
112117 if sessionsJSON {
113118 return outputSessionsJSON(output.Sessions)
114119 }
115120
116- return outputSessionsTable(output.Sessions)
121+ fmt.Println(renderer.RenderList(output.Sessions, sessionsLimit))
122+ return nil
117123 }
118124
119125 func outputSessionsJSON(sessions []entity.SessionInfo) error {
@@ -122,37 +128,6 @@ func outputSessionsJSON(sessions []entity.SessionInfo) error {
122128 return enc.Encode(sessions)
123129 }
124130
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-
156131 // sessions restore <id>
157132 var sessionsRestoreCmd = &cobra.Command{
158133 Use: "restore <session-id>",
@@ -180,15 +155,19 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
180155 if app == nil {
181156 return fmt.Errorf("app not initialized")
182157 }
158+ renderer := styles.NewSessionsCLIRenderer(app.Theme)
183159
184160 if app.RestoreUC == nil || app.ListSessionsUC == nil {
185- return fmt.Errorf("session restoration not available")
161+ err := fmt.Errorf("session restoration not available")
162+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
163+ return wrapPrintedError(err)
186164 }
187165
188166 // Find session by ID or suffix
189167 sessionInfo, err := findSessionByIDOrSuffix(args[0])
190168 if err != nil {
191- return err
169+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
170+ return wrapPrintedError(err)
192171 }
193172
194173 sessionID := sessionInfo.Session.ID
@@ -196,16 +175,20 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
196175 // Validate the session has restorable state
197176 _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})
198177 if err != nil {
199- return fmt.Errorf("restore session: %w", err)
178+ wrappedErr := fmt.Errorf("restore session: %w", err)
179+ fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
180+ return wrapPrintedError(wrappedErr)
200181 }
201182
202183 // Spawn a new dumber instance with the session
203184 spawner := desktop.NewSessionSpawner(app.Ctx())
204185 if err := spawner.SpawnWithSession(sessionID); err != nil {
205- return fmt.Errorf("spawn browser: %w", err)
186+ wrappedErr := fmt.Errorf("spawn browser: %w", err)
187+ fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
188+ return wrapPrintedError(wrappedErr)
206189 }
207190
208- fmt.Printf("Restoring session %s...\n", sessionID)
191+ fmt.Println(renderer.RenderRestoreStarted(sessionID))
209192 return nil
210193 }
211194
@@ -236,15 +219,19 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
236219 if app == nil {
237220 return fmt.Errorf("app not initialized")
238221 }
222+ renderer := styles.NewSessionsCLIRenderer(app.Theme)
239223
240224 if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {
241- return fmt.Errorf("session management not available")
225+ err := fmt.Errorf("session management not available")
226+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
227+ return wrapPrintedError(err)
242228 }
243229
244230 // Find session by ID or suffix
245231 sessionInfo, err := findSessionByIDOrSuffix(args[0])
246232 if err != nil {
247- return err
233+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
234+ return wrapPrintedError(err)
248235 }
249236
250237 // Get current session ID
@@ -258,10 +245,12 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
258245 SessionID: sessionInfo.Session.ID,
259246 CurrentSessionID: currentSessionID,
260247 }); err != nil {
261- return fmt.Errorf("delete session: %w", err)
248+ wrappedErr := fmt.Errorf("delete session: %w", err)
249+ fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
250+ return wrapPrintedError(wrappedErr)
262251 }
263252
264- fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID)
253+ fmt.Println(renderer.RenderDeleted(sessionInfo.Session.ID))
265254 return nil
266255 }
267256
@@ -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,12 @@ 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+ err := fmt.Errorf("session management not available")
100+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
101+ return wrapPrintedError(err)
99 }102 }
100 103
101 // Get current session ID (empty if not running as browser)104 // Get current session ID (empty if not running as browser)
@@ -106,14 +109,17 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
106 109
107 output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)110 output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)
108 if err != nil {111 if err != nil {
109- return fmt.Errorf("list sessions: %w", err)112+ wrappedErr := fmt.Errorf("list sessions: %w", err)
113+ fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
114+ return wrapPrintedError(wrappedErr)
110 }115 }
111 116
112 if sessionsJSON {117 if sessionsJSON {
113 return outputSessionsJSON(output.Sessions)118 return outputSessionsJSON(output.Sessions)
114 }119 }
115 120
116- return outputSessionsTable(output.Sessions)121+ fmt.Println(renderer.RenderList(output.Sessions, sessionsLimit))
122+ return nil
117 }123 }
118 124
119 func outputSessionsJSON(sessions []entity.SessionInfo) error {125 func outputSessionsJSON(sessions []entity.SessionInfo) error {
@@ -122,37 +128,6 @@ func outputSessionsJSON(sessions []entity.SessionInfo) error {
122 return enc.Encode(sessions)128 return enc.Encode(sessions)
123 }129 }
124 130
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>131 // sessions restore <id>
157 var sessionsRestoreCmd = &cobra.Command{132 var sessionsRestoreCmd = &cobra.Command{
158 Use: "restore <session-id>",133 Use: "restore <session-id>",
@@ -180,15 +155,19 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
180 if app == nil {155 if app == nil {
181 return fmt.Errorf("app not initialized")156 return fmt.Errorf("app not initialized")
182 }157 }
158+ renderer := styles.NewSessionsCLIRenderer(app.Theme)
183 159
184 if app.RestoreUC == nil || app.ListSessionsUC == nil {160 if app.RestoreUC == nil || app.ListSessionsUC == nil {
185- return fmt.Errorf("session restoration not available")161+ err := fmt.Errorf("session restoration not available")
162+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
163+ return wrapPrintedError(err)
186 }164 }
187 165
188 // Find session by ID or suffix166 // Find session by ID or suffix
189 sessionInfo, err := findSessionByIDOrSuffix(args[0])167 sessionInfo, err := findSessionByIDOrSuffix(args[0])
190 if err != nil {168 if err != nil {
191- return err169+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
170+ return wrapPrintedError(err)
192 }171 }
193 172
194 sessionID := sessionInfo.Session.ID173 sessionID := sessionInfo.Session.ID
@@ -196,16 +175,20 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
196 // Validate the session has restorable state175 // Validate the session has restorable state
197 _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})176 _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})
198 if err != nil {177 if err != nil {
199- return fmt.Errorf("restore session: %w", err)178+ wrappedErr := fmt.Errorf("restore session: %w", err)
179+ fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
180+ return wrapPrintedError(wrappedErr)
200 }181 }
201 182
202 // Spawn a new dumber instance with the session183 // Spawn a new dumber instance with the session
203 spawner := desktop.NewSessionSpawner(app.Ctx())184 spawner := desktop.NewSessionSpawner(app.Ctx())
204 if err := spawner.SpawnWithSession(sessionID); err != nil {185 if err := spawner.SpawnWithSession(sessionID); err != nil {
205- return fmt.Errorf("spawn browser: %w", err)186+ wrappedErr := fmt.Errorf("spawn browser: %w", err)
187+ fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
188+ return wrapPrintedError(wrappedErr)
206 }189 }
207 190
208- fmt.Printf("Restoring session %s...\n", sessionID)191+ fmt.Println(renderer.RenderRestoreStarted(sessionID))
209 return nil192 return nil
210 }193 }
211 194
@@ -236,15 +219,19 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
236 if app == nil {219 if app == nil {
237 return fmt.Errorf("app not initialized")220 return fmt.Errorf("app not initialized")
238 }221 }
222+ renderer := styles.NewSessionsCLIRenderer(app.Theme)
239 223
240 if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {224 if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {
241- return fmt.Errorf("session management not available")225+ err := fmt.Errorf("session management not available")
226+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
227+ return wrapPrintedError(err)
242 }228 }
243 229
244 // Find session by ID or suffix230 // Find session by ID or suffix
245 sessionInfo, err := findSessionByIDOrSuffix(args[0])231 sessionInfo, err := findSessionByIDOrSuffix(args[0])
246 if err != nil {232 if err != nil {
247- return err233+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
234+ return wrapPrintedError(err)
248 }235 }
249 236
250 // Get current session ID237 // Get current session ID
@@ -258,10 +245,12 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
258 SessionID: sessionInfo.Session.ID,245 SessionID: sessionInfo.Session.ID,
259 CurrentSessionID: currentSessionID,246 CurrentSessionID: currentSessionID,
260 }); err != nil {247 }); err != nil {
261- return fmt.Errorf("delete session: %w", err)248+ wrappedErr := fmt.Errorf("delete session: %w", err)
249+ fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
250+ return wrapPrintedError(wrappedErr)
262 }251 }
263 252
264- fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID)253+ fmt.Println(renderer.RenderDeleted(sessionInfo.Session.ID))
265 return nil254 return nil
266 }255 }
267 256
M internal/cli/model/sessions.go +104 -29
@@ -353,40 +353,43 @@ func (m SessionsModel) View() string {
353353 }
354354
355355 t := m.theme
356- var b strings.Builder
356+ var prefix strings.Builder
357357
358358 // Header
359- header := m.renderHeader()
360- b.WriteString(header)
361- b.WriteString("\n\n")
359+ prefix.WriteString(m.renderHeader())
360+ prefix.WriteString("\n")
362361
363362 // Error display
364363 if m.err != nil {
365- b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366- b.WriteString("\n\n")
364+ prefix.WriteString("\n")
365+ prefix.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366+ prefix.WriteString("\n")
367367 }
368368
369369 // Status message
370370 if m.statusMessage != "" {
371- b.WriteString(t.Subtle.Render(m.statusMessage))
372- b.WriteString("\n\n")
371+ prefix.WriteString("\n")
372+ prefix.WriteString(t.Subtle.Render(m.statusMessage))
373+ prefix.WriteString("\n")
373374 }
374375
375- // Sessions list
376- if len(m.sessions) == 0 {
377- b.WriteString(t.Subtle.Render(" No saved sessions found."))
378- b.WriteString("\n")
379- } else {
380- b.WriteString(m.renderSessionsList())
381- }
382-
383- b.WriteString("\n")
384-
385376 // Help
386377 helpView := m.help.View(m.keys)
387- b.WriteString(helpView)
388378
389- return b.String()
379+ // Compute available height for list so the view never exceeds terminal height.
380+ // This prevents Bubble Tea from truncating the bottom of the UI when there
381+ // are many sessions.
382+ prefixH := lipgloss.Height(prefix.String())
383+ helpH := lipgloss.Height(helpView)
384+ listH := m.height - prefixH - 1 - helpH // one spacer line between list and help
385+ if listH < 1 {
386+ listH = 1
387+ }
388+
389+ listView := m.renderSessionsList(listH)
390+
391+ // Compose final output with a single spacer line above help.
392+ return strings.TrimRight(prefix.String(), "\n") + "\n" + listView + "\n" + helpView
390393 }
391394
392395 func (m SessionsModel) renderHeader() string {
@@ -416,23 +419,85 @@ func (m SessionsModel) renderHeader() string {
416419 return icon + title + stats
417420 }
418421
419-func (m SessionsModel) renderSessionsList() string {
422+func (m SessionsModel) renderSessionsList(maxHeight int) string {
423+ t := m.theme
424+
425+ if maxHeight <= 0 {
426+ return ""
427+ }
428+ if len(m.sessions) == 0 {
429+ return t.Subtle.Render(" No saved sessions found.")
430+ }
431+
432+ // Determine starting index so the selected row stays visible even when
433+ // rows above it are expanded and consume multiple lines.
434+ start := m.selectedIdx
435+ if start < 0 {
436+ start = 0
437+ }
438+ if start >= len(m.sessions) {
439+ start = len(m.sessions) - 1
440+ }
441+ used := m.rowHeight(start)
442+ for start > 0 {
443+ prevHeight := m.rowHeight(start - 1)
444+ if used+prevHeight > maxHeight {
445+ break
446+ }
447+ start--
448+ used += prevHeight
449+ }
450+
420451 var b strings.Builder
452+ remaining := maxHeight
421453
422- for i, info := range m.sessions {
454+ for i := start; i < len(m.sessions) && remaining > 0; i++ {
455+ info := m.sessions[i]
423456 isSelected := i == m.selectedIdx
424457 isExpanded := i == m.expandedIdx
425458
426459 b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
427- b.WriteString("\n")
460+ remaining--
461+ if remaining <= 0 {
462+ break
463+ }
464+
465+ // Render expanded details, but never exceed available height.
466+ if isExpanded && remaining > 0 {
467+ details := m.renderSessionDetails(info)
468+ details = truncateLines(details, remaining)
469+ if details != "" {
470+ b.WriteString("\n")
471+ b.WriteString(details)
472+ remaining -= lipgloss.Height(details)
473+ }
474+ }
428475
429- // Render expanded details
430- if isExpanded && info.State != nil {
431- b.WriteString(m.renderSessionDetails(info))
476+ if remaining > 0 {
477+ b.WriteString("\n")
432478 }
433479 }
434480
435- return b.String()
481+ return strings.TrimRight(b.String(), "\n")
482+}
483+
484+func (m SessionsModel) rowHeight(idx int) int {
485+ if idx < 0 || idx >= len(m.sessions) {
486+ return 0
487+ }
488+
489+ height := 1 // always render one row line
490+ if idx != m.expandedIdx {
491+ return height
492+ }
493+
494+ info := m.sessions[idx]
495+ details := m.renderSessionDetails(info)
496+ if details == "" {
497+ return height
498+ }
499+
500+ return height + lipgloss.Height(details)
436501 }
437502
438503 func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
@@ -509,7 +574,7 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
509574 var b strings.Builder
510575
511576 if info.State == nil {
512- b.WriteString(t.Subtle.Render(" No state data available\n"))
577+ b.WriteString(t.Subtle.Render(" No state data available"))
513578 return b.String()
514579 }
515580
@@ -547,10 +612,20 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
547612 m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
548613 }
549614
550- b.WriteString("\n")
551615 return b.String()
552616 }
553617
618+func truncateLines(s string, maxLines int) string {
619+ if maxLines <= 0 || s == "" {
620+ return ""
621+ }
622+ lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
623+ if len(lines) <= maxLines {
624+ return strings.Join(lines, "\n")
625+ }
626+ return strings.Join(lines[:maxLines], "\n")
627+}
628+
554629 func (m SessionsModel) renderPaneTree(
555630 b *strings.Builder,
556631 ws *entity.WorkspaceSnapshot,
@@ -353,40 +353,43 @@ func (m SessionsModel) View() string {
353 }353 }
354 354
355 t := m.theme355 t := m.theme
356- var b strings.Builder356+ var prefix strings.Builder
357 357
358 // Header358 // Header
359- header := m.renderHeader()359+ prefix.WriteString(m.renderHeader())
360- b.WriteString(header)360+ prefix.WriteString("\n")
361- b.WriteString("\n\n")
362 361
363 // Error display362 // Error display
364 if m.err != nil {363 if m.err != nil {
365- b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))364+ prefix.WriteString("\n")
366- b.WriteString("\n\n")365+ prefix.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366+ prefix.WriteString("\n")
367 }367 }
368 368
369 // Status message369 // Status message
370 if m.statusMessage != "" {370 if m.statusMessage != "" {
371- b.WriteString(t.Subtle.Render(m.statusMessage))371+ prefix.WriteString("\n")
372- b.WriteString("\n\n")372+ prefix.WriteString(t.Subtle.Render(m.statusMessage))
373+ prefix.WriteString("\n")
373 }374 }
374 375
375- // Sessions list
376- if len(m.sessions) == 0 {
377- b.WriteString(t.Subtle.Render(" No saved sessions found."))
378- b.WriteString("\n")
379- } else {
380- b.WriteString(m.renderSessionsList())
381- }
382-
383- b.WriteString("\n")
384-
385 // Help376 // Help
386 helpView := m.help.View(m.keys)377 helpView := m.help.View(m.keys)
387- b.WriteString(helpView)
388 378
389- return b.String()379+ // Compute available height for list so the view never exceeds terminal height.
380+ // This prevents Bubble Tea from truncating the bottom of the UI when there
381+ // are many sessions.
382+ prefixH := lipgloss.Height(prefix.String())
383+ helpH := lipgloss.Height(helpView)
384+ listH := m.height - prefixH - 1 - helpH // one spacer line between list and help
385+ if listH < 1 {
386+ listH = 1
387+ }
388+
389+ listView := m.renderSessionsList(listH)
390+
391+ // Compose final output with a single spacer line above help.
392+ return strings.TrimRight(prefix.String(), "\n") + "\n" + listView + "\n" + helpView
390 }393 }
391 394
392 func (m SessionsModel) renderHeader() string {395 func (m SessionsModel) renderHeader() string {
@@ -416,23 +419,85 @@ func (m SessionsModel) renderHeader() string {
416 return icon + title + stats419 return icon + title + stats
417 }420 }
418 421
419-func (m SessionsModel) renderSessionsList() string {422+func (m SessionsModel) renderSessionsList(maxHeight int) string {
423+ t := m.theme
424+
425+ if maxHeight <= 0 {
426+ return ""
427+ }
428+ if len(m.sessions) == 0 {
429+ return t.Subtle.Render(" No saved sessions found.")
430+ }
431+
432+ // Determine starting index so the selected row stays visible even when
433+ // rows above it are expanded and consume multiple lines.
434+ start := m.selectedIdx
435+ if start < 0 {
436+ start = 0
437+ }
438+ if start >= len(m.sessions) {
439+ start = len(m.sessions) - 1
440+ }
441+ used := m.rowHeight(start)
442+ for start > 0 {
443+ prevHeight := m.rowHeight(start - 1)
444+ if used+prevHeight > maxHeight {
445+ break
446+ }
447+ start--
448+ used += prevHeight
449+ }
450+
420 var b strings.Builder451 var b strings.Builder
452+ remaining := maxHeight
421 453
422- for i, info := range m.sessions {454+ for i := start; i < len(m.sessions) && remaining > 0; i++ {
455+ info := m.sessions[i]
423 isSelected := i == m.selectedIdx456 isSelected := i == m.selectedIdx
424 isExpanded := i == m.expandedIdx457 isExpanded := i == m.expandedIdx
425 458
426 b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))459 b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
427- b.WriteString("\n")460+ remaining--
461+ if remaining <= 0 {
462+ break
463+ }
464+
465+ // Render expanded details, but never exceed available height.
466+ if isExpanded && remaining > 0 {
467+ details := m.renderSessionDetails(info)
468+ details = truncateLines(details, remaining)
469+ if details != "" {
470+ b.WriteString("\n")
471+ b.WriteString(details)
472+ remaining -= lipgloss.Height(details)
473+ }
474+ }
428 475
429- // Render expanded details476+ if remaining > 0 {
430- if isExpanded && info.State != nil {477+ b.WriteString("\n")
431- b.WriteString(m.renderSessionDetails(info))
432 }478 }
433 }479 }
434 480
435- return b.String()481+ return strings.TrimRight(b.String(), "\n")
482+}
483+
484+func (m SessionsModel) rowHeight(idx int) int {
485+ if idx < 0 || idx >= len(m.sessions) {
486+ return 0
487+ }
488+
489+ height := 1 // always render one row line
490+ if idx != m.expandedIdx {
491+ return height
492+ }
493+
494+ info := m.sessions[idx]
495+ details := m.renderSessionDetails(info)
496+ if details == "" {
497+ return height
498+ }
499+
500+ return height + lipgloss.Height(details)
436 }501 }
437 502
438 func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {503 func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
@@ -509,7 +574,7 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
509 var b strings.Builder574 var b strings.Builder
510 575
511 if info.State == nil {576 if info.State == nil {
512- b.WriteString(t.Subtle.Render(" No state data available\n"))577+ b.WriteString(t.Subtle.Render(" No state data available"))
513 return b.String()578 return b.String()
514 }579 }
515 580
@@ -547,10 +612,20 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
547 m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)612 m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
548 }613 }
549 614
550- b.WriteString("\n")
551 return b.String()615 return b.String()
552 }616 }
553 617
618+func truncateLines(s string, maxLines int) string {
619+ if maxLines <= 0 || s == "" {
620+ return ""
621+ }
622+ lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
623+ if len(lines) <= maxLines {
624+ return strings.Join(lines, "\n")
625+ }
626+ return strings.Join(lines[:maxLines], "\n")
627+}
628+
554 func (m SessionsModel) renderPaneTree(629 func (m SessionsModel) renderPaneTree(
555 b *strings.Builder,630 b *strings.Builder,
556 ws *entity.WorkspaceSnapshot,631 ws *entity.WorkspaceSnapshot,
A internal/cli/model/sessions_test.go +51 -0
@@ -0,0 +1,51 @@
1+package model
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/cli/styles"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/bnema/dumber/internal/infrastructure/config"
14+)
15+
16+func TestRenderSessionsList_KeepsSelectedVisibleWhenExpandedRowAbove(t *testing.T) {
17+ theme := styles.NewTheme(config.DefaultConfig())
18+ m := NewSessionsModel(context.Background(), theme, SessionsModelConfig{})
19+
20+ m.sessions = make([]entity.SessionInfo, 0, 6)
21+ for i := 0; i < 6; i++ {
22+ m.sessions = append(m.sessions, testSessionInfo(fmt.Sprintf("session-%02d", i), 1))
23+ }
24+
25+ // Expand a row above the selected row with many detail lines.
26+ m.sessions[2] = testSessionInfo("session-02", 6)
27+ m.selectedIdx = 4
28+ m.expandedIdx = 2
29+
30+ view := m.renderSessionsList(5)
31+ require.Contains(t, view, "session-04", "selected row should remain visible")
32+}
33+
34+func testSessionInfo(id string, tabCount int) entity.SessionInfo {
35+ tabs := make([]entity.TabSnapshot, 0, tabCount)
36+ for i := 0; i < tabCount; i++ {
37+ tabs = append(tabs, entity.TabSnapshot{Name: fmt.Sprintf("Tab %d", i+1)})
38+ }
39+
40+ return entity.SessionInfo{
41+ Session: &entity.Session{
42+ ID: entity.SessionID(id),
43+ Type: entity.SessionTypeBrowser,
44+ StartedAt: time.Now().Add(-time.Hour),
45+ },
46+ State: &entity.SessionState{Tabs: tabs},
47+ TabCount: tabCount,
48+ PaneCount: 0,
49+ UpdatedAt: time.Now(),
50+ }
51+}
@@ -0,0 +1,51 @@
1+package model
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/cli/styles"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/bnema/dumber/internal/infrastructure/config"
14+)
15+
16+func TestRenderSessionsList_KeepsSelectedVisibleWhenExpandedRowAbove(t *testing.T) {
17+ theme := styles.NewTheme(config.DefaultConfig())
18+ m := NewSessionsModel(context.Background(), theme, SessionsModelConfig{})
19+
20+ m.sessions = make([]entity.SessionInfo, 0, 6)
21+ for i := 0; i < 6; i++ {
22+ m.sessions = append(m.sessions, testSessionInfo(fmt.Sprintf("session-%02d", i), 1))
23+ }
24+
25+ // Expand a row above the selected row with many detail lines.
26+ m.sessions[2] = testSessionInfo("session-02", 6)
27+ m.selectedIdx = 4
28+ m.expandedIdx = 2
29+
30+ view := m.renderSessionsList(5)
31+ require.Contains(t, view, "session-04", "selected row should remain visible")
32+}
33+
34+func testSessionInfo(id string, tabCount int) entity.SessionInfo {
35+ tabs := make([]entity.TabSnapshot, 0, tabCount)
36+ for i := 0; i < tabCount; i++ {
37+ tabs = append(tabs, entity.TabSnapshot{Name: fmt.Sprintf("Tab %d", i+1)})
38+ }
39+
40+ return entity.SessionInfo{
41+ Session: &entity.Session{
42+ ID: entity.SessionID(id),
43+ Type: entity.SessionTypeBrowser,
44+ StartedAt: time.Now().Add(-time.Hour),
45+ },
46+ State: &entity.SessionState{Tabs: tabs},
47+ TabCount: tabCount,
48+ PaneCount: 0,
49+ UpdatedAt: time.Now(),
50+ }
51+}
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