diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..169b789abd224b774592032c02a5156b91efb3a5 --- /dev/null +++ b/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") +} diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index f03e783ad5a0b7c52a90dbc8f1c94dfc65e647af..29faff93557730bfb0d60bd8dac3dc9bcca84828 100644 --- a/internal/ui/dialog/actions.go +++ b/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. diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 8211016b95fb1e71b5cb64699d2d0fd12930ee84..03707a54775992992a36e90e6857b0f55ce3c8e3 100644 --- a/internal/ui/dialog/commands.go +++ b/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) + } } diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index 408fe70865bfb02ce446c57c32c2b3d79bfd8fe5..9a2cf2ceef2be54c6f8d9897d4ddd923fd07b80f 100644 --- a/internal/ui/dialog/commands_item.go +++ b/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) } diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go new file mode 100644 index 0000000000000000000000000000000000000000..e01a19143c20e0d3e2c6753b719c28092077ac91 --- /dev/null +++ b/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 +} diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index 1f13b5afc3c8a90b6ca14e304636e31fbedddbfc..61e9f75d478ef51daee465ca7eeca109acd6c64b 100644 --- a/internal/ui/model/lsp.go +++ b/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 diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go index 4100907d2c58f4238eb080356a069cf9bd0a2da6..40be8619133268edbc53cf2bee863ed89a2af00f 100644 --- a/internal/ui/model/mcp.go +++ b/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 { diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go index 065a17ad49b7d14092fc6bb868390e522e5eeaa8..38fd718db9cf2b44eb48538a9debb25870b90a7d 100644 --- a/internal/ui/model/session.go +++ b/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 diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 11d7b73baee60fbf68514ae34fb5aeaf459a16d9..c0e46eb31530bc9b9d4f62fbfb020afdd7abc009 100644 --- a/internal/ui/model/sidebar.go +++ b/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(). diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 649a588056b9386af5568ff30b6db2bde0ba5638..201f440d3820d72707800820e4f5c4e8bfa8af25 100644 --- a/internal/ui/model/ui.go +++ b/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{ diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 947b514a2ab0dd3f3737912c46288b100e1bc328..a2d45a3265d170bf545d09b8617ff376be9898b8 100644 --- a/internal/ui/styles/styles.go +++ b/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) diff --git a/internal/uicmd/uicmd.go b/internal/uicmd/uicmd.go index c571dacd1989c518347e3a773b36d6d5fd2b8878..c2ce2d89d1457459ac84c9e97c6e68b371e042d8 100644 --- a/internal/uicmd/uicmd.go +++ b/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 (