Merge branch 'ui' into ui-picker

Ayman Bagabas created

Change summary

internal/commands/commands.go       | 213 +++++++++++++++++
internal/ui/dialog/actions.go       |  20 +
internal/ui/dialog/commands.go      | 386 ++++++++++++++----------------
internal/ui/dialog/commands_item.go |  39 ++
internal/ui/model/header.go         | 112 ++++++++
internal/ui/model/lsp.go            |   3 
internal/ui/model/mcp.go            |   3 
internal/ui/model/session.go        |  11 
internal/ui/model/sidebar.go        |   2 
internal/ui/model/ui.go             | 374 +++++++++++++++++++++++------
internal/ui/styles/styles.go        |  34 ++
internal/uicmd/uicmd.go             |   1 
12 files changed, 888 insertions(+), 310 deletions(-)

Detailed changes

internal/commands/commands.go πŸ”—

@@ -0,0 +1,213 @@
+package commands
+
+import (
+	"io/fs"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
+)
+
+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
+
+const (
+	userCommandPrefix    = "user:"
+	projectCommandPrefix = "project:"
+)
+
+// Argument represents a command argument with its name and required status.
+type Argument struct {
+	Name     string
+	Required bool
+}
+
+// MCPCustomCommand represents a custom command loaded from an MCP server.
+type MCPCustomCommand struct {
+	ID        string
+	Name      string
+	Client    string
+	Arguments []Argument
+}
+
+// CustomCommand represents a user-defined custom command loaded from markdown files.
+type CustomCommand struct {
+	ID        string
+	Name      string
+	Content   string
+	Arguments []Argument
+}
+
+type commandSource struct {
+	path   string
+	prefix string
+}
+
+// LoadCustomCommands loads custom commands from multiple sources including
+// XDG config directory, home directory, and project directory.
+func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
+	return loadAll(buildCommandSources(cfg))
+}
+
+// LoadMCPCustomCommands loads custom commands from available MCP servers.
+func LoadMCPCustomCommands() ([]MCPCustomCommand, error) {
+	var commands []MCPCustomCommand
+	for mcpName, prompts := range mcp.Prompts() {
+		for _, prompt := range prompts {
+			key := mcpName + ":" + prompt.Name
+			var args []Argument
+			for _, arg := range prompt.Arguments {
+				args = append(args, Argument{Name: arg.Name, Required: arg.Required})
+			}
+
+			commands = append(commands, MCPCustomCommand{
+				ID:        key,
+				Name:      prompt.Name,
+				Client:    mcpName,
+				Arguments: args,
+			})
+		}
+	}
+	return commands, nil
+}
+
+func buildCommandSources(cfg *config.Config) []commandSource {
+	var sources []commandSource
+
+	// XDG config directory
+	if dir := getXDGCommandsDir(); dir != "" {
+		sources = append(sources, commandSource{
+			path:   dir,
+			prefix: userCommandPrefix,
+		})
+	}
+
+	// Home directory
+	if home := home.Dir(); home != "" {
+		sources = append(sources, commandSource{
+			path:   filepath.Join(home, ".crush", "commands"),
+			prefix: userCommandPrefix,
+		})
+	}
+
+	// Project directory
+	sources = append(sources, commandSource{
+		path:   filepath.Join(cfg.Options.DataDirectory, "commands"),
+		prefix: projectCommandPrefix,
+	})
+
+	return sources
+}
+
+func loadAll(sources []commandSource) ([]CustomCommand, error) {
+	var commands []CustomCommand
+
+	for _, source := range sources {
+		if cmds, err := loadFromSource(source); err == nil {
+			commands = append(commands, cmds...)
+		}
+	}
+
+	return commands, nil
+}
+
+func loadFromSource(source commandSource) ([]CustomCommand, error) {
+	if err := ensureDir(source.path); err != nil {
+		return nil, err
+	}
+
+	var commands []CustomCommand
+
+	err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
+		if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
+			return err
+		}
+
+		cmd, err := loadCommand(path, source.path, source.prefix)
+		if err != nil {
+			return nil // Skip invalid files
+		}
+
+		commands = append(commands, cmd)
+		return nil
+	})
+
+	return commands, err
+}
+
+func loadCommand(path, baseDir, prefix string) (CustomCommand, error) {
+	content, err := os.ReadFile(path)
+	if err != nil {
+		return CustomCommand{}, err
+	}
+
+	id := buildCommandID(path, baseDir, prefix)
+
+	return CustomCommand{
+		ID:        id,
+		Name:      id,
+		Content:   string(content),
+		Arguments: extractArgNames(string(content)),
+	}, nil
+}
+
+func extractArgNames(content string) []Argument {
+	matches := namedArgPattern.FindAllStringSubmatch(content, -1)
+	if len(matches) == 0 {
+		return nil
+	}
+
+	seen := make(map[string]bool)
+	var args []Argument
+
+	for _, match := range matches {
+		arg := match[1]
+		if !seen[arg] {
+			seen[arg] = true
+			// for normal custom commands, all args are required
+			args = append(args, Argument{Name: arg, Required: true})
+		}
+	}
+
+	return args
+}
+
+func buildCommandID(path, baseDir, prefix string) string {
+	relPath, _ := filepath.Rel(baseDir, path)
+	parts := strings.Split(relPath, string(filepath.Separator))
+
+	// Remove .md extension from last part
+	if len(parts) > 0 {
+		lastIdx := len(parts) - 1
+		parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
+	}
+
+	return prefix + strings.Join(parts, ":")
+}
+
+func getXDGCommandsDir() string {
+	xdgHome := os.Getenv("XDG_CONFIG_HOME")
+	if xdgHome == "" {
+		if home := home.Dir(); home != "" {
+			xdgHome = filepath.Join(home, ".config")
+		}
+	}
+	if xdgHome != "" {
+		return filepath.Join(xdgHome, "crush", "commands")
+	}
+	return ""
+}
+
+func ensureDir(path string) error {
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		return os.MkdirAll(path, 0o755)
+	}
+	return nil
+}
+
+func isMarkdownFile(name string) bool {
+	return strings.HasSuffix(strings.ToLower(name), ".md")
+}

