fix: improve logs, standardize capitalized (#2047)

Carlos Alexandro Becker and Andrey Nering created

* fix: improve logs, standarize capitalized

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* Update Taskfile.yaml

Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>

* chore: lint

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>

Change summary

AGENTS.md                           |  2 ++
Taskfile.yaml                       |  6 ++++++
internal/agent/agent.go             | 28 ++++++++++++++--------------
internal/agent/coordinator.go       | 12 ++++++------
internal/agent/hyper/provider.go    |  2 +-
internal/agent/tools/mcp/init.go    | 12 ++++++------
internal/agent/tools/mcp/prompts.go |  2 +-
internal/agent/tools/mcp/tools.go   |  2 +-
internal/app/app.go                 | 26 +++++++++++++++-----------
internal/app/lsp.go                 |  2 +-
internal/cmd/run.go                 | 12 +++++++++++-
internal/config/config.go           |  4 ++--
internal/fsext/ls.go                | 12 ++++++------
internal/home/home.go               |  2 +-
internal/lsp/client.go              |  8 ++++----
internal/session/session.go         |  2 +-
internal/ui/image/image.go          |  2 +-
internal/ui/model/history.go        |  2 +-
internal/ui/model/ui.go             |  6 +++---
scripts/check_log_capitalization.sh |  5 +++++
20 files changed, 88 insertions(+), 61 deletions(-)

Detailed changes

AGENTS.md 🔗

@@ -26,6 +26,8 @@
   need of a temporary directory. This directory does not need to be removed.
 - **JSON tags**: Use snake_case for JSON field names
 - **File permissions**: Use octal notation (0o755, 0o644) for file permissions
+- **Log messages**: Log messages must start with a capital letter (e.g., "Failed to save session" not "failed to save session")
+  - This is enforced by `task lint:log` which runs as part of `task lint`
 - **Comments**: End comments in periods unless comments are at the end of the line.
 
 ## Testing with Mock Providers

Taskfile.yaml 🔗

@@ -23,10 +23,16 @@ tasks:
   lint:
     desc: Run base linters
     cmds:
+      - task: lint:log
       - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m
     env:
       GOEXPERIMENT: null
 
+  lint:log:
+    desc: Check that log messages start with capital letters
+    cmds:
+      - ./scripts/check_log_capitalization.sh
+
   lint:fix:
     desc: Run base linters and fix issues
     cmds:

internal/agent/agent.go 🔗

@@ -802,22 +802,22 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 	resp, err := agent.Stream(ctx, streamCall)
 	if err == nil {
 		// We successfully generated a title with the small model.
-		slog.Info("generated title with small model")
+		slog.Debug("Generated title with small model")
 	} else {
 		// It didn't work. Let's try with the big model.
-		slog.Error("error generating title with small model; trying big model", "err", err)
+		slog.Error("Error generating title with small model; trying big model", "err", err)
 		model = largeModel
 		agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
 		resp, err = agent.Stream(ctx, streamCall)
 		if err == nil {
-			slog.Info("generated title with large model")
+			slog.Debug("Generated title with large model")
 		} else {
 			// Welp, the large model didn't work either. Use the default
 			// session name and return.
-			slog.Error("error generating title with large model", "err", err)
+			slog.Error("Error generating title with large model", "err", err)
 			saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
 			if saveErr != nil {
-				slog.Error("failed to save session title and usage", "error", saveErr)
+				slog.Error("Failed to save session title and usage", "error", saveErr)
 			}
 			return
 		}
@@ -826,10 +826,10 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 	if resp == nil {
 		// Actually, we didn't get a response so we can't. Use the default
 		// session name and return.
-		slog.Error("response is nil; can't generate title")
+		slog.Error("Response is nil; can't generate title")
 		saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0)
 		if saveErr != nil {
-			slog.Error("failed to save session title and usage", "error", saveErr)
+			slog.Error("Failed to save session title and usage", "error", saveErr)
 		}
 		return
 	}
@@ -843,7 +843,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 
 	title = strings.TrimSpace(title)
 	if title == "" {
-		slog.Warn("empty title; using fallback")
+		slog.Debug("Empty title; using fallback")
 		title = defaultSessionName
 	}
 
@@ -878,7 +878,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
 	// concurrent session updates.
 	saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
 	if saveErr != nil {
-		slog.Error("failed to save session title and usage", "error", saveErr)
+		slog.Error("Failed to save session title and usage", "error", saveErr)
 		return
 	}
 }
