Merge branch 'main' into not-needed-keeb

Kujtim Hoxha created

Change summary

CRUSH.md                                                                          |   2 
cmd/logs.go                                                                       | 212 
cmd/root.go                                                                       |   3 
internal/app/app.go                                                               |   6 
internal/config/load.go                                                           |   1 
internal/llm/agent/agent.go                                                       |  35 
internal/message/content.go                                                       |  10 
internal/tui/components/chat/chat.go                                              |   9 
internal/tui/components/chat/editor/editor.go                                     |   3 
internal/tui/components/chat/header/header.go                                     |   6 
internal/tui/components/chat/messages/messages.go                                 |   8 
internal/tui/components/chat/sidebar/sidebar.go                                   | 438 
internal/tui/components/chat/splash/splash.go                                     |   6 
internal/tui/components/core/status_test.go                                       | 147 
internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden |   1 
internal/tui/components/core/testdata/TestStatus/Default.golden                   |   1 
internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden          |   1 
internal/tui/components/core/testdata/TestStatus/LongDescription.golden           |   1 
internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden               |   1 
internal/tui/components/core/testdata/TestStatus/NoIcon.golden                    |   1 
internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden           |   1 
internal/tui/components/core/testdata/TestStatus/WithColors.golden                |   1 
internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden            |   1 
internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden          |   1 
internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden         |   1 
internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden         |   1 
internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden         |   1 
internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden         |   1 
internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden         |   1 
internal/tui/components/dialogs/permissions/permissions.go                        |  19 
internal/tui/page/chat/chat.go                                                    |  24 
internal/tui/tui.go                                                               |   8 
32 files changed, 838 insertions(+), 114 deletions(-)

Detailed changes

CRUSH.md πŸ”—

@@ -4,7 +4,7 @@
 
 - **Build**: `go build .` or `go run .`
 - **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
-- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
+- **Lint**: `task lint-fix`
 - **Format**: `task fmt` (gofumpt -w .)
 - **Dev**: `task dev` (runs with profiling enabled)
 

cmd/logs.go πŸ”—