internal/ui/dialog/actions.go πŸ”—

@@ -8,6 +8,7 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
@@ -47,6 +48,8 @@ type (
 	ActionToggleThinking    struct{}
 	ActionExternalEditor    struct{}
 	ActionToggleYoloMode    struct{}
+	// ActionInitializeProject is a message to initialize a project.
+	ActionInitializeProject struct{}
 	ActionSummarize         struct {
 		SessionID string
 	}
@@ -54,6 +57,23 @@ type (
 		Permission permission.PermissionRequest
 		Action     PermissionAction
 	}
+	// ActionRunCustomCommand is a message to run a custom command.
+	ActionRunCustomCommand struct {
+		CommandID string
+		// Used when running a user-defined command
+		Content string
+		// Used when running a prompt from MCP
+		Client string
+	}
+	// ActionOpenCustomCommandArgumentsDialog is a message to open the custom command arguments dialog.
+	ActionOpenCustomCommandArgumentsDialog struct {
+		CommandID string
+		// Used when running a user-defined command
+		Content string
+		// Used when running a prompt from MCP
+		Client    string
+		Arguments []commands.Argument
+	}
 )
 
 // Messages for API key input dialog.

internal/ui/dialog/commands.go πŸ”—

@@ -1,9 +1,7 @@
 package dialog
 
 import (
-	"fmt"
 	"os"
-	"slices"
 	"strings"
 
 	"charm.land/bubbles/v2/help"
@@ -12,15 +10,11 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/crush/internal/uicmd"
-	"github.com/charmbracelet/crush/internal/uiutil"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 )
@@ -28,6 +22,20 @@ import (
 // CommandsID is the identifier for the commands dialog.
 const CommandsID = "commands"
 
+// CommandType represents the type of commands being displayed.
+type CommandType uint
+
+// String returns the string representation of the CommandType.
+func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
+
+const sidebarCompactModeBreakpoint = 120
+
+const (
+	SystemCommands CommandType = iota
+	UserCommands
+	MCPPrompts
+)
+
 // Commands represents a dialog that shows available commands.
 type Commands struct {
 	com    *common.Common
@@ -37,39 +45,33 @@ type Commands struct {
 		Next,
 		Previous,
 		Tab,
+		ShiftTab,
 		Close key.Binding
 	}
 
-	sessionID  string // can be empty for non-session-specific commands
-	selected   uicmd.CommandType
-	userCmds   []uicmd.Command
-	mcpPrompts *csync.Slice[uicmd.Command]
+	sessionID string // can be empty for non-session-specific commands
+	selected  CommandType
 
 	help  help.Model
 	input textinput.Model
 	list  *list.FilterableList
 
-	width int
+	windowWidth int
+
+	customCommands    []commands.CustomCommand
+	mcpCustomCommands []commands.MCPCustomCommand
 }
 
 var _ Dialog = (*Commands)(nil)
 
 // NewCommands creates a new commands dialog.
-func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
-	commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
-	if err != nil {
-		return nil, err
-	}
-
-	mcpPrompts := csync.NewSlice[uicmd.Command]()
-	mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
-
+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpCustomCommands []commands.MCPCustomCommand) (*Commands, error) {
 	c := &Commands{
-		com:        com,
-		userCmds:   commands,
-		selected:   uicmd.SystemCommands,
-		mcpPrompts: mcpPrompts,
-		sessionID:  sessionID,
+		com:               com,
+		selected:          SystemCommands,
+		sessionID:         sessionID,
+		customCommands:    customCommands,
+		mcpCustomCommands: mcpCustomCommands,
 	}
 
 	help := help.New()
@@ -96,7 +98,7 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
 		key.WithHelp("↑/↓", "choose"),
 	)
 	c.keyMap.Next = key.NewBinding(
-		key.WithKeys("down", "ctrl+n"),
+		key.WithKeys("down"),
 		key.WithHelp("↓", "next item"),
 	)
 	c.keyMap.Previous = key.NewBinding(
@@ -107,12 +109,16 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
 		key.WithKeys("tab"),
 		key.WithHelp("tab", "switch selection"),
 	)
+	c.keyMap.ShiftTab = key.NewBinding(
+		key.WithKeys("shift+tab"),
+		key.WithHelp("shift+tab", "switch selection prev"),
+	)
 	closeKey := CloseKey
 	closeKey.SetHelp("esc", "cancel")
 	c.keyMap.Close = closeKey
 
 	// Set initial commands
-	c.setCommandType(c.selected)
+	c.setCommandItems(c.selected)
 
 	return c, nil
 }
@@ -150,20 +156,28 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
 		case key.Matches(msg, c.keyMap.Select):
 			if selectedItem := c.list.SelectedItem(); selectedItem != nil {
 				if item, ok := selectedItem.(*CommandItem); ok && item != nil {
-					// TODO: Please unravel this mess later and the Command
-					// Handler design.
-					if cmd := item.Cmd.Handler(item.Cmd); cmd != nil { // Huh??
-						return cmd()
-					}
+					return item.Action()
 				}
 			}
 		case key.Matches(msg, c.keyMap.Tab):
-			if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
+			if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
 				c.selected = c.nextCommandType()
-				c.setCommandType(c.selected)
+				c.setCommandItems(c.selected)
+			}
+		case key.Matches(msg, c.keyMap.ShiftTab):
+			if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
+				c.selected = c.previousCommandType()
+				c.setCommandItems(c.selected)
 			}
 		default:
 			var cmd tea.Cmd
+			for _, item := range c.list.VisibleItems() {
+				if item, ok := item.(*CommandItem); ok && item != nil {
+					if msg.String() == item.Shortcut() {
+						return item.Action()
+					}
+				}
+			}
 			c.input, cmd = c.input.Update(msg)
 			value := c.input.Value()
 			c.list.SetFilter(value)
@@ -175,28 +189,18 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
 	return nil
 }
 