@@ -921,25 +921,25 @@ func (a *sessionAgent) Cancel(sessionID string) {
 	// fully completes (including error handling that may access the DB).
 	// The defer in processRequest will clean up the entry.
 	if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
-		slog.Info("Request cancellation initiated", "session_id", sessionID)
+		slog.Debug("Request cancellation initiated", "session_id", sessionID)
 		cancel()
 	}
 
 	// Also check for summarize requests.
 	if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
-		slog.Info("Summarize cancellation initiated", "session_id", sessionID)
+		slog.Debug("Summarize cancellation initiated", "session_id", sessionID)
 		cancel()
 	}
 
 	if a.QueuedPrompts(sessionID) > 0 {
-		slog.Info("Clearing queued prompts", "session_id", sessionID)
+		slog.Debug("Clearing queued prompts", "session_id", sessionID)
 		a.messageQueue.Del(sessionID)
 	}
 }
 
 func (a *sessionAgent) ClearQueue(sessionID string) {
 	if a.QueuedPrompts(sessionID) > 0 {
-		slog.Info("Clearing queued prompts", "session_id", sessionID)
+		slog.Debug("Clearing queued prompts", "session_id", sessionID)
 		a.messageQueue.Del(sessionID)
 	}
 }
@@ -1099,7 +1099,7 @@ func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Mes
 			if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
 				decoded, err := base64.StdEncoding.DecodeString(media.Data)
 				if err != nil {
-					slog.Warn("failed to decode media data", "error", err)
+					slog.Warn("Failed to decode media data", "error", err)
 					textParts = append(textParts, part)
 					continue
 				}

internal/agent/coordinator.go 🔗

@@ -151,7 +151,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 	mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
 
 	if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() {
-		slog.Info("Token needs to be refreshed", "provider", providerCfg.ID)
+		slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
 		if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
 			return nil, err
 		}
@@ -176,18 +176,18 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 	if c.isUnauthorized(originalErr) {
 		switch {
 		case providerCfg.OAuthToken != nil:
-			slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
+			slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
 			if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
 				return nil, originalErr
 			}
-			slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
+			slog.Debug("Retrying request with refreshed OAuth token", "provider", providerCfg.ID)
 			return run()
 		case strings.Contains(providerCfg.APIKeyTemplate, "$"):
-			slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
+			slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
 			if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil {
 				return nil, originalErr
 			}
-			slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID)
+			slog.Debug("Retrying request with refreshed API key", "provider", providerCfg.ID)
 			return run()
 		}
 	}
@@ -428,7 +428,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 		}
 		if len(agent.AllowedMCP) == 0 {
 			// No MCPs allowed
-			slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
+			slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
 			break
 		}
 

internal/agent/hyper/provider.go 🔗

@@ -49,7 +49,7 @@ var Enabled = sync.OnceValue(func() bool {
 var Embedded = sync.OnceValue(func() catwalk.Provider {
 	var provider catwalk.Provider
 	if err := json.Unmarshal(embedded, &provider); err != nil {
-		slog.Error("could not use embedded provider data", "err", err)
+		slog.Error("Could not use embedded provider data", "err", err)
 	}
 	return provider
 })

internal/agent/tools/mcp/init.go 🔗

@@ -140,7 +140,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 	for name, m := range cfg.MCP {
 		if m.Disabled {
 			updateState(name, StateDisabled, nil, nil, Counts{})
-			slog.Debug("skipping disabled mcp", "name", name)
+			slog.Debug("Skipping disabled MCP", "name", name)
 			continue
 		}
 
@@ -162,7 +162,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 						err = fmt.Errorf("panic: %v", v)
 					}
 					updateState(name, StateError, err, nil, Counts{})
-					slog.Error("panic in mcp client initialization", "error", err, "name", name)
+					slog.Error("Panic in MCP client initialization", "error", err, "name", name)
 				}
 			}()
 
