From 1e328be6cba6321f5034b7c5be0db571ed8f856f Mon Sep 17 00:00:00 2001 From: Thomas Glanzmann Date: Wed, 26 Aug 2026 07:31:46 +0200 Subject: [PATCH 07/10] feat: remove quit banner and thank-you message Quitting Crush no longer prints the logo banner, random exit message, or session resume hint, so exits are completely silent. --- internal/agent/tools/crush_info.go | 1 - internal/agent/tools/crush_info_test.go | 2 - internal/cmd/root.go | 18 --- internal/config/config.go | 16 --- internal/config/load.go | 3 - internal/config/load_test.go | 4 - internal/shellconfig/options.go | 5 - internal/shellconfig/options_test.go | 18 --- internal/ui/exitbanner/exitbanner.go | 117 ----------------- internal/ui/exitbanner/exitbanner_test.go | 149 ---------------------- schema.json | 10 -- 11 files changed, 343 deletions(-) delete mode 100644 internal/ui/exitbanner/exitbanner.go delete mode 100644 internal/ui/exitbanner/exitbanner_test.go diff --git a/internal/agent/tools/crush_info.go b/internal/agent/tools/crush_info.go index a98dc026..76c7b8f2 100644 --- a/internal/agent/tools/crush_info.go +++ b/internal/agent/tools/crush_info.go @@ -406,7 +406,6 @@ func writeOptions(b *strings.Builder, cfg *config.ConfigStore) { // rather than an empty value. opts = append(opts, kv{"diff_mode", cmp.Or(c.Options.TUI.DiffMode, "auto")}) opts = append(opts, kv{"scrollbar", c.Options.TUI.Scrollbar}) - opts = append(opts, kv{"exit_banner", string(c.Options.TUI.ExitBanner)}) opts = append(opts, kv{"transparent", fmt.Sprintf("%v", c.Options.TUI.IsTransparent())}) // Report the completion limits only once the user has pinned them; // see Completions.Limits for what zero means. diff --git a/internal/agent/tools/crush_info_test.go b/internal/agent/tools/crush_info_test.go index 128957da..6c309c38 100644 --- a/internal/agent/tools/crush_info_test.go +++ b/internal/agent/tools/crush_info_test.go @@ -222,7 +222,6 @@ func TestCrushInfo_TUIOptions(t *testing.T) { CompactMode: true, DiffMode: config.DiffModeSplit, Scrollbar: config.ScrollbarNever, - ExitBanner: config.ExitBannerCompact, Transparent: &transparent, Completions: config.Completions{MaxDepth: &depth, MaxItems: &items}, }, @@ -233,7 +232,6 @@ func TestCrushInfo_TUIOptions(t *testing.T) { require.Contains(t, output, "compact_mode = true") require.Contains(t, output, "diff_mode = split") require.Contains(t, output, "scrollbar = never") - require.Contains(t, output, "exit_banner = compact") require.Contains(t, output, "transparent = true") require.Contains(t, output, "completions_max_depth = 3") require.Contains(t, output, "completions_max_items = 42") diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 25e5c7c5..a4d8bee1 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -37,7 +37,6 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/skills" "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/ui/exitbanner" ui "github.com/charmbracelet/crush/internal/ui/model" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/crush/internal/workspace" @@ -145,11 +144,6 @@ crush --continue slog.Error("TUI run error", "error", err) return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") //nolint:staticcheck } - var banner config.ExitBanner - if cfg := com.Config(); cfg != nil { - banner = cfg.Options.TUI.ExitBanner - } - printSessionResume(model, banner) return nil }, } @@ -168,18 +162,6 @@ var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(` ▀▀▀▀▀▀ `) -// printSessionResume prints the exit banner after the TUI exits, including the -// session title and the hint to resume it with `crush -s `. The banner -// style decides how much of that is shown; see config.ExitBanner. -func printSessionResume(model *ui.UI, banner config.ExitBanner) { - tw, _, _ := term.GetSize(os.Stdout.Fd()) - body := exitbanner.Render(banner, model.CurrentSession(), tw) - if body == "" { - return - } - fmt.Fprintln(colorprofile.NewWriter(os.Stderr, os.Environ()), body) -} - // copied from cobra: const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}} ` diff --git a/internal/config/config.go b/internal/config/config.go index 61b67b2e..f8505536 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -342,7 +342,6 @@ type TUIOptions struct { Transparent *bool `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"` Scrollbar string `json:"scrollbar,omitempty" jsonschema:"description=Chat scrollbar visibility,enum=default,enum=always,enum=never,default=default"` Mouse *bool `json:"mouse,omitempty" jsonschema:"description=Enable terminal mouse capture for selection\\, clicks\\, and scrolling in the TUI. Disable to let the terminal emulator or tmux handle text selection and copy/paste,default=true"` - ExitBanner ExitBanner `json:"exit_banner,omitempty" jsonschema:"description=Exit banner style after quitting Crush,enum=default,enum=compact,enum=none,default=default"` } // IsTransparent reports whether the TUI draws a transparent background. The @@ -408,21 +407,6 @@ const ( ScrollbarNever = "never" // Never show scrollbar ) -// ExitBanner selects what Crush prints after the TUI exits. -type ExitBanner string - -const ( - // ExitBannerDefault renders the full ASCII art logo with padding. It is - // also what the zero value and any unrecognized value fall back to. - ExitBannerDefault ExitBanner = "default" - // ExitBannerCompact renders only the session and resume lines, with no - // logo and no padding. With no active session it renders nothing at all, - // so Crush exits silently. - ExitBannerCompact ExitBanner = "compact" - // ExitBannerNone renders nothing. - ExitBannerNone ExitBanner = "none" -) - type Permissions struct { AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"` } diff --git a/internal/config/load.go b/internal/config/load.go index cf892c58..3b5dad2e 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -565,9 +565,6 @@ func (c *Config) NormalizeOptions() { if c.Options.TUI.Scrollbar == "" { c.Options.TUI.Scrollbar = ScrollbarDefault } - if c.Options.TUI.ExitBanner == "" { - c.Options.TUI.ExitBanner = ExitBannerDefault - } } func (c *Config) setDefaults(workingDir, dataDir string) { diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 123d2d5c..b6c85076 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -269,7 +269,6 @@ func TestConfig_setDefaults(t *testing.T) { // the zero value as "pick split or unified based on terminal width". require.Empty(t, cfg.Options.TUI.DiffMode) require.Equal(t, ScrollbarDefault, cfg.Options.TUI.Scrollbar) - require.Equal(t, ExitBannerDefault, cfg.Options.TUI.ExitBanner) for _, path := range defaultContextPaths { require.Contains(t, cfg.Options.ContextPaths, path) } @@ -283,16 +282,13 @@ func TestConfig_setDefaults(t *testing.T) { require.Empty(t, cfg.Options.TUI.DiffMode) require.Equal(t, ScrollbarDefault, cfg.Options.TUI.Scrollbar) - require.Equal(t, ExitBannerDefault, cfg.Options.TUI.ExitBanner) cfg.Options.TUI.DiffMode = DiffModeSplit cfg.Options.TUI.Scrollbar = ScrollbarNever - cfg.Options.TUI.ExitBanner = ExitBannerCompact cfg.setDefaults(workingDir, "") require.Equal(t, DiffModeSplit, cfg.Options.TUI.DiffMode) require.Equal(t, ScrollbarNever, cfg.Options.TUI.Scrollbar) - require.Equal(t, ExitBannerCompact, cfg.Options.TUI.ExitBanner) }) t.Run("prunes orphaned OAuth token MCP entries but keeps real ones", func(t *testing.T) { diff --git a/internal/shellconfig/options.go b/internal/shellconfig/options.go index 74cddd1b..e0358684 100644 --- a/internal/shellconfig/options.go +++ b/internal/shellconfig/options.go @@ -252,11 +252,6 @@ func optionUI(options map[string]any, args []string, stderr io.Writer) error { return usage(stderr, fmt.Sprintf("option ui scrollbar expects default, always, or never, got %q", value)) } ui["scrollbar"] = value - case "exit-banner": - if value != "default" && value != "compact" && value != "none" { - return usage(stderr, fmt.Sprintf("option ui exit-banner expects default, compact, or none, got %q", value)) - } - ui["exit_banner"] = value case "completions-max-depth", "completions-max-items": parsed, err := strconv.Atoi(value) if err != nil || parsed < 0 { diff --git a/internal/shellconfig/options_test.go b/internal/shellconfig/options_test.go index 3a56f5ec..25cb2fe7 100644 --- a/internal/shellconfig/options_test.go +++ b/internal/shellconfig/options_test.go @@ -162,24 +162,6 @@ func TestOption_UIUnknownKey(t *testing.T) { require.Contains(t, err.Error(), "unknown key") } -func TestOption_UIExitBanner(t *testing.T) { - t.Parallel() - - path := filepath.Join(t.TempDir(), "crushrc") - jsonBytes, err := LoadShellConfig(t.Context(), path, []byte(`option ui exit-banner compact`)) - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(jsonBytes, &result)) - - ui := result["options"].(map[string]any)["tui"].(map[string]any) - require.Equal(t, "compact", ui["exit_banner"]) - - _, err = LoadShellConfig(t.Context(), path, []byte(`option ui exit-banner bogus`)) - require.Error(t, err) - require.Contains(t, err.Error(), "expects default, compact, or none") -} - func TestOption_BoolShorthand(t *testing.T) { t.Parallel() diff --git a/internal/ui/exitbanner/exitbanner.go b/internal/ui/exitbanner/exitbanner.go deleted file mode 100644 index 59e25db2..00000000 --- a/internal/ui/exitbanner/exitbanner.go +++ /dev/null @@ -1,117 +0,0 @@ -// Package exitbanner renders what Crush prints after the TUI exits. -package exitbanner - -import ( - "math/rand/v2" - "os" - "strings" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/ui/logo" - "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/version" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/exp/charmtone" -) - -// FallbackWidth is used when stdout is not a terminal, so the banner still -// wraps to something sane when output is piped or captured. -const FallbackWidth = 80 - -// Render returns the exit banner for the given style, or an empty string when -// there is nothing to print. A nil or untitled session means the resume hint is -// omitted, which for the compact banner leaves nothing at all. -func Render(banner config.ExitBanner, sess *session.Session, width int) string { - if width <= 0 { - width = FallbackWidth - } - hasSession := sess != nil && sess.ID != "" - - switch banner { - case config.ExitBannerNone: - return "" - - case config.ExitBannerCompact: - if !hasSession { - return "" - } - return sessionResumeLines(sess, width) - - default: - // Unrecognized values render the full banner rather than nothing. - style := lipgloss.NewStyle().Padding(1, 3) - contentWidth := width - style.GetHorizontalFrameSize() - - sections := []string{logoSection(contentWidth)} - if hasSession { - sections = append(sections, sessionResumeLines(sess, contentWidth)) - } - return style.Render(strings.Join(sections, "\n\n")) - } -} - -// logoSection returns the ASCII art logo followed by the parting message. -func logoSection(contentWidth int) string { - hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) - t := styles.ThemeForProvider("", hasDarkBG) - crushLogo := logo.Render(t.Logo.GradCanvas, version.Version, true, logo.Opts{ - FieldColor: t.Logo.FieldColor, - TitleColorA: t.Logo.TitleColorA, - TitleColorB: t.Logo.TitleColorB, - CharmColor: t.Logo.CharmColor, - VersionColor: t.Logo.VersionColor, - Hyper: false, - }) - // Wrap the greeting and the message together: wrapping only the message - // leaves the greeting's own width unaccounted for and overflows the frame. - return crushLogo + "\n" + - lipgloss.NewStyle().Width(contentWidth).Render("Thanks for using Crush! "+randomExitMessage()) -} - -// sessionResumeLines returns the "Session \nContinue crush -s <hash>" -// pair used by the exit banner. -func sessionResumeLines(sess *session.Session, contentWidth int) string { - title := strings.ReplaceAll(sess.Title, "\n", " ") - - labelWidth := lipgloss.Width("Session ") - titleWidth := contentWidth - labelWidth - if titleWidth > 0 { - title = ansi.Truncate(title, titleWidth, "…") - } - - hash := session.HashID(sess.ID)[:7] - label := lipgloss.NewStyle().Foreground(charmtone.Charple) - sessionLine := label.Render("Session ") + title - continueLine := label.Render("Continue ") + "crush -s " + hash - return sessionLine + "\n" + continueLine -} - -// randomExitMessage returns a random exit message. -func randomExitMessage() string { - messages := []string{ - "", - "See ya later.", - "You look great.", - "Have a gorgeous time.", - "Get some rest.", - "Come back soon.", - "You worked handsomely.", - "Time for a snack.", - "Who’s hungry?", - "That was fun.", - "See you at breakfast?", - "Time for a nap.", - "Who wants some spaghetti?", - "Take care of yourself.", - "Remember to hydrate.", - "Time for a swim?", - "You’re quite glamorous, you know.", - "Nice work.", - "You’re a sensation.", - "Where’s my eyeliner?", - "It’s tea time.", - } - return messages[rand.IntN(len(messages))] -} diff --git a/internal/ui/exitbanner/exitbanner_test.go b/internal/ui/exitbanner/exitbanner_test.go deleted file mode 100644 index 537679b6..00000000 --- a/internal/ui/exitbanner/exitbanner_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package exitbanner - -import ( - "strings" - "testing" - - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/x/ansi" - "github.com/stretchr/testify/require" -) - -func testSession() *session.Session { - return &session.Session{ID: "abc123def456", Title: "Fix the flaky test"} -} - -func TestRender(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - banner config.ExitBanner - sess *session.Session - want []string - notWant []string - empty bool - }{ - { - name: "none prints nothing even with a session", - banner: config.ExitBannerNone, - sess: testSession(), - empty: true, - }, - { - name: "compact without a session prints nothing", - banner: config.ExitBannerCompact, - empty: true, - }, - { - name: "compact with a session prints only the resume lines", - banner: config.ExitBannerCompact, - sess: testSession(), - want: []string{"Session", "Fix the flaky test", "Continue", "crush -s "}, - // No logo, and no padding around the two lines. - notWant: []string{"Thanks for using Crush!"}, - }, - { - name: "default with a session prints the logo and the resume lines", - banner: config.ExitBannerDefault, - sess: testSession(), - want: []string{"Thanks for using Crush!", "Session", "Fix the flaky test", "Continue"}, - }, - { - name: "default without a session still prints the logo", - banner: config.ExitBannerDefault, - want: []string{"Thanks for using Crush!"}, - // The separator between logo and resume lines must not be left - // dangling when there is no session. - notWant: []string{"Continue"}, - }, - { - name: "unrecognized values fall back to the full banner", - banner: config.ExitBanner("wat"), - sess: testSession(), - want: []string{"Thanks for using Crush!", "Continue"}, - }, - { - name: "zero value falls back to the full banner", - sess: testSession(), - want: []string{"Thanks for using Crush!", "Continue"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := Render(tt.banner, tt.sess, 100) - if tt.empty { - require.Empty(t, got) - return - } - require.NotEmpty(t, got) - for _, want := range tt.want { - require.Contains(t, got, want) - } - for _, notWant := range tt.notWant { - require.NotContains(t, got, notWant) - } - }) - } -} - -func TestRenderCompactIsExactlyTwoLines(t *testing.T) { - t.Parallel() - - got := Render(config.ExitBannerCompact, testSession(), 100) - require.Len(t, strings.Split(got, "\n"), 2) -} - -func TestRenderTruncatesLongTitles(t *testing.T) { - t.Parallel() - - sess := testSession() - sess.Title = strings.Repeat("long ", 60) - - got := Render(config.ExitBannerCompact, sess, 40) - for _, line := range strings.Split(got, "\n") { - require.LessOrEqual(t, ansi.StringWidth(line), 40, "line wider than the terminal: %q", line) - } - require.Contains(t, got, "…") -} - -func TestRenderFlattensNewlinesInTitles(t *testing.T) { - t.Parallel() - - sess := testSession() - sess.Title = "first\nsecond" - - got := Render(config.ExitBannerCompact, sess, 100) - require.Len(t, strings.Split(got, "\n"), 2) - require.Contains(t, got, "first second") -} - -func TestRenderFitsTheGivenWidth(t *testing.T) { - t.Parallel() - - for _, width := range []int{40, 80, 200} { - for _, banner := range []config.ExitBanner{config.ExitBannerCompact, config.ExitBannerDefault} { - got := Render(banner, testSession(), width) - for _, line := range strings.Split(got, "\n") { - require.LessOrEqual(t, ansi.StringWidth(line), width, - "%s banner overflows width %d: %q", banner, width, line) - } - } - } -} - -func TestRenderNonPositiveWidthUsesFallback(t *testing.T) { - t.Parallel() - - sess := testSession() - sess.Title = strings.Repeat("long ", 60) - - got := Render(config.ExitBannerCompact, sess, 0) - for _, line := range strings.Split(got, "\n") { - require.LessOrEqual(t, ansi.StringWidth(line), FallbackWidth) - } -} diff --git a/schema.json b/schema.json index 85ba477f..cd9c7b49 100644 --- a/schema.json +++ b/schema.json @@ -905,16 +905,6 @@ "type": "boolean", "description": "Enable terminal mouse capture for selection, clicks, and scrolling in the TUI. Disable to let the terminal emulator or tmux handle text selection and copy/paste", "default": true - }, - "exit_banner": { - "type": "string", - "enum": [ - "default", - "compact", - "none" - ], - "description": "Exit banner style after quitting Crush", - "default": "default" } }, "additionalProperties": false, -- 2.53.0