-// ReloadMCPPrompts reloads the MCP prompts.
-func (c *Commands) ReloadMCPPrompts() tea.Cmd {
-	c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
-	// If we're currently viewing MCP prompts, refresh the list
-	if c.selected == uicmd.MCPPrompts {
-		c.setCommandType(uicmd.MCPPrompts)
-	}
-	return nil
-}
-
 // Cursor returns the cursor position relative to the dialog.
 func (c *Commands) Cursor() *tea.Cursor {
 	return InputCursor(c.com.Styles, c.input.Cursor())
 }
 
 // commandsRadioView generates the command type selector radio buttons.
-func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
+func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
 	if !hasUserCmds && !hasMCPPrompts {
 		return ""
 	}
 
-	selectedFn := func(t uicmd.CommandType) string {
+	selectedFn := func(t CommandType) string {
 		if t == selected {
 			return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
 		}
@@ -204,14 +208,14 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
 	}
 
 	parts := []string{
-		selectedFn(uicmd.SystemCommands),
+		selectedFn(SystemCommands),
 	}
 
 	if hasUserCmds {
-		parts = append(parts, selectedFn(uicmd.UserCommands))
+		parts = append(parts, selectedFn(UserCommands))
 	}
 	if hasMCPPrompts {
-		parts = append(parts, selectedFn(uicmd.MCPPrompts))
+		parts = append(parts, selectedFn(MCPPrompts))
 	}
 
 	return strings.Join(parts, " ")
@@ -222,7 +226,12 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := c.com.Styles
 	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
 	height := max(0, min(defaultDialogHeight, area.Dy()))
-	c.width = width
+	if area.Dx() != c.windowWidth && c.selected == SystemCommands {
+		c.windowWidth = area.Dx()
+		// since some items in the list depend on width (e.g. toggle sidebar command),
+		// we need to reset the command items when width changes
+		c.setCommandItems(c.selected)
+	}
 	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
 	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
 		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
@@ -233,7 +242,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	c.list.SetSize(innerWidth, height-heightOffset)
 	c.help.SetWidth(innerWidth)
 
-	radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
+	radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpCustomCommands) > 0)
 	titleStyle := t.Dialog.Title
 	dialogStyle := t.Dialog.View.Width(width)
 	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
@@ -265,99 +274,116 @@ func (c *Commands) FullHelp() [][]key.Binding {
 	}
 }
 
-func (c *Commands) nextCommandType() uicmd.CommandType {
+// nextCommandType returns the next command type in the cycle.
+func (c *Commands) nextCommandType() CommandType {
 	switch c.selected {
-	case uicmd.SystemCommands:
-		if len(c.userCmds) > 0 {
-			return uicmd.UserCommands
+	case SystemCommands:
+		if len(c.customCommands) > 0 {
+			return UserCommands
 		}
-		if c.mcpPrompts.Len() > 0 {
-			return uicmd.MCPPrompts
+		if len(c.mcpCustomCommands) > 0 {
+			return MCPPrompts
 		}
 		fallthrough
-	case uicmd.UserCommands:
-		if c.mcpPrompts.Len() > 0 {
-			return uicmd.MCPPrompts
+	case UserCommands:
+		if len(c.mcpCustomCommands) > 0 {
+			return MCPPrompts
 		}
 		fallthrough
-	case uicmd.MCPPrompts:
-		return uicmd.SystemCommands
+	case MCPPrompts:
+		return SystemCommands
 	default:
-		return uicmd.SystemCommands
+		return SystemCommands
 	}
 }
 
-func (c *Commands) setCommandType(commandType uicmd.CommandType) {
-	c.selected = commandType
-
-	var commands []uicmd.Command
+// previousCommandType returns the previous command type in the cycle.
+func (c *Commands) previousCommandType() CommandType {
 	switch c.selected {
-	case uicmd.SystemCommands:
-		commands = c.defaultCommands()
-	case uicmd.UserCommands:
-		commands = c.userCmds
-	case uicmd.MCPPrompts:
-		commands = slices.Collect(c.mcpPrompts.Seq())
+	case SystemCommands:
+		if len(c.mcpCustomCommands) > 0 {
+			return MCPPrompts
+		}
+		if len(c.customCommands) > 0 {
+			return UserCommands
+		}
+		return SystemCommands
+	case UserCommands:
+		return SystemCommands
+	case MCPPrompts:
+		if len(c.customCommands) > 0 {
+			return UserCommands
+		}
+		return SystemCommands
+	default:
+		return SystemCommands
 	}
+}
+
+// setCommandItems sets the command items based on the specified command type.
+func (c *Commands) setCommandItems(commandType CommandType) {
+	c.selected = commandType
 
 	commandItems := []list.FilterableItem{}
-	for _, cmd := range commands {
-		commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
+	switch c.selected {
+	case SystemCommands:
+		for _, cmd := range c.defaultCommands() {
+			commandItems = append(commandItems, cmd)
+		}
+	case UserCommands:
+		for _, cmd := range c.customCommands {
+			var action Action
+			if len(cmd.Arguments) > 0 {
+				action = ActionOpenCustomCommandArgumentsDialog{
+					CommandID: cmd.ID,
+					Content:   cmd.Content,
+					Arguments: cmd.Arguments,
+				}
+			} else {
+				action = ActionRunCustomCommand{
+					CommandID: cmd.ID,
+					Content:   cmd.Content,
+				}
+			}
+			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
+		}
+	case MCPPrompts:
+		for _, cmd := range c.mcpCustomCommands {
+			var action Action
+			if len(cmd.Arguments) > 0 {
+				action = ActionOpenCustomCommandArgumentsDialog{
+					CommandID: cmd.ID,
+					Client:    cmd.Client,
+					Arguments: cmd.Arguments,
+				}
+			} else {
+				action = ActionRunCustomCommand{
+					CommandID: cmd.ID,
+					Client:    cmd.Client,
+				}
+			}
+			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.Name, "", action))
+		}
 	}
 
 	c.list.SetItems(commandItems...)
-	c.list.SetSelected(0)
 	c.list.SetFilter("")
 	c.list.ScrollToTop()
 	c.list.SetSelected(0)
 	c.input.SetValue("")
 }
 
-// TODO: Rethink this
-func (c *Commands) defaultCommands() []uicmd.Command {
-	commands := []uicmd.Command{
-		{
-			ID:          "new_session",
-			Title:       "New Session",
-			Description: "start a new session",
-			Shortcut:    "ctrl+n",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionNewSession{})
-			},
-		},
-		{
-			ID:          "switch_session",
-			Title:       "Switch Session",
-			Description: "Switch to a different session",
-			Shortcut:    "ctrl+s",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionOpenDialog{SessionsID})
-			},
-		},
-		{
-			ID:          "switch_model",
-			Title:       "Switch Model",
-			Description: "Switch to a different model",
-			// FIXME: The shortcut might get updated if enhanced keyboard is supported.
-			Shortcut: "ctrl+l",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionOpenDialog{ModelsID})
-			},
-		},
+// defaultCommands returns the list of default system commands.
+func (c *Commands) defaultCommands() []*CommandItem {
+	commands := []*CommandItem{
+		NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
+		NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
+		NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
 	}
 
 	// Only show compact command if there's an active session
 	if c.sessionID != "" {
-		commands = append(commands, uicmd.Command{
-			ID:          "Summarize",
-			Title:       "Summarize Session",
-			Description: "Summarize the current session and create a new one with the summary",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionSummarize{
-					SessionID: c.sessionID,
-				})
-			},
-		})
+		commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
 	}
 
 	// Add reasoning toggle for models that support it