@@ -1,11 +1,14 @@
 package cmd
 
 import (
+	"bufio"
 	"encoding/json"
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 	"slices"
+	"strings"
 	"time"
 
 	"github.com/charmbracelet/crush/internal/config"
@@ -14,20 +17,30 @@ import (
 	"github.com/spf13/cobra"
 )
 
-func init() {
-	rootCmd.AddCommand(logsCmd)
-}
-
 var logsCmd = &cobra.Command{
 	Use:   "logs",
 	Short: "View crush logs",
-	Long:  `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring purposes.`,
+	Long:  `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
 	RunE: func(cmd *cobra.Command, args []string) error {
 		cwd, err := cmd.Flags().GetString("cwd")
 		if err != nil {
 			return fmt.Errorf("failed to get current working directory: %v", err)
 		}
+
+		follow, err := cmd.Flags().GetBool("follow")
+		if err != nil {
+			return fmt.Errorf("failed to get follow flag: %v", err)
+		}
+
+		tailLines, err := cmd.Flags().GetInt("tail")
+		if err != nil {
+			return fmt.Errorf("failed to get tail flag: %v", err)
+		}
+
 		log.SetLevel(log.DebugLevel)
+		// Configure log to output to stdout instead of stderr
+		log.SetOutput(os.Stdout)
+
 		cfg, err := config.Load(cwd, false)
 		if err != nil {
 			return fmt.Errorf("failed to load configuration: %v", err)
@@ -38,62 +51,155 @@ var logsCmd = &cobra.Command{
 			log.Warn("Looks like you are not in a crush project. No logs found.")
 			return nil
 		}
-		t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
-		if err != nil {
-			return fmt.Errorf("failed to tail log file: %v", err)
-		}
 
-		// Print the text of each received line
-		for line := range t.Lines {
-			var data map[string]any
-			if err := json.Unmarshal([]byte(line.Text), &data); err != nil {
-				continue
+		if follow {
+			// Follow mode - tail the file continuously
+			t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
+			if err != nil {
+				return fmt.Errorf("failed to tail log file: %v", err)
 			}
-			msg := data["msg"]
-			level := data["level"]
-			otherData := []any{}
-			keys := []string{}
-			for k := range data {
-				keys = append(keys, k)
-			}
-			slices.Sort(keys)
-			for _, k := range keys {
-				switch k {
-				case "msg", "level", "time":
-					continue
-				case "source":
-					source, ok := data[k].(map[string]any)
-					if !ok {
-						continue
-					}
-					sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
-					otherData = append(otherData, "source", sourceFile)
 
-				default:
-					otherData = append(otherData, k, data[k])
-				}
+			// Print the text of each received line
+			for line := range t.Lines {
+				printLogLine(line.Text)
 			}
-			log.SetTimeFunction(func(_ time.Time) time.Time {
-				// parse the timestamp from the log line if available
-				t, err := time.Parse(time.RFC3339, data["time"].(string))
+		} else if tailLines > 0 {
+			// Tail mode - show last N lines
+			lines, err := readLastNLines(logsFile, tailLines)
+			if err != nil {
+				return fmt.Errorf("failed to read last %d lines: %v", tailLines, err)
+			}
+			for _, line := range lines {
+				printLogLine(line)
+			}
+		} else {
+			// Oneshot mode - read the entire file once
+			file, err := os.Open(logsFile)
+			if err != nil {
+				return fmt.Errorf("failed to open log file: %v", err)
+			}
+			defer file.Close()
+
+			reader := bufio.NewReader(file)
+			for {
+				line, err := reader.ReadString('\n')
 				if err != nil {
-					return time.Now() // fallback to current time if parsing fails
+					if err == io.EOF && line != "" {
+						// Handle last line without newline
+						printLogLine(line)
+					}
+					break
 				}
-				return t
-			})
-			switch level {
-			case "INFO":
-				log.Info(msg, otherData...)
-			case "DEBUG":
-				log.Debug(msg, otherData...)
-			case "ERROR":
-				log.Error(msg, otherData...)
-			case "WARN":
-				log.Warn(msg, otherData...)
-			default:
-				log.Info(msg, otherData...)
+				// Remove trailing newline
+				line = strings.TrimSuffix(line, "\n")
+				printLogLine(line)
 			}
 		}
+
 		return nil
 	},
 }
+
+func init() {
+	logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
+	logsCmd.Flags().IntP("tail", "t", 0, "Show only the last N lines")
+	rootCmd.AddCommand(logsCmd)
+}
+
+// readLastNLines reads the last N lines from a file using a simple circular buffer approach
+func readLastNLines(filename string, n int) ([]string, error) {
+	file, err := os.Open(filename)
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	// Use a circular buffer to keep only the last N lines
+	lines := make([]string, n)
+	count := 0
+	index := 0
+
+	reader := bufio.NewReader(file)
+	for {
+		line, err := reader.ReadString('\n')
+		if err != nil {
+			if err == io.EOF && line != "" {
+				// Handle last line without newline
+				line = strings.TrimSuffix(line, "\n")
+				lines[index] = line
+				count++
+				index = (index + 1) % n
+			}
+			break
+		}
+		// Remove trailing newline
+		line = strings.TrimSuffix(line, "\n")
+		lines[index] = line
+		count++
+		index = (index + 1) % n
+	}
+
+	// Extract the last N lines in correct order
+	if count <= n {
+		// We have fewer lines than requested, return them all
+		return lines[:count], nil
+	}
+
+	// We have more lines than requested, extract from circular buffer
+	result := make([]string, n)
+	for i := range n {
+		result[i] = lines[(index+i)%n]
+	}
+	return result, nil
+}
+
+func printLogLine(lineText string) {
+	var data map[string]any
+	if err := json.Unmarshal([]byte(lineText), &data); err != nil {
+		return
+	}
+	msg := data["msg"]
+	level := data["level"]
+	otherData := []any{}
+	keys := []string{}
+	for k := range data {
+		keys = append(keys, k)
+	}
+	slices.Sort(keys)
+	for _, k := range keys {
+		switch k {
+		case "msg", "level", "time":
+			continue
+		case "source":
+			source, ok := data[k].(map[string]any)
+			if !ok {
+				continue
+			}
+			sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
+			otherData = append(otherData, "source", sourceFile)
+
+		default:
+			otherData = append(otherData, k, data[k])
+		}
+	}
+	log.SetTimeFunction(func(_ time.Time) time.Time {
+		// parse the timestamp from the log line if available
+		t, err := time.Parse(time.RFC3339, data["time"].(string))
+		if err != nil {
+			return time.Now() // fallback to current time if parsing fails
+		}
+		return t
+	})
+	switch level {
+	case "INFO":
+		log.Info(msg, otherData...)
+	case "DEBUG":
+		log.Debug(msg, otherData...)
+	case "ERROR":
+		log.Error(msg, otherData...)
+	case "WARN":
+		log.Warn(msg, otherData...)
+	default:
+		log.Info(msg, otherData...)
+	}
+}

cmd/root.go πŸ”—

@@ -85,7 +85,6 @@ to assist developers in writing, debugging, and understanding code directly from
 			slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
 			return err
 		}
-		// Defer shutdown here so it runs for both interactive and non-interactive modes
 		defer app.Shutdown()
 
 		// Initialize MCP tools early for both modes
@@ -107,6 +106,7 @@ to assist developers in writing, debugging, and understanding code directly from
 		program := tea.NewProgram(
 			tui.New(app),
 			tea.WithAltScreen(),
+			tea.WithContext(ctx),
 			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
 			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
 		)
@@ -117,7 +117,6 @@ to assist developers in writing, debugging, and understanding code directly from
 			slog.Error(fmt.Sprintf("TUI run error: %v", err))
 			return fmt.Errorf("TUI error: %v", err)
 		}
-		app.Shutdown()
 		return nil
 	},
 }

internal/app/app.go πŸ”—

@@ -282,6 +282,9 @@ func (app *App) Subscribe(program *tea.Program) {
 
 // Shutdown performs a clean shutdown of the application
 func (app *App) Shutdown() {
+	if app.CoderAgent != nil {
+		app.CoderAgent.CancelAll()
+	}
 	app.cancelFuncsMutex.Lock()
 	for _, cancel := range app.watcherCancelFuncs {
 		cancel()
@@ -301,9 +304,6 @@ func (app *App) Shutdown() {
 		}
 		cancel()
 	}
-	if app.CoderAgent != nil {
-		app.CoderAgent.CancelAll()
-	}
 
 	for _, cleanup := range app.cleanupFuncs {
 		if cleanup != nil {

internal/config/load.go πŸ”—

@@ -301,6 +301,7 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la
 		defaultSmallModel := cfg.GetModel(string(p.ID), p.DefaultSmallModelID)
 		if defaultSmallModel == nil {
 			err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
+			return
 		}
 		smallModel = SelectedModel{
 			Provider:        string(p.ID),

internal/llm/agent/agent.go πŸ”—

@@ -403,7 +403,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
 		agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
 		if err != nil {
 			if errors.Is(err, context.Canceled) {
-				agentMessage.AddFinish(message.FinishReasonCanceled)
+				agentMessage.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
 				a.messages.Update(context.Background(), agentMessage)
 				return a.err(ErrRequestCancelled)
 			}
@@ -454,11 +454,15 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
 	// Process each event in the stream.
 	for event := range eventChan {
 		if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
-			a.finishMessage(ctx, &assistantMsg, message.FinishReasonCanceled)
+			if errors.Is(processErr, context.Canceled) {
+				a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
+			} else {
+				a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
+			}
 			return assistantMsg, nil, processErr
 		}
 		if ctx.Err() != nil {
-			a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
+			a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
 			return assistantMsg, nil, ctx.Err()
 		}
 	}
@@ -468,7 +472,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
 	for i, toolCall := range toolCalls {
 		select {
 		case <-ctx.Done():
-			a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
+			a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
 			// Make all future tool calls cancelled
 			for j := i; j < len(toolCalls); j++ {
 				toolResults[j] = message.ToolResult{
@@ -516,7 +520,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
 							IsError:    true,
 						}
 					}
-					a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
+					a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied, "Permission denied", "")
 					break
 				}
 			}
@@ -548,8 +552,8 @@ out:
 	return assistantMsg, &msg, err
 }
 
-func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
-	msg.AddFinish(finishReson)
+func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason, message, details string) {
+	msg.AddFinish(finishReson, message, details)
 	_ = a.messages.Update(ctx, *msg)
 }
 
@@ -580,15 +584,10 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
 		assistantMsg.FinishToolCall(event.ToolCall.ID)
 		return a.messages.Update(ctx, *assistantMsg)
 	case provider.EventError:
-		if errors.Is(event.Error, context.Canceled) {
-			slog.Info(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
-			return context.Canceled
-		}
-		slog.Error(event.Error.Error())
 		return event.Error
 	case provider.EventComplete:
 		assistantMsg.SetToolCalls(event.Response.ToolCalls)
-		assistantMsg.AddFinish(event.Response.FinishReason)
+		assistantMsg.AddFinish(event.Response.FinishReason, "", "")
 		if err := a.messages.Update(ctx, *assistantMsg); err != nil {
 			return fmt.Errorf("failed to update message: %w", err)
 		}
@@ -801,6 +800,16 @@ func (a *agent) CancelAll() {
 		a.Cancel(key.(string)) // key is sessionID
 		return true
 	})
+
+	timeout := time.After(5 * time.Second)
+	for a.IsBusy() {
+		select {
+		case <-timeout:
+			return
+		default:
+			time.Sleep(200 * time.Millisecond)
+		}
+	}
 }
 
 func (a *agent) UpdateModel() error {

internal/message/content.go πŸ”—

@@ -102,8 +102,10 @@ type ToolResult struct {
 func (ToolResult) isPart() {}
 
 type Finish struct {
-	Reason FinishReason `json:"reason"`
-	Time   int64        `json:"time"`
+	Reason  FinishReason `json:"reason"`
+	Time    int64        `json:"time"`
+	Message string       `json:"message,omitempty"`
+	Details string       `json:"details,omitempty"`
 }
 
 func (Finish) isPart() {}
@@ -308,7 +310,7 @@ func (m *Message) SetToolResults(tr []ToolResult) {
 	}
 }
 
-func (m *Message) AddFinish(reason FinishReason) {
+func (m *Message) AddFinish(reason FinishReason, message, details string) {
 	// remove any existing finish part
 	for i, part := range m.Parts {
 		if _, ok := part.(Finish); ok {
@@ -316,7 +318,7 @@ func (m *Message) AddFinish(reason FinishReason) {
 			break
 		}
 	}
-	m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
+	m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
 }
 
 func (m *Message) AddImageURL(url, detail string) {

internal/tui/components/chat/chat.go πŸ”—

@@ -337,7 +337,7 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
 	var cmds []tea.Cmd
 
 	for _, tc := range msg.ToolCalls() {
-		if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
+		if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
 			cmds = append(cmds, cmd)
 		}
 	}
@@ -346,18 +346,21 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
 }
 
 // updateOrAddToolCall updates an existing tool call or adds a new one.
-func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
+func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
 	// Try to find existing tool call
 	for index, existingTC := range existingToolCalls {
 		if tc.ID == existingTC.GetToolCall().ID {
 			existingTC.SetToolCall(tc)
+			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
+				existingTC.SetCancelled()
+			}
 			m.listCmp.UpdateItem(index, existingTC)
 			return nil
 		}
 	}
 
 	// Add new tool call if not found
-	return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
+	return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
 }
 
 // handleNewAssistantMessage processes new assistant messages and their tool calls.

internal/tui/components/chat/editor/editor.go πŸ”—

@@ -130,6 +130,9 @@ func (m *editorCmp) Init() tea.Cmd {
 }
 
 func (m *editorCmp) send() tea.Cmd {
+	if m.app.CoderAgent == nil {
+		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
+	}
 	if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
 		return util.ReportWarn("Agent is working, please wait...")
 	}

internal/tui/components/chat/header/header.go πŸ”—

@@ -21,6 +21,7 @@ type Header interface {
 	SetSession(session session.Session) tea.Cmd
 	SetWidth(width int) tea.Cmd
 	SetDetailsOpen(open bool)
+	ShowingDetails() bool
 }
 
 type header struct {
@@ -137,3 +138,8 @@ func (h *header) SetWidth(width int) tea.Cmd {
 	h.width = width
 	return nil
 }
+
+// ShowingDetails implements Header.
+func (h *header) ShowingDetails() bool {
+	return h.detailsOpen
+}

internal/tui/components/chat/messages/messages.go πŸ”—

@@ -8,6 +8,7 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/fur/provider"
@@ -184,6 +185,7 @@ func (m *messageCmp) toMarkdown(content string) string {
 // markdownContent processes the message content and handles special states.
 // Returns appropriate content for thinking, finished, and error states.
 func (m *messageCmp) markdownContent() string {
+	t := styles.CurrentTheme()
 	content := m.message.Content().String()
 	if m.message.Role == message.Assistant {
 		thinking := m.message.IsThinking()
@@ -199,6 +201,12 @@ func (m *messageCmp) markdownContent() string {
 			content = ""
 		} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
 			content = "*Canceled*"
+		} else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
+			errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
+			truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
+			title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
+			details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
+			return fmt.Sprintf("%s\n\n%s", title, details)
 		}
 	}
 	return m.toMarkdown(content)

internal/tui/components/chat/sidebar/sidebar.go πŸ”—

@@ -33,6 +33,16 @@ type FileHistory struct {
 	latestVersion  history.File
 }
 
+const LogoHeightBreakpoint = 40
+
+// Default maximum number of items to show in each section
+const (
+	DefaultMaxFilesShown = 10
+	DefaultMaxLSPsShown  = 8
+	DefaultMaxMCPsShown  = 8
+	MinItemsPerSection   = 2 // Minimum items to show per section
+)
+
 type SessionFile struct {
 	History   FileHistory
 	FilePath  string
@@ -100,8 +110,14 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (m *sidebarCmp) View() string {
 	t := styles.CurrentTheme()
 	parts := []string{}
+
 	if !m.compactMode {
-		parts = append(parts, m.logo)
+		if m.height > LogoHeightBreakpoint {
+			parts = append(parts, m.logo)
+		} else {
+			// Use a smaller logo for smaller screens
+			parts = append(parts, m.smallerScreenLogo(), "")
+		}
 	}
 
 	if !m.compactMode && m.session.ID != "" {
@@ -119,15 +135,26 @@ func (m *sidebarCmp) View() string {
 	parts = append(parts,
 		m.currentModelBlock(),
 	)
-	if m.session.ID != "" {
-		parts = append(parts, "", m.filesBlock())
+
+	// Check if we should use horizontal layout for sections
+	if m.compactMode && m.width > m.height {
+		// Horizontal layout for compact mode when width > height
+		sectionsContent := m.renderSectionsHorizontal()
+		if sectionsContent != "" {
+			parts = append(parts, "", sectionsContent)
+		}
+	} else {
+		// Vertical layout (default)
+		if m.session.ID != "" {
+			parts = append(parts, "", m.filesBlock())
+		}
+		parts = append(parts,
+			"",
+			m.lspBlock(),
+			"",
+			m.mcpBlock(),
+		)
 	}
-	parts = append(parts,
-		"",
-		m.lspBlock(),
-		"",
-		m.mcpBlock(),
-	)
 
 	style := t.S().Base.
 		Width(m.width).
@@ -258,6 +285,328 @@ func (m *sidebarCmp) getMaxWidth() int {
 	return min(m.width-2, 58) // -2 for padding
 }
 
+// calculateAvailableHeight estimates how much height is available for dynamic content
+func (m *sidebarCmp) calculateAvailableHeight() int {
+	usedHeight := 0
+
+	if !m.compactMode {
+		if m.height > LogoHeightBreakpoint {
+			usedHeight += 7 // Approximate logo height
+		} else {
+			usedHeight += 2 // Smaller logo height
+		}
+		usedHeight += 1 // Empty line after logo
+	}
+
+	if m.session.ID != "" {
+		usedHeight += 1 // Title line
+		usedHeight += 1 // Empty line after title
+	}
+
+	if !m.compactMode {
+		usedHeight += 1 // CWD line
+		usedHeight += 1 // Empty line after CWD
+	}
+
+	usedHeight += 2 // Model info
+
+	usedHeight += 6 // 3 sections Γ— 2 lines each (header + empty line)
+
+	// Base padding
+	usedHeight += 2 // Top and bottom padding
+
+	return max(0, m.height-usedHeight)
+}
+
+// getDynamicLimits calculates how many items to show in each section based on available height
+func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
+	availableHeight := m.calculateAvailableHeight()
+
+	// If we have very little space, use minimum values
+	if availableHeight < 10 {
+		return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
+	}
+
+	// Distribute available height among the three sections
+	// Give priority to files, then LSPs, then MCPs
+	totalSections := 3
+	heightPerSection := availableHeight / totalSections
+
+	// Calculate limits for each section, ensuring minimums
+	maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
+	maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
+	maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
+
+	// If we have extra space, give it to files first
+	remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
+	if remainingHeight > 0 {
+		extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
+		maxFiles += extraForFiles
+		remainingHeight -= extraForFiles
+
+		if remainingHeight > 0 {
+			extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
+			maxLSPs += extraForLSPs
+			remainingHeight -= extraForLSPs
+
+			if remainingHeight > 0 {
+				maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
+			}
+		}
+	}
+
+	return maxFiles, maxLSPs, maxMCPs
+}
+
+// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
+func (m *sidebarCmp) renderSectionsHorizontal() string {
+	// Calculate available width for each section
+	totalWidth := m.width - 4 // Account for padding and spacing
+	sectionWidth := min(50, totalWidth/3)
+
+	// Get the sections content with limited height
+	var filesContent, lspContent, mcpContent string
+
+	filesContent = m.filesBlockCompact(sectionWidth)
+	lspContent = m.lspBlockCompact(sectionWidth)
+	mcpContent = m.mcpBlockCompact(sectionWidth)
+
+	return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
+}
+
+// filesBlockCompact renders the files block with limited width and height for horizontal layout
+func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
+	t := styles.CurrentTheme()
+
+	section := t.S().Subtle.Render("Modified Files")
+
+	files := make([]SessionFile, 0)
+	m.files.Range(func(key, value any) bool {
+		file := value.(SessionFile)
+		files = append(files, file)
+		return true
+	})
+
+	if len(files) == 0 {
+		content := lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Base.Foreground(t.Border).Render("None"),
+		)
+		return lipgloss.NewStyle().Width(maxWidth).Render(content)
+	}
+
+	fileList := []string{section, ""}
+	sort.Slice(files, func(i, j int) bool {
+		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
+	})
+
+	// Limit items for horizontal layout - use less space
+	maxItems := min(5, len(files))
+	availableHeight := m.height - 8 // Reserve space for header and other content
+	if availableHeight > 0 {
+		maxItems = min(maxItems, availableHeight)
+	}
+
+	filesShown := 0
+	for _, file := range files {
+		if file.Additions == 0 && file.Deletions == 0 {
+			continue
+		}
+		if filesShown >= maxItems {
+			break
+		}
+
+		var statusParts []string
+		if file.Additions > 0 {
+			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
+		}
+		if file.Deletions > 0 {
+			statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
+		}
+
+		extraContent := strings.Join(statusParts, " ")
+		cwd := config.Get().WorkingDir() + string(os.PathSeparator)
+		filePath := file.FilePath
+		filePath = strings.TrimPrefix(filePath, cwd)
+		filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
+		filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
+
+		fileList = append(fileList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:    t.FgMuted,
+					NoIcon:       true,
+					Title:        filePath,
+					ExtraContent: extraContent,
+				},
+				maxWidth,
+			),
+		)
+		filesShown++
+	}
+
+	// Add "..." indicator if there are more files
+	totalFilesWithChanges := 0
+	for _, file := range files {
+		if file.Additions > 0 || file.Deletions > 0 {
+			totalFilesWithChanges++
+		}
+	}
+	if totalFilesWithChanges > maxItems {
+		fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
+	}
+
+	content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
+	return lipgloss.NewStyle().Width(maxWidth).Render(content)
+}
+
+// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
+func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
+	t := styles.CurrentTheme()
+
+	section := t.S().Subtle.Render("LSPs")
+
+	lspList := []string{section, ""}
+
+	lsp := config.Get().LSP.Sorted()
+	if len(lsp) == 0 {
+		content := lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Base.Foreground(t.Border).Render("None"),
+		)
+		return lipgloss.NewStyle().Width(maxWidth).Render(content)
+	}
+
+	// Limit items for horizontal layout
+	maxItems := min(5, len(lsp))
+	availableHeight := m.height - 8
+	if availableHeight > 0 {
+		maxItems = min(maxItems, availableHeight)
+	}
+
+	for i, l := range lsp {
+		if i >= maxItems {
+			break
+		}
+
+		iconColor := t.Success
+		if l.LSP.Disabled {
+			iconColor = t.FgMuted
+		}
+
+		lspErrs := map[protocol.DiagnosticSeverity]int{
+			protocol.SeverityError:       0,
+			protocol.SeverityWarning:     0,
+			protocol.SeverityHint:        0,
+			protocol.SeverityInformation: 0,
+		}
+		if client, ok := m.lspClients[l.Name]; ok {
+			for _, diagnostics := range client.GetDiagnostics() {
+				for _, diagnostic := range diagnostics {
+					if severity, ok := lspErrs[diagnostic.Severity]; ok {
+						lspErrs[diagnostic.Severity] = severity + 1
+					}
+				}
+			}
+		}
+
+		errs := []string{}
+		if lspErrs[protocol.SeverityError] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
+		}
+		if lspErrs[protocol.SeverityWarning] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
+		}
+		if lspErrs[protocol.SeverityHint] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
+		}
+		if lspErrs[protocol.SeverityInformation] > 0 {
+			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
+		}
+
+		lspList = append(lspList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:    iconColor,
+					Title:        l.Name,
+					Description:  l.LSP.Command,
+					ExtraContent: strings.Join(errs, " "),
+				},
+				maxWidth,
+			),
+		)
+	}
+
+	// Add "..." indicator if there are more LSPs
+	if len(lsp) > maxItems {
+		lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
+	}
+
+	content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
+	return lipgloss.NewStyle().Width(maxWidth).Render(content)
+}
+
+// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
+func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
+	t := styles.CurrentTheme()
+
+	section := t.S().Subtle.Render("MCPs")
+
+	mcpList := []string{section, ""}
+
+	mcps := config.Get().MCP.Sorted()
+	if len(mcps) == 0 {
+		content := lipgloss.JoinVertical(
+			lipgloss.Left,
+			section,
+			"",
+			t.S().Base.Foreground(t.Border).Render("None"),
+		)
+		return lipgloss.NewStyle().Width(maxWidth).Render(content)
+	}
+
+	// Limit items for horizontal layout
+	maxItems := min(5, len(mcps))
+	availableHeight := m.height - 8
+	if availableHeight > 0 {
+		maxItems = min(maxItems, availableHeight)
+	}
+
+	for i, l := range mcps {
+		if i >= maxItems {
+			break
+		}
+
+		iconColor := t.Success
+		if l.MCP.Disabled {
+			iconColor = t.FgMuted
+		}
+
+		mcpList = append(mcpList,
+			core.Status(
+				core.StatusOpts{
+					IconColor:   iconColor,
+					Title:       l.Name,
+					Description: l.MCP.Command,
+				},
+				maxWidth,
+			),
+		)
+	}
+
+	// Add "..." indicator if there are more MCPs
+	if len(mcps) > maxItems {
+		mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
+	}
+
+	content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
+	return lipgloss.NewStyle().Width(maxWidth).Render(content)
+}
+
 func (m *sidebarCmp) filesBlock() string {
 	t := styles.CurrentTheme()
 
@@ -286,10 +635,19 @@ func (m *sidebarCmp) filesBlock() string {
 		return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
 	})
 
+	// Limit the number of files shown
+	maxFiles, _, _ := m.getDynamicLimits()
+	maxFiles = min(len(files), maxFiles)
+	filesShown := 0
+
 	for _, file := range files {
 		if file.Additions == 0 && file.Deletions == 0 {
 			continue // skip files with no changes
 		}
+		if filesShown >= maxFiles {
+			break
+		}
+
 		var statusParts []string
 		if file.Additions > 0 {
 			statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
@@ -315,6 +673,21 @@ func (m *sidebarCmp) filesBlock() string {
 				m.getMaxWidth(),
 			),
 		)
+		filesShown++
+	}
+
+	// Add indicator if there are more files
+	totalFilesWithChanges := 0
+	for _, file := range files {
+		if file.Additions > 0 || file.Deletions > 0 {
+			totalFilesWithChanges++
+		}
+	}
+	if totalFilesWithChanges > maxFiles {
+		remaining := totalFilesWithChanges - maxFiles
+		fileList = append(fileList,
+			t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
+		)
 	}
 
 	return lipgloss.JoinVertical(
@@ -342,7 +715,14 @@ func (m *sidebarCmp) lspBlock() string {
 		)
 	}
 
-	for _, l := range lsp {
+	// Limit the number of LSPs shown
+	_, maxLSPs, _ := m.getDynamicLimits()
+	maxLSPs = min(len(lsp), maxLSPs)
+	for i, l := range lsp {
+		if i >= maxLSPs {
+			break
+		}
+
 		iconColor := t.Success
 		if l.LSP.Disabled {
 			iconColor = t.FgMuted
@@ -390,6 +770,14 @@ func (m *sidebarCmp) lspBlock() string {
 		)
 	}
 
+	// Add indicator if there are more LSPs
+	if len(lsp) > maxLSPs {
+		remaining := len(lsp) - maxLSPs
+		lspList = append(lspList,
+			t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
+		)
+	}
+
 	return lipgloss.JoinVertical(
 		lipgloss.Left,
 		lspList...,
@@ -415,7 +803,14 @@ func (m *sidebarCmp) mcpBlock() string {
 		)
 	}
 
-	for _, l := range mcps {
+	// Limit the number of MCPs shown
+	_, _, maxMCPs := m.getDynamicLimits()
+	maxMCPs = min(len(mcps), maxMCPs)
+	for i, l := range mcps {
+		if i >= maxMCPs {
+			break
+		}
+
 		iconColor := t.Success
 		if l.MCP.Disabled {
 			iconColor = t.FgMuted
@@ -432,6 +827,14 @@ func (m *sidebarCmp) mcpBlock() string {
 		)
 	}
 
+	// Add indicator if there are more MCPs
+	if len(mcps) > maxMCPs {
+		remaining := len(mcps) - maxMCPs
+		mcpList = append(mcpList,
+			t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
+		)
+	}
+
 	return lipgloss.JoinVertical(
 		lipgloss.Left,
 		mcpList...,
@@ -504,6 +907,19 @@ func (s *sidebarCmp) currentModelBlock() string {
 	)
 }
 
+func (m *sidebarCmp) smallerScreenLogo() string {
+	t := styles.CurrentTheme()
+	title := t.S().Base.Foreground(t.Secondary).Render("Charmβ„’")
+	title += " " + styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)
+	remainingWidth := m.width - lipgloss.Width(title) - 3
+	if remainingWidth > 0 {
+		char := "β•±"
+		lines := strings.Repeat(char, remainingWidth)
+		title += " " + t.S().Base.Foreground(t.Primary).Render(lines)
+	}
+	return title
+}
+
 // SetSession implements Sidebar.
 func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
 	m.session = session

internal/tui/components/chat/splash/splash.go πŸ”—

@@ -192,10 +192,8 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return s, s.initializeProject()
 			}
 		case key.Matches(msg, s.keyMap.No):
-			if s.needsProjectInit {
-				s.needsProjectInit = false
-				return s, util.CmdHandler(OnboardingCompleteMsg{})
-			}
+			s.selectedNo = true
+			return s, s.initializeProject()
 		default:
 			if s.needsAPIKey {
 				u, cmd := s.apiKeyInput.Update(msg)

internal/tui/components/core/status_test.go πŸ”—

@@ -0,0 +1,147 @@
+package core_test
+
+import (
+	"fmt"
+	"image/color"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/tui/components/core"
+	"github.com/charmbracelet/x/exp/golden"
+)
+
+func TestStatus(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		opts  core.StatusOpts
+		width int
+	}{
+		{
+			name: "Default",
+			opts: core.StatusOpts{
+				Title:       "Status",
+				Description: "Everything is working fine",
+			},
+			width: 80,
+		},
+		{
+			name: "WithCustomIcon",
+			opts: core.StatusOpts{
+				Icon:        "βœ“",
+				Title:       "Success",
+				Description: "Operation completed successfully",
+			},
+			width: 80,
+		},
+		{
+			name: "NoIcon",
+			opts: core.StatusOpts{
+				NoIcon:      true,
+				Title:       "Info",
+				Description: "This status has no icon",
+			},
+			width: 80,
+		},
+		{
+			name: "WithColors",
+			opts: core.StatusOpts{
+				Icon:             "⚠",
+				IconColor:        color.RGBA{255, 165, 0, 255}, // Orange
+				Title:            "Warning",
+				TitleColor:       color.RGBA{255, 255, 0, 255}, // Yellow
+				Description:      "This is a warning message",
+				DescriptionColor: color.RGBA{255, 0, 0, 255}, // Red
+			},
+			width: 80,
+		},
+		{
+			name: "WithExtraContent",
+			opts: core.StatusOpts{
+				Title:        "Build",
+				Description:  "Building project",
+				ExtraContent: "[2/5]",
+			},
+			width: 80,
+		},
+		{
+			name: "LongDescription",
+			opts: core.StatusOpts{
+				Title:       "Processing",
+				Description: "This is a very long description that should be truncated when the width is too small to display it completely without wrapping",
+			},
+			width: 60,
+		},
+		{
+			name: "NarrowWidth",
+			opts: core.StatusOpts{
+				Icon:        "●",
+				Title:       "Status",
+				Description: "Short message",
+			},
+			width: 30,
+		},
+		{
+			name: "VeryNarrowWidth",
+			opts: core.StatusOpts{
+				Icon:        "●",
+				Title:       "Test",
+				Description: "This will be truncated",
+			},
+			width: 20,
+		},
+		{
+			name: "EmptyDescription",
+			opts: core.StatusOpts{
+				Icon:  "●",
+				Title: "Title Only",
+			},
+			width: 80,
+		},
+		{
+			name: "AllFieldsWithExtraContent",
+			opts: core.StatusOpts{
+				Icon:             "πŸš€",
+				IconColor:        color.RGBA{0, 255, 0, 255}, // Green
+				Title:            "Deployment",
+				TitleColor:       color.RGBA{0, 0, 255, 255}, // Blue
+				Description:      "Deploying to production environment",
+				DescriptionColor: color.RGBA{128, 128, 128, 255}, // Gray
+				ExtraContent:     "v1.2.3",
+			},
+			width: 80,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			output := core.Status(tt.opts, tt.width)
+			golden.RequireEqual(t, []byte(output))
+		})
+	}
+}
+
+func TestStatusTruncation(t *testing.T) {
+	t.Parallel()
+
+	opts := core.StatusOpts{
+		Icon:         "●",
+		Title:        "Very Long Title",
+		Description:  "This is an extremely long description that definitely needs to be truncated",
+		ExtraContent: "[extra]",
+	}
+
+	// Test different widths to ensure truncation works correctly
+	widths := []int{20, 30, 40, 50, 60}
+
+	for _, width := range widths {
+		t.Run(fmt.Sprintf("Width%d", width), func(t *testing.T) {
+			t.Parallel()
+
+			output := core.Status(opts, width)
+			golden.RequireEqual(t, []byte(output))
+		})
+	}
+}

internal/tui/components/dialogs/permissions/permissions.go πŸ”—

@@ -60,6 +60,9 @@ type permissionDialogCmp struct {
 	cachedContent string
 	contentDirty  bool
 
+	positionRow int // Row position for dialog
+	positionCol int // Column position for dialog
+
 	keyMap KeyMap
 }
 
@@ -446,6 +449,10 @@ func (p *permissionDialogCmp) render() string {
 	p.contentViewPort.SetHeight(contentHeight)
 	p.contentViewPort.SetContent(contentFinal)
 
+	p.positionRow = p.wHeight / 2
+	p.positionRow -= (contentHeight + 9) / 2
+	p.positionRow -= 3 // Move dialog slightly higher than middle
+
 	var contentHelp string
 	if p.supportsDiffView() {
 		contentHelp = help.New().View(p.keyMap)
@@ -509,7 +516,11 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
 	if oldWidth != p.width || oldHeight != p.height {
 		p.contentDirty = true
 	}
-
+	p.positionRow = p.wHeight / 2
+	p.positionRow -= p.height / 2
+	p.positionRow -= 3 // Move dialog slightly higher than middle
+	p.positionCol = p.wWidth / 2
+	p.positionCol -= p.width / 2
 	return nil
 }
 
@@ -529,9 +540,5 @@ func (p *permissionDialogCmp) ID() dialogs.DialogID {
 
 // Position implements PermissionDialogCmp.
 func (p *permissionDialogCmp) Position() (int, int) {
-	row := (p.wHeight / 2) - 2 // Just a bit above the center
-	row -= p.height / 2
-	col := p.wWidth / 2
-	col -= p.width / 2
-	return row, col
+	return p.positionRow, p.positionCol
 }

internal/tui/page/chat/chat.go πŸ”—

@@ -51,11 +51,12 @@ const (
 )
 
 const (
-	CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
-	EditorHeight          = 5   // Height of the editor input area including padding
-	SideBarWidth          = 31  // Width of the sidebar
-	SideBarDetailsPadding = 1   // Padding for the sidebar details section
-	HeaderHeight          = 1   // Height of the header
+	CompactModeWidthBreakpoint  = 120 // Width at which the chat page switches to compact mode
+	CompactModeHeightBreakpoint = 30  // Height at which the chat page switches to compact mode
+	EditorHeight                = 5   // Height of the editor input area including padding
+	SideBarWidth                = 31  // Width of the sidebar
+	SideBarDetailsPadding       = 1   // Padding for the sidebar details section
+	HeaderHeight                = 1   // Height of the header
 
 	// Layout constants for borders and padding
 	BorderWidth        = 1 // Width of component borders
@@ -177,7 +178,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if p.forceCompact {
 			p.setCompactMode(true)
 			cmd = p.updateCompactConfig(true)
-		} else if p.width >= CompactModeBreakpoint {
+		} else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
 			p.setCompactMode(false)
 			cmd = p.updateCompactConfig(false)
 		}
@@ -313,6 +314,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (p *chatPage) Cursor() *tea.Cursor {
+	if p.header.ShowingDetails() {
+		return nil
+	}
 	switch p.focusedPane {
 	case PanelTypeEditor:
 		return p.editor.Cursor()
@@ -420,20 +424,20 @@ func (p *chatPage) setCompactMode(compact bool) {
 	}
 }
 
-func (p *chatPage) handleCompactMode(newWidth int) {
+func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
 	if p.forceCompact {
 		return
 	}
-	if newWidth < CompactModeBreakpoint && !p.compact {
+	if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
 		p.setCompactMode(true)
 	}
-	if newWidth >= CompactModeBreakpoint && p.compact {
+	if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
 		p.setCompactMode(false)
 	}
 }
 
 func (p *chatPage) SetSize(width, height int) tea.Cmd {
-	p.handleCompactMode(width)
+	p.handleCompactMode(width, height)
 	p.width = width
 	p.height = height
 	var cmds []tea.Cmd

internal/tui/tui.go πŸ”—

@@ -315,7 +315,6 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	// dialogs
 	case key.Matches(msg, a.keyMap.Quit):
 		if a.dialog.ActiveDialogID() == quit.QuitDialogID {
-			// if the quit dialog is already open, close the app
 			return tea.Quit
 		}
 		return util.CmdHandler(dialogs.OpenDialogMsg{
@@ -324,20 +323,21 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 
 	case key.Matches(msg, a.keyMap.Commands):
 		if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
-			// If the commands dialog is already open, close it
 			return util.CmdHandler(dialogs.CloseDialogMsg{})
 		}
 		if a.dialog.HasDialogs() {
-			return nil // Don't open commands dialog if another dialog is active
+			return nil
 		}
 		return util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: commands.NewCommandDialog(a.selectedSessionID),
 		})
 	case key.Matches(msg, a.keyMap.Sessions):
 		if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
-			// If the sessions dialog is already open, close it
 			return util.CmdHandler(dialogs.CloseDialogMsg{})
 		}
+		if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
+			return nil
+		}
 		var cmds []tea.Cmd
 		if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
 			// If the commands dialog is open, close it first