@@ -174,7 +174,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 
 			tools, err := getTools(ctx, session)
 			if err != nil {
-				slog.Error("error listing tools", "error", err)
+				slog.Error("Error listing tools", "error", err)
 				updateState(name, StateError, err, nil, Counts{})
 				session.Close()
 				return
@@ -182,7 +182,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config
 
 			prompts, err := getPrompts(ctx, session)
 			if err != nil {
-				slog.Error("error listing prompts", "error", err)
+				slog.Error("Error listing prompts", "error", err)
 				updateState(name, StateError, err, nil, Counts{})
 				session.Close()
 				return
@@ -277,7 +277,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 	transport, err := createTransport(mcpCtx, m, resolver)
 	if err != nil {
 		updateState(name, StateError, err, nil, Counts{})
-		slog.Error("error creating mcp client", "error", err, "name", name)
+		slog.Error("Error creating MCP client", "error", err, "name", name)
 		cancel()
 		cancelTimer.Stop()
 		return nil, err
@@ -319,7 +319,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve
 	}
 
 	cancelTimer.Stop()
-	slog.Info("MCP client initialized", "name", name)
+	slog.Debug("MCP client initialized", "name", name)
 	return session, nil
 }
 

internal/agent/tools/mcp/prompts.go 🔗

@@ -49,7 +49,7 @@ func GetPromptMessages(ctx context.Context, clientName, promptName string, args
 func RefreshPrompts(ctx context.Context, name string) {
 	session, ok := sessions.Get(name)
 	if !ok {
-		slog.Warn("refresh prompts: no session", "name", name)
+		slog.Warn("Refresh prompts: no session", "name", name)
 		return
 	}
 

internal/agent/tools/mcp/tools.go 🔗

@@ -111,7 +111,7 @@ func RunTool(ctx context.Context, name, toolName string, input string) (ToolResu
 func RefreshTools(ctx context.Context, name string) {
 	session, ok := sessions.Get(name)
 	if !ok {
-		slog.Warn("refresh tools: no session", "name", name)
+		slog.Warn("Refresh tools: no session", "name", name)
 		return
 	}
 

internal/app/app.go 🔗

@@ -135,7 +135,7 @@ func (app *App) Config() *config.Config {
 
 // RunNonInteractive runs the application in non-interactive mode with the
 // given prompt, printing to stdout.
-func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error {
+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
 	slog.Info("Running in non-interactive mode")
 
 	ctx, cancel := context.WithCancel(ctx)
@@ -160,10 +160,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 	}
 	stderrTTY = term.IsTerminal(os.Stderr.Fd())
 	stdinTTY = term.IsTerminal(os.Stdin.Fd())
-
 	progress = app.config.Options.Progress == nil || *app.config.Options.Progress
 
-	if !quiet && stderrTTY {
+	if !hideSpinner && stderrTTY {
 		t := styles.CurrentTheme()
 
 		// Detect background color to set the appropriate color for the
@@ -188,7 +187,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 
 	// Helper function to stop spinner once.
 	stopSpinner := func() {
-		if !quiet && spinner != nil {
+		if !hideSpinner && spinner != nil {
 			spinner.Stop()
 			spinner = nil
 		}
@@ -245,6 +244,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 
 	messageEvents := app.Messages.Subscribe(ctx)
 	messageReadBytes := make(map[string]int)
+	var printed bool
 
 	defer func() {
 		if progress && stderrTTY {
@@ -268,7 +268,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 			stopSpinner()
 			if result.err != nil {
 				if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) {
-					slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
+					slog.Debug("Non-interactive: agent processing cancelled", "session_id", sess.ID)
 					return nil
 				}
 				return fmt.Errorf("agent processing failed: %w", result.err)
@@ -294,7 +294,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
 				if readBytes == 0 {
 					part = strings.TrimLeft(part, " \t")
 				}
-				fmt.Fprint(output, part)
+				// Ignore initial whitespace-only messages.
+				if printed || strings.TrimSpace(part) != "" {
+					printed = true
+					fmt.Fprint(output, part)
+				}
 				messageReadBytes[msg.ID] = len(content)
 			}
 
@@ -433,20 +437,20 @@ func setupSubscriber[T any](
 			select {
 			case event, ok := <-subCh:
 				if !ok {
-					slog.Debug("subscription channel closed", "name", name)
+					slog.Debug("Subscription channel closed", "name", name)
 					return
 				}
 				var msg tea.Msg = event
 				select {
 				case outputCh <- msg:
 				case <-time.After(2 * time.Second):
-					slog.Warn("message dropped due to slow consumer", "name", name)
+					slog.Debug("Message dropped due to slow consumer", "name", name)
 				case <-ctx.Done():
-					slog.Debug("subscription cancelled", "name", name)
+					slog.Debug("Subscription cancelled", "name", name)
 					return
 				}
 			case <-ctx.Done():
-				slog.Debug("subscription cancelled", "name", name)
+				slog.Debug("Subscription cancelled", "name", name)
 				return
 			}
 		}
@@ -511,7 +515,7 @@ func (app *App) Subscribe(program *tea.Program) {
 // Shutdown performs a graceful shutdown of the application.
 func (app *App) Shutdown() {
 	start := time.Now()
-	defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }()
+	defer func() { slog.Debug("Shutdown took " + time.Since(start).String()) }()
 
 	// First, cancel all agents and wait for them to finish. This must complete
 	// before closing the DB so agents can finish writing their state.

internal/app/lsp.go 🔗

@@ -140,7 +140,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config
 		updateLSPState(name, lsp.StateReady, nil, lspClient, 0)
 	}
 
-	slog.Info("LSP client initialized", "name", name)
+	slog.Debug("LSP client initialized", "name", name)
 
 	// Add to map with mutex protection before starting goroutine
 	app.LSPClients.Set(name, lspClient)

internal/cmd/run.go 🔗

@@ -8,6 +8,7 @@ import (
 	"os/signal"
 	"strings"
 
+	"charm.land/log/v2"
 	"github.com/charmbracelet/crush/internal/event"
 	"github.com/spf13/cobra"
 )
@@ -29,9 +30,13 @@ crush run "What is this code doing?" <<< prrr.go
 
 # Run in quiet mode (hide the spinner)
 crush run --quiet "Generate a README for this project"
+
+# Run in verbose mode
+crush run --verbose "Generate a README for this project"
   `,
 	RunE: func(cmd *cobra.Command, args []string) error {
 		quiet, _ := cmd.Flags().GetBool("quiet")
+		verbose, _ := cmd.Flags().GetBool("verbose")
 		largeModel, _ := cmd.Flags().GetString("model")
 		smallModel, _ := cmd.Flags().GetString("small-model")
 
@@ -49,6 +54,10 @@ crush run --quiet "Generate a README for this project"
 			return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
 		}
 
+		if verbose {
+			slog.SetDefault(slog.New(log.New(os.Stderr)))
+		}
+
 		prompt := strings.Join(args, " ")
 
 		prompt, err = MaybePrependStdin(prompt)
@@ -64,7 +73,7 @@ crush run --quiet "Generate a README for this project"
 		event.SetNonInteractive(true)
 		event.AppInitialized()
 
-		return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet)
+		return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose)
 	},
 	PostRun: func(cmd *cobra.Command, args []string) {
 		event.AppExited()
@@ -73,6 +82,7 @@ crush run --quiet "Generate a README for this project"
 
 func init() {
 	runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
+	runCmd.Flags().BoolP("verbose", "v", false, "Show logs")
 	runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
 	runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
 }

internal/config/config.go 🔗

@@ -317,7 +317,7 @@ func (m MCPConfig) ResolvedHeaders() map[string]string {
 		var err error
 		m.Headers[e], err = resolver.ResolveValue(v)
 		if err != nil {
-			slog.Error("error resolving header variable", "error", err, "variable", e, "value", v)
+			slog.Error("Error resolving header variable", "error", err, "variable", e, "value", v)
 			continue
 		}
 	}
@@ -840,7 +840,7 @@ func resolveEnvs(envs map[string]string) []string {
 		var err error
 		envs[e], err = resolver.ResolveValue(v)
 		if err != nil {
-			slog.Error("error resolving environment variable", "error", err, "variable", e, "value", v)
+			slog.Error("Error resolving environment variable", "error", err, "variable", e, "value", v)
 			continue
 		}
 	}

internal/fsext/ls.go 🔗

@@ -144,20 +144,20 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
 	}
 
 	if commonIgnorePatterns().MatchesPath(relPath) {
-		slog.Debug("ignoring common pattern", "path", relPath)
+		slog.Debug("Ignoring common pattern", "path", relPath)
 		return true
 	}
 
 	parentDir := filepath.Dir(path)
 	ignoreParser := dl.getIgnore(parentDir)
 	if ignoreParser.MatchesPath(relPath) {
-		slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir)
+		slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir)
 		return true
 	}
 
 	// For directories, also check with trailing slash (gitignore convention)
 	if ignoreParser.MatchesPath(relPath + "/") {
-		slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
+		slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir)
 		return true
 	}
 
@@ -166,7 +166,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo
 	}
 
 	if homeIgnore().MatchesPath(relPath) {
-		slog.Debug("ignoring home dir pattern", "path", relPath)
+		slog.Debug("Ignoring home dir pattern", "path", relPath)
 		return true
 	}
 
@@ -177,7 +177,7 @@ func (dl *directoryLister) checkParentIgnores(path string) bool {
 	parent := filepath.Dir(filepath.Dir(path))
 	for parent != "." && path != "." {
 		if dl.getIgnore(parent).MatchesPath(path) {
-			slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
+			slog.Debug("Ignoring parent dir pattern", "path", path, "dir", parent)
 			return true
 		}
 		if parent == dl.rootPath {
@@ -210,7 +210,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int
 	found := csync.NewSlice[string]()
 	dl := NewDirectoryLister(initialPath)
 
-	slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
+	slog.Debug("Listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns)
 
 	conf := fastwalk.Config{
 		Follow:   true,

internal/home/home.go 🔗

@@ -12,7 +12,7 @@ var homedir, homedirErr = os.UserHomeDir()
 
 func init() {
 	if homedirErr != nil {
-		slog.Error("failed to get user home directory", "error", homedirErr)
+		slog.Error("Failed to get user home directory", "error", homedirErr)
 	}
 }
 

internal/lsp/client.go 🔗

@@ -317,12 +317,12 @@ func (c *Client) HandlesFile(path string) bool {
 	// Check if file is within working directory.
 	absPath, err := filepath.Abs(path)
 	if err != nil {
-		slog.Debug("cannot resolve path", "name", c.name, "file", path, "error", err)
+		slog.Debug("Cannot resolve path", "name", c.name, "file", path, "error", err)
 		return false
 	}
 	relPath, err := filepath.Rel(c.workDir, absPath)
 	if err != nil || strings.HasPrefix(relPath, "..") {
-		slog.Debug("file outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
+		slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.workDir)
 		return false
 	}
 
@@ -339,11 +339,11 @@ func (c *Client) HandlesFile(path string) bool {
 			suffix = "." + suffix
 		}
 		if strings.HasSuffix(name, suffix) || filetype == string(kind) {
-			slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
+			slog.Debug("Handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind)
 			return true
 		}
 	}
-	slog.Debug("doesn't handle file", "name", c.name, "file", name)
+	slog.Debug("Doesn't handle file", "name", c.name, "file", name)
 	return false
 }
 

internal/session/session.go 🔗

@@ -203,7 +203,7 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
 func (s service) fromDBItem(item db.Session) Session {
 	todos, err := unmarshalTodos(item.Todos.String)
 	if err != nil {
-		slog.Error("failed to unmarshal todos", "session_id", item.ID, "error", err)
+		slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err)
 	}
 	return Session{
 		ID:               item.ID,

internal/ui/image/image.go 🔗

@@ -168,7 +168,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
 				return chunk
 			},
 		}); err != nil {
-			slog.Error("failed to encode image for kitty graphics", "err", err)
+			slog.Error("Failed to encode image for kitty graphics", "err", err)
 			return uiutil.InfoMsg{
 				Type: uiutil.InfoTypeError,
 				Msg:  "failed to encode image",

internal/ui/model/history.go 🔗

@@ -27,7 +27,7 @@ func (m *UI) loadPromptHistory() tea.Cmd {
 			messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
 		}
 		if err != nil {
-			slog.Error("failed to load prompt history", "error", err)
+			slog.Error("Failed to load prompt history", "error", err)
 			return promptHistoryLoadedMsg{messages: nil}
 		}
 

internal/ui/model/ui.go 🔗

@@ -330,7 +330,7 @@ func (m *UI) loadCustomCommands() tea.Cmd {
 	return func() tea.Msg {
 		customCommands, err := commands.LoadCustomCommands(m.com.Config())
 		if err != nil {
-			slog.Error("failed to load custom commands", "error", err)
+			slog.Error("Failed to load custom commands", "error", err)
 		}
 		return userCommandsLoadedMsg{Commands: customCommands}
 	}
@@ -341,7 +341,7 @@ func (m *UI) loadMCPrompts() tea.Cmd {
 	return func() tea.Msg {
 		prompts, err := commands.LoadMCPPrompts()
 		if err != nil {
-			slog.Error("failed to load mcp prompts", "error", err)
+			slog.Error("Failed to load MCP prompts", "error", err)
 		}
 		if prompts == nil {
 			// flag them as loaded even if there is none or an error
@@ -683,7 +683,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case uv.KittyGraphicsEvent:
 		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
-			slog.Warn("unexpected Kitty graphics response",
+			slog.Warn("Unexpected Kitty graphics response",
 				"response", string(msg.Payload),
 				"options", msg.Options)
 		}

scripts/check_log_capitalization.sh 🔗

@@ -0,0 +1,5 @@
+#!/bin/bash
+if grep -rE 'slog\.(Error|Info|Warn|Debug|Fatal|Print|Println|Printf)\(["\"][a-z]' --include="*.go" . 2>/dev/null; then
+  echo "❌ Log messages must start with a capital letter. Found lowercase logs above."
+  exit 1
+fi