@@ -374,116 +400,58 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 				if selectedModel.Think {
 					status = "Disable"
 				}
-				commands = append(commands, uicmd.Command{
-					ID:          "toggle_thinking",
-					Title:       status + " Thinking Mode",
-					Description: "Toggle model thinking for reasoning-capable models",
-					Handler: func(cmd uicmd.Command) tea.Cmd {
-						return uiutil.CmdHandler(ActionToggleThinking{})
-					},
-				})
+				commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
 			}
 
 			// OpenAI models: reasoning effort dialog
 			if len(model.ReasoningLevels) > 0 {
-				commands = append(commands, uicmd.Command{
-					ID:          "select_reasoning_effort",
-					Title:       "Select Reasoning Effort",
-					Description: "Choose reasoning effort level (low/medium/high)",
-					Handler: func(cmd uicmd.Command) tea.Cmd {
-						return uiutil.CmdHandler(ActionOpenDialog{
-							// TODO: Pass reasoning dialog id
-						})
-					},
-				})
+				commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
+					// TODO: Pass in the reasoning effort dialog id
+				}))
 			}
 		}
 	}
-	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
-	// TODO: Get. Rid. Of. Magic. Numbers!
-	if c.width > 120 && c.sessionID != "" {
-		commands = append(commands, uicmd.Command{
-			ID:          "toggle_sidebar",
-			Title:       "Toggle Sidebar",
-			Description: "Toggle between compact and normal layout",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionToggleCompactMode{})
-			},
-		})
+	// Only show toggle compact mode command if window width is larger than compact breakpoint (120)
+	if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
+		commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
 	}
 	if c.sessionID != "" {
 		cfg := c.com.Config()
 		agentCfg := cfg.Agents[config.AgentCoder]
 		model := cfg.GetModelByType(agentCfg.Model)
 		if model != nil && model.SupportsImages {
-			commands = append(commands, uicmd.Command{
-				ID:          "file_picker",
-				Title:       "Open File Picker",
-				Shortcut:    "ctrl+f",
-				Description: "Open file picker",
-				Handler: func(cmd uicmd.Command) tea.Cmd {
-					return uiutil.CmdHandler(ActionOpenDialog{
-						// TODO: Pass file picker dialog id
-					})
-				},
-			})
+			commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
+				// TODO: Pass in the file picker dialog id
+			}))
 		}
 	}
 
 	// Add external editor command if $EDITOR is available
 	// TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
 	if os.Getenv("EDITOR") != "" {
-		commands = append(commands, uicmd.Command{
-			ID:          "open_external_editor",
-			Title:       "Open External Editor",
-			Shortcut:    "ctrl+o",
-			Description: "Open external editor to compose message",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionExternalEditor{})
-			},
-		})
+		commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
 	}
 
-	return append(commands, []uicmd.Command{
-		{
-			ID:          "toggle_yolo",
-			Title:       "Toggle Yolo Mode",
-			Description: "Toggle yolo mode",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionToggleYoloMode{})
-			},
-		},
-		{
-			ID:          "toggle_help",
-			Title:       "Toggle Help",
-			Shortcut:    "ctrl+g",
-			Description: "Toggle help",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionToggleHelp{})
-			},
-		},
-		{
-			ID:          "init",
-			Title:       "Initialize Project",
-			Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				initPrompt, err := agent.InitializePrompt(*c.com.Config())
-				if err != nil {
-					return uiutil.ReportError(err)
-				}
-				return uiutil.CmdHandler(chat.SendMsg{
-					Text: initPrompt,
-				})
-			},
-		},
-		{
-			ID:          "quit",
-			Title:       "Quit",
-			Description: "Quit",
-			Shortcut:    "ctrl+c",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(tea.QuitMsg{})
-			},
-		},
-	}...)
+	return append(commands,
+		NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
+		NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
+		NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
+		NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
+	)
+}
+
+// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
+func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
+	c.customCommands = customCommands
+	if c.selected == UserCommands {
+		c.setCommandItems(c.selected)
+	}
+}
+
+// SetMCPCustomCommands sets the MCP custom commands and refreshes the view if MCP prompts are currently displayed.
+func (c *Commands) SetMCPCustomCommands(mcpCustomCommands []commands.MCPCustomCommand) {
+	c.mcpCustomCommands = mcpCustomCommands
+	if c.selected == MCPPrompts {
+		c.setCommandItems(c.selected)
+	}
 }

internal/ui/dialog/commands_item.go πŸ”—

@@ -2,37 +2,42 @@ package dialog
 
 import (
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/crush/internal/uicmd"
 	"github.com/sahilm/fuzzy"
 )
 
 // CommandItem wraps a uicmd.Command to implement the ListItem interface.
 type CommandItem struct {
-	Cmd     uicmd.Command
-	t       *styles.Styles
-	m       fuzzy.Match
-	cache   map[int]string
-	focused bool
+	id       string
+	title    string
+	shortcut string
+	action   Action
+	t        *styles.Styles
+	m        fuzzy.Match
+	cache    map[int]string
+	focused  bool
 }
 
 var _ ListItem = &CommandItem{}
 
 // NewCommandItem creates a new CommandItem.
