Detailed changes
@@ -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")
+}
@@ -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.
@@ -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)
+ }
}
@@ -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)
}
@@ -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
+}
@@ -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
@@ -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 {
@@ -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
@@ -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().
@@ -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{
@@ -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)
@@ -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 (