-func NewCommandItem(t *styles.Styles, cmd uicmd.Command) *CommandItem {
+func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem {
 	return &CommandItem{
-		Cmd: cmd,
-		t:   t,
+		id:       id,
+		t:        t,
+		title:    title,
+		shortcut: shortcut,
+		action:   action,
 	}
 }
 
 // Filter implements ListItem.
 func (c *CommandItem) Filter() string {
-	return c.Cmd.Title
+	return c.title
 }
 
 // ID implements ListItem.
 func (c *CommandItem) ID() string {
-	return c.Cmd.ID
+	return c.id
 }
 
 // SetFocused implements ListItem.
@@ -49,7 +54,17 @@ func (c *CommandItem) SetMatch(m fuzzy.Match) {
 	c.m = m
 }
 
+// Action returns the action associated with the command item.
+func (c *CommandItem) Action() Action {
+	return c.action
+}
+
+// Shortcut returns the shortcut associated with the command item.
+func (c *CommandItem) Shortcut() string {
+	return c.shortcut
+}
+
 // Render implements ListItem.
 func (c *CommandItem) Render(width int) string {
-	return renderItem(c.t, c.Cmd.Title, c.Cmd.Shortcut, c.focused, width, c.cache, &c.m)
+	return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
 }

internal/ui/model/header.go πŸ”—

@@ -0,0 +1,112 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+const (
+	headerDiag     = "β•±"
+	minHeaderDiags = 3
+	leftPadding    = 1
+	rightPadding   = 1
+)
+
+// renderCompactHeader renders the compact header for the given session.
+func renderCompactHeader(
+	com *common.Common,
+	session *session.Session,
+	lspClients *csync.Map[string, *lsp.Client],
+	detailsOpen bool,
+	width int,
+) string {
+	if session == nil || session.ID == "" {
+		return ""
+	}
+
+	t := com.Styles
+
+	var b strings.Builder
+
+	b.WriteString(t.Header.Charm.Render("Charmβ„’"))
+	b.WriteString(" ")
+	b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary))
+	b.WriteString(" ")
+
+	availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
+	details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth)
+
+	remainingWidth := width -
+		lipgloss.Width(b.String()) -
+		lipgloss.Width(details) -
+		leftPadding -
+		rightPadding
+
+	if remainingWidth > 0 {
+		b.WriteString(t.Header.Diagonals.Render(
+			strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)),
+		))
+		b.WriteString(" ")
+	}
+
+	b.WriteString(details)
+
+	return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
+}
+
+// renderHeaderDetails renders the details section of the header.
+func renderHeaderDetails(
+	com *common.Common,
+	session *session.Session,
+	lspClients *csync.Map[string, *lsp.Client],
+	detailsOpen bool,
+	availWidth int,
+) string {
+	t := com.Styles
+
+	var parts []string
+
+	errorCount := 0
+	for l := range lspClients.Seq() {
+		errorCount += l.GetDiagnosticCounts().Error
+	}
+
+	if errorCount > 0 {
+		parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
+	}
+
+	agentCfg := config.Get().Agents[config.AgentCoder]
+	model := config.Get().GetModelByType(agentCfg.Model)
+	percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
+	formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
+	parts = append(parts, formattedPercentage)
+
+	const keystroke = "ctrl+d"
+	if detailsOpen {
+		parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close"))
+	} else {
+		parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open "))
+	}
+
+	dot := t.Header.Separator.Render(" β€’ ")
+	metadata := strings.Join(parts, dot)
+	metadata = dot + metadata
+
+	const dirTrimLimit = 4
+	cfg := com.Config()
+	cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit)
+	cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
+	cwd = t.Header.WorkingDir.Render(cwd)
+
+	return cwd + metadata
+}

internal/ui/model/lsp.go πŸ”—

@@ -72,6 +72,9 @@ func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverit
 // lspList renders a list of LSP clients with their status and diagnostics,
 // truncating to maxItems if needed.
 func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
 	var renderedLsps []string
 	for _, l := range lsps {
 		var icon string

internal/ui/model/mcp.go πŸ”—

@@ -49,6 +49,9 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
 // mcpList renders a list of MCP clients with their status and counts,
 // truncating to maxItems if needed.
 func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
 	var renderedMcps []string
 
 	for _, m := range mcps {

internal/ui/model/session.go πŸ”—

@@ -169,9 +169,13 @@ func (m *UI) handleFileEvent(file history.File) tea.Cmd {
 
 // filesInfo renders the modified files section for the sidebar, showing files
 // with their addition/deletion counts.
-func (m *UI) filesInfo(cwd string, width, maxItems int) string {
+func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
 	t := m.com.Styles
-	title := common.Section(t, "Modified Files", width)
+
+	title := t.Subtle.Render("Modified Files")
+	if isSection {
+		title = common.Section(t, "Modified Files", width)
+	}
 	list := t.Subtle.Render("None")
 
 	if len(m.sessionFiles) > 0 {
@@ -184,6 +188,9 @@ func (m *UI) filesInfo(cwd string, width, maxItems int) string {
 // fileList renders a list of files with their diff statistics, truncating to
 // maxItems and showing a "...and N more" message if needed.
 func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
 	var renderedFiles []string
 	filesShown := 0
 

internal/ui/model/sidebar.go πŸ”—

@@ -133,7 +133,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
 
 	lspSection := m.lspInfo(width, maxLSPs, true)
 	mcpSection := m.mcpInfo(width, maxMCPs, true)
-	filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles)
+	filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true)
 
 	uv.NewStyledString(
 		lipgloss.NewStyle().

internal/ui/model/ui.go πŸ”—

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"image"
+	"log/slog"
 	"math/rand"
 	"net/http"
 	"os"
@@ -23,6 +24,7 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
@@ -46,6 +48,15 @@ import (
 	"github.com/charmbracelet/x/editor"
 )
 
+// Compact mode breakpoints.
+const (
+	compactModeWidthBreakpoint  = 120
+	compactModeHeightBreakpoint = 30
+)
+
+// Session details panel max height.
+const sessionDetailsMaxHeight = 20
+
 // uiFocusState represents the current focus state of the UI.
 type uiFocusState uint8
 
@@ -64,14 +75,24 @@ const (
 	uiInitialize
 	uiLanding
 	uiChat
-	uiChatCompact
 )
 
 type openEditorMsg struct {
 	Text string
 }
 
-type cancelTimerExpiredMsg struct{}
+type (
+	// cancelTimerExpiredMsg is sent when the cancel timer expires.
+	cancelTimerExpiredMsg struct{}
+	// userCommandsLoadedMsg is sent when user commands are loaded.
+	userCommandsLoadedMsg struct {
+		Commands []commands.CustomCommand
+	}
+	// mcpCustomCommandsLoadedMsg is sent when mcp prompts are loaded.
+	mcpCustomCommandsLoadedMsg struct {
+		Prompts []commands.MCPCustomCommand
+	}
+)
 
 // UI represents the main user interface model.
 type UI struct {
@@ -142,6 +163,20 @@ type UI struct {
 
 	// imgCaps stores the terminal image capabilities.
 	imgCaps timage.Capabilities
+
+	// custom commands & mcp commands
+	customCommands    []commands.CustomCommand
+	mcpCustomCommands []commands.MCPCustomCommand
+
+	// forceCompactMode tracks whether compact mode is forced by user toggle
+	forceCompactMode bool
+
+	// isCompact tracks whether we're currently in compact layout mode (either
+	// by user toggle or auto-switch based on window size)
+	isCompact bool
+
+	// detailsOpen tracks whether the details panel is open (in compact mode)
+	detailsOpen bool
 }
 
 // New creates a new instance of the [UI] model.
@@ -214,6 +249,9 @@ func New(com *common.Common) *UI {
 	ui.textarea.Placeholder = ui.readyPlaceholder
 	ui.status = status
 
+	// Initialize compact mode from config
+	ui.forceCompactMode = com.Config().Options.TUI.CompactMode
+
 	return ui
 }
 
@@ -228,9 +266,37 @@ func (m *UI) Init() tea.Cmd {
 		// sequences.
 		cmds = append(cmds, timage.RequestCapabilities())
 	}
+	// load the user commands async
+	cmds = append(cmds, m.loadCustomCommands())
 	return tea.Batch(cmds...)
 }
 
+// loadCustomCommands loads the custom commands asynchronously.
+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)
+		}
+		return userCommandsLoadedMsg{Commands: customCommands}
+	}
+}
+
+// loadMCPrompts loads the MCP prompts asynchronously.
+func (m *UI) loadMCPrompts() tea.Cmd {
+	return func() tea.Msg {
+		prompts, err := commands.LoadMCPCustomCommands()
+		if err != nil {
+			slog.Error("failed to load mcp prompts", "error", err)
+		}
+		if prompts == nil {
+			// flag them as loaded even if there is none or an error
+			prompts = []commands.MCPCustomCommand{}
+		}
+		return mcpCustomCommandsLoadedMsg{Prompts: prompts}
+	}
+}
+
 // Update handles updates to the UI model.
 func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
@@ -242,6 +308,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case loadSessionMsg:
 		m.state = uiChat
+		if m.forceCompactMode {
+			m.isCompact = true
+		}
 		m.session = msg.session
 		m.sessionFiles = msg.files
 		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
@@ -253,6 +322,29 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, cmd)
 		}
 
+	case userCommandsLoadedMsg:
+		m.customCommands = msg.Commands
+		dia := m.dialog.Dialog(dialog.CommandsID)
+		if dia == nil {
+			break
+		}
+
+		commands, ok := dia.(*dialog.Commands)
+		if ok {
+			commands.SetCustomCommands(m.customCommands)
+		}
+	case mcpCustomCommandsLoadedMsg:
+		m.mcpCustomCommands = msg.Prompts
+		dia := m.dialog.Dialog(dialog.CommandsID)
+		if dia == nil {
+			break
+		}
+
+		commands, ok := dia.(*dialog.Commands)
+		if ok {
+			commands.SetMCPCustomCommands(m.mcpCustomCommands)
+		}
+
 	case pubsub.Event[message.Message]:
 		// Check if this is a child session message for an agent tool.
 		if m.session == nil {
@@ -277,18 +369,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.lspStates = app.GetLSPStates()
 	case pubsub.Event[mcp.Event]:
 		m.mcpStates = mcp.GetStates()
-		if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) {
-			dia := m.dialog.Dialog(dialog.CommandsID)
-			if dia == nil {
+		// check if all mcps are initialized
+		initialized := true
+		for _, state := range m.mcpStates {
+			if state.State == mcp.StateStarting {
+				initialized = false
 				break
 			}
-
-			commands, ok := dia.(*dialog.Commands)
-			if ok {
-				if cmd := commands.ReloadMCPPrompts(); cmd != nil {
-					cmds = append(cmds, cmd)
-				}
-			}
+		}
+		if initialized && m.mcpCustomCommands == nil {
+			cmds = append(cmds, m.loadMCPrompts())
 		}
 	case pubsub.Event[permission.PermissionRequest]:
 		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -307,6 +397,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 	case tea.WindowSizeMsg:
 		m.width, m.height = msg.Width, msg.Height
+		m.handleCompactMode(m.width, m.height)
 		m.updateLayoutAndSize()
 		// XXX: We need to store cell dimensions for image rendering.
 		m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
@@ -789,8 +880,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 	case dialog.ActionToggleHelp:
 		m.status.ToggleHelp()
 		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionToggleCompactMode:
+		cmds = append(cmds, m.toggleCompactMode())
+		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionQuit:
 		cmds = append(cmds, tea.Quit)
+	case dialog.ActionInitializeProject:
+		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+			break
+		}
+		cmds = append(cmds, m.initializeProject())
+
 	case dialog.ActionSelectModel:
 		if m.com.App.AgentCoordinator.IsBusy() {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
@@ -890,6 +991,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				cmds = append(cmds, cmd)
 			}
 			return true
+		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
+			m.detailsOpen = !m.detailsOpen
+			m.updateLayoutAndSize()
+			return true
 		}
 		return false
 	}
@@ -924,7 +1029,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	case uiInitialize:
 		cmds = append(cmds, m.updateInitializeView(msg)...)
 		return tea.Batch(cmds...)
-	case uiChat, uiLanding, uiChatCompact:
+	case uiChat, uiLanding:
 		switch m.focus {
 		case uiFocusEditor:
 			// Handle completions if open.
@@ -1027,6 +1132,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					}
 				}
 
+				// remove the details if they are open when user starts typing
+				if m.detailsOpen {
+					m.detailsOpen = false
+					m.updateLayoutAndSize()
+				}
+
 				ta, cmd := m.textarea.Update(msg)
 				m.textarea = ta
 				cmds = append(cmds, cmd)
@@ -1177,28 +1288,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		editor.Draw(scr, layout.editor)
 
 	case uiChat:
-		m.chat.Draw(scr, layout.main)
+		if m.isCompact {
+			header := uv.NewStyledString(m.header)
+			header.Draw(scr, layout.header)
+		} else {
+			m.drawSidebar(scr, layout.sidebar)
+		}
 
-		header := uv.NewStyledString(m.header)
-		header.Draw(scr, layout.header)
-		m.drawSidebar(scr, layout.sidebar)
+		m.chat.Draw(scr, layout.main)
 
-		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx()))
+		editorWidth := scr.Bounds().Dx()
+		if !m.isCompact {
+			editorWidth -= layout.sidebar.Dx()
+		}
+		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
 		editor.Draw(scr, layout.editor)
 
-	case uiChatCompact:
-		header := uv.NewStyledString(m.header)
-		header.Draw(scr, layout.header)
-
-		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
-			Height(layout.main.Dy()).
-			Background(lipgloss.ANSIColor(rand.Intn(256))).
-			Render(" Compact Chat Messages ")
-		main := uv.NewStyledString(mainView)
-		main.Draw(scr, layout.main)
-
-		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
-		editor.Draw(scr, layout.editor)
+		// Draw details overlay in compact mode when open
+		if m.isCompact && m.detailsOpen {
+			m.drawSessionDetails(scr, layout.sessionDetails)
+		}
 	}
 
 	// Add status and help layer
@@ -1247,6 +1356,10 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 			// Don't show cursor if editor is not visible
 			return nil
 		}
+		if m.detailsOpen && m.isCompact {
+			// Don't show cursor if details overlay is open
+			return nil
+		}
 
 		if m.textarea.Focused() {
 			cur := m.textarea.Cursor()
@@ -1494,6 +1607,36 @@ func (m *UI) FullHelp() [][]key.Binding {
 	return binds
 }
 
+// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
+func (m *UI) toggleCompactMode() tea.Cmd {
+	m.forceCompactMode = !m.forceCompactMode
+
+	err := m.com.Config().SetCompactMode(m.forceCompactMode)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.handleCompactMode(m.width, m.height)
+	m.updateLayoutAndSize()
+
+	return nil
+}
+
+// handleCompactMode updates the UI state based on window size and compact mode setting.
+func (m *UI) handleCompactMode(newWidth, newHeight int) {
+	if m.state == uiChat {
+		if m.forceCompactMode {
+			m.isCompact = true
+			return
+		}
+		if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
+			m.isCompact = true
+		} else {
+			m.isCompact = false
+		}
+	}
+}
+
 // updateLayoutAndSize updates the layout and sizes of UI components.
 func (m *UI) updateLayoutAndSize() {
 	m.layout = m.generateLayout(m.width, m.height)
@@ -1515,11 +1658,11 @@ func (m *UI) updateSize() {
 		m.renderHeader(false, m.layout.header.Dx())
 
 	case uiChat:
-		m.renderSidebarLogo(m.layout.sidebar.Dx())
-
-	case uiChatCompact:
-		// TODO: set the width and heigh of the chat component
-		m.renderHeader(true, m.layout.header.Dx())
+		if m.isCompact {
+			m.renderHeader(true, m.layout.header.Dx())
+		} else {
+			m.renderSidebarLogo(m.layout.sidebar.Dx())
+		}
 	}
 }
 
@@ -1536,8 +1679,7 @@ func (m *UI) generateLayout(w, h int) layout {
 	// The sidebar width
 	sidebarWidth := 30
 	// The header height
-	// TODO: handle compact
-	headerHeight := 4
+	const landingHeaderHeight = 4
 
 	var helpKeyMap help.KeyMap = m
 	if m.status.ShowingAll() {
@@ -1576,7 +1718,7 @@ func (m *UI) generateLayout(w, h int) layout {
 		// ------
 		// help
 
-		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
 		layout.header = headerRect
 		layout.main = mainRect
 
@@ -1590,7 +1732,7 @@ func (m *UI) generateLayout(w, h int) layout {
 		// editor
 		// ------
 		// help
-		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
 		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
 		// Remove extra padding from editor (but keep it for header and main)
 		editorRect.Min.X -= 1
@@ -1600,41 +1742,52 @@ func (m *UI) generateLayout(w, h int) layout {
 		layout.editor = editorRect
 
 	case uiChat:
-		// Layout
-		//
-		// ------|---
-		// main  |
-		// ------| side
-		// editor|
-		// ----------
-		// help
-
-		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
-		// Add padding left
-		sideRect.Min.X += 1
-		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
-		mainRect.Max.X -= 1 // Add padding right
-		// Add bottom margin to main
-		mainRect.Max.Y -= 1
-		layout.sidebar = sideRect
-		layout.main = mainRect
-		layout.editor = editorRect
-
-	case uiChatCompact:
-		// Layout
-		//
-		// compact-header
-		// ------
-		// main
-		// ------
-		// editor
-		// ------
-		// help
-		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
-		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
-		layout.header = headerRect
-		layout.main = mainRect
-		layout.editor = editorRect
+		if m.isCompact {
+			// Layout
+			//
+			// compact-header
+			// ------
+			// main
+			// ------
+			// editor
+			// ------
+			// help
+			const compactHeaderHeight = 1
+			headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
+			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
+			sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
+			layout.sessionDetails = sessionDetailsArea
+			layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
+			// Add one line gap between header and main content
+			mainRect.Min.Y += 1
+			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+			mainRect.Max.X -= 1 // Add padding right
+			// Add bottom margin to main
+			mainRect.Max.Y -= 1
+			layout.header = headerRect
+			layout.main = mainRect
+			layout.editor = editorRect
+		} else {
+			// Layout
+			//
+			// ------|---
+			// main  |
+			// ------| side
+			// editor|
+			// ----------
+			// help
+
+			mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
+			// Add padding left
+			sideRect.Min.X += 1
+			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+			mainRect.Max.X -= 1 // Add padding right
+			// Add bottom margin to main
+			mainRect.Max.Y -= 1
+			layout.sidebar = sideRect
+			layout.main = mainRect
+			layout.editor = editorRect
+		}
 	}
 
 	if !layout.editor.Empty() {
@@ -1668,6 +1821,9 @@ type layout struct {
 
 	// status is the area for the status view.
 	status uv.Rectangle
+
+	// session details is the area for the session details overlay in compact mode.
+	sessionDetails uv.Rectangle
 }
 
 func (m *UI) openEditor(value string) tea.Cmd {
@@ -1873,8 +2029,11 @@ func (m *UI) renderEditorView(width int) string {
 
 // renderHeader renders and caches the header logo at the specified width.
 func (m *UI) renderHeader(compact bool, width int) {
-	// TODO: handle the compact case differently
-	m.header = renderLogo(m.com.Styles, compact, width)
+	if compact && m.session != nil && m.com.App != nil {
+		m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
+	} else {
+		m.header = renderLogo(m.com.Styles, compact, width)
+	}
 }
 
 // renderSidebarLogo renders and caches the sidebar logo at the specified
@@ -1896,8 +2055,13 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
 			return uiutil.ReportError(err)
 		}
 		m.state = uiChat
-		m.session = &newSession
-		cmds = append(cmds, m.loadSession(newSession.ID))
+		if m.forceCompactMode {
+			m.isCompact = true
+		}
+		if newSession.ID != "" {
+			m.session = &newSession
+			cmds = append(cmds, m.loadSession(newSession.ID))
+		}
 	}
 
 	// Capture session ID to avoid race with main goroutine updating m.session.
@@ -2031,7 +2195,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
 		sessionID = m.session.ID
 	}
 
-	commands, err := dialog.NewCommands(m.com, sessionID)
+	commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpCustomCommands)
 	if err != nil {
 		return uiutil.ReportError(err)
 	}
@@ -2228,6 +2392,56 @@ func (m *UI) pasteIdx() int {
 	return result + 1
 }
 
+// drawSessionDetails draws the session details in compact mode.
+func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
+	if m.session == nil {
+		return
+	}
+
+	s := m.com.Styles
+
+	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
+	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
+
+	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
+	blocks := []string{
+		title,
+		"",
+		m.modelInfo(width),
+		"",
+	}
+
+	detailsHeader := lipgloss.JoinVertical(
+		lipgloss.Left,
+		blocks...,
+	)
+
+	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
+
+	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
+
+	const maxSectionWidth = 50
+	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
+	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
+
+	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
+	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
+	filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
+	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
+	uv.NewStyledString(
+		s.CompactDetails.View.
+			Width(area.Dx()).
+			Render(
+				lipgloss.JoinVertical(
+					lipgloss.Left,
+					detailsHeader,
+					sections,
+					version,
+				),
+			),
+	).Draw(scr, area)
+}
+
 // renderLogo renders the Crush logo with the given styles and dimensions.
 func renderLogo(t *styles.Styles, compact bool, width int) string {
 	return logo.Render(version.Version, compact, logo.Opts{

internal/ui/styles/styles.go πŸ”—

@@ -69,9 +69,22 @@ type Styles struct {
 	TagError lipgloss.Style
 	TagInfo  lipgloss.Style
 
-	// Headers
-	HeaderTool       lipgloss.Style
-	HeaderToolNested lipgloss.Style
+	// Header
+	Header struct {
+		Charm        lipgloss.Style // Style for "Charmβ„’" label
+		Diagonals    lipgloss.Style // Style for diagonal separators (β•±)
+		Percentage   lipgloss.Style // Style for context percentage
+		Keystroke    lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d")
+		KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close")
+		WorkingDir   lipgloss.Style // Style for current working directory
+		Separator    lipgloss.Style // Style for separator dots (β€’)
+	}
+
+	CompactDetails struct {
+		View    lipgloss.Style
+		Version lipgloss.Style
+		Title   lipgloss.Style
+	}
 
 	// Panels
 	PanelMuted lipgloss.Style
@@ -997,9 +1010,18 @@ func DefaultStyles() Styles {
 	s.TagError = s.TagBase.Background(redDark)
 	s.TagInfo = s.TagBase.Background(blueLight)
 
-	// headers
-	s.HeaderTool = lipgloss.NewStyle().Foreground(blue)
-	s.HeaderToolNested = lipgloss.NewStyle().Foreground(fgHalfMuted)
+	// Compact header styles
+	s.Header.Charm = base.Foreground(secondary)
+	s.Header.Diagonals = base.Foreground(primary)
+	s.Header.Percentage = s.Muted
+	s.Header.Keystroke = s.Muted
+	s.Header.KeystrokeTip = s.Subtle
+	s.Header.WorkingDir = s.Muted
+	s.Header.Separator = s.Subtle
+
+	s.CompactDetails.Title = s.Base
+	s.CompactDetails.View = s.Base.Padding(0, 1, 1, 1).Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
+	s.CompactDetails.Version = s.Muted
 
 	// panels
 	s.PanelMuted = s.Muted.Background(bgBaseLighter)

internal/uicmd/uicmd.go πŸ”—

@@ -1,6 +1,7 @@
 // Package uicmd provides functionality to load and handle custom commands
 // from markdown files and MCP prompts.
 // TODO: Move this into internal/ui after refactoring.
+// TODO: DELETE when we delete the old tui
 package uicmd
 
 import (