on main help sign in
Merge branch 'ui' into ui-picker
adef6e6Ayman Bagabas
internal/commands/commands.gonew file+213
@@ -0,0 +1,213 @@
1+package commands
2+
3+import (
4+ "io/fs"
5+ "os"
6+ "path/filepath"
7+ "regexp"
8+ "strings"
9+
10+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/home"
13+)
14+
15+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
16+
17+const (
18+ userCommandPrefix = "user:"
19+ projectCommandPrefix = "project:"
20+)
21+
22+// Argument represents a command argument with its name and required status.
23+type Argument struct {
24+ Name string
25+ Required bool
26+}
27+
28+// MCPCustomCommand represents a custom command loaded from an MCP server.
29+type MCPCustomCommand struct {
30+ ID string
31+ Name string
32+ Client string
33+ Arguments []Argument
34+}
35+
36+// CustomCommand represents a user-defined custom command loaded from markdown files.
37+type CustomCommand struct {
38+ ID string
39+ Name string
40+ Content string
41+ Arguments []Argument
42+}
43+
44+type commandSource struct {
45+ path string
46+ prefix string
47+}
48+
49+// LoadCustomCommands loads custom commands from multiple sources including
50+// XDG config directory, home directory, and project directory.
51+func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
52+ return loadAll(buildCommandSources(cfg))
53+}
54+
55+// LoadMCPCustomCommands loads custom commands from available MCP servers.
56+func LoadMCPCustomCommands() ([]MCPCustomCommand, error) {
57+ var commands []MCPCustomCommand
58+ for mcpName, prompts := range mcp.Prompts() {
59+ for _, prompt := range prompts {
60+ key := mcpName + ":" + prompt.Name
61+ var args []Argument
62+ for _, arg := range prompt.Arguments {
63+ args = append(args, Argument{Name: arg.Name, Required: arg.Required})
64+ }
65+
66+ commands = append(commands, MCPCustomCommand{
67+ ID: key,
68+ Name: prompt.Name,
69+ Client: mcpName,
70+ Arguments: args,
71+ })
72+ }
73+ }
74+ return commands, nil
75+}
76+
77+func buildCommandSources(cfg *config.Config) []commandSource {
78+ var sources []commandSource
79+
80+ // XDG config directory
81+ if dir := getXDGCommandsDir(); dir != "" {
82+ sources = append(sources, commandSource{
83+ path: dir,
84+ prefix: userCommandPrefix,
85+ })
86+ }
87+
88+ // Home directory
89+ if home := home.Dir(); home != "" {
90+ sources = append(sources, commandSource{
91+ path: filepath.Join(home, ".crush", "commands"),
92+ prefix: userCommandPrefix,
93+ })
94+ }
95+
96+ // Project directory
97+ sources = append(sources, commandSource{
98+ path: filepath.Join(cfg.Options.DataDirectory, "commands"),
99+ prefix: projectCommandPrefix,
100+ })
101+
102+ return sources
103+}
104+
105+func loadAll(sources []commandSource) ([]CustomCommand, error) {
106+ var commands []CustomCommand
107+
108+ for _, source := range sources {
109+ if cmds, err := loadFromSource(source); err == nil {
110+ commands = append(commands, cmds...)
111+ }
112+ }
113+
114+ return commands, nil
115+}
116+
117+func loadFromSource(source commandSource) ([]CustomCommand, error) {
118+ if err := ensureDir(source.path); err != nil {
119+ return nil, err
120+ }
121+
122+ var commands []CustomCommand
123+
124+ err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
125+ if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
126+ return err
127+ }
128+
129+ cmd, err := loadCommand(path, source.path, source.prefix)
130+ if err != nil {
131+ return nil // Skip invalid files
132+ }
133+
134+ commands = append(commands, cmd)
135+ return nil
136+ })
137+
138+ return commands, err
139+}
140+
141+func loadCommand(path, baseDir, prefix string) (CustomCommand, error) {
142+ content, err := os.ReadFile(path)
143+ if err != nil {
144+ return CustomCommand{}, err
145+ }
146+
147+ id := buildCommandID(path, baseDir, prefix)
148+
149+ return CustomCommand{
150+ ID: id,
151+ Name: id,
152+ Content: string(content),
153+ Arguments: extractArgNames(string(content)),
154+ }, nil
155+}
156+
157+func extractArgNames(content string) []Argument {
158+ matches := namedArgPattern.FindAllStringSubmatch(content, -1)
159+ if len(matches) == 0 {
160+ return nil
161+ }
162+
163+ seen := make(map[string]bool)
164+ var args []Argument
165+
166+ for _, match := range matches {
167+ arg := match[1]
168+ if !seen[arg] {
169+ seen[arg] = true
170+ // for normal custom commands, all args are required
171+ args = append(args, Argument{Name: arg, Required: true})
172+ }
173+ }
174+
175+ return args
176+}
177+
178+func buildCommandID(path, baseDir, prefix string) string {
179+ relPath, _ := filepath.Rel(baseDir, path)
180+ parts := strings.Split(relPath, string(filepath.Separator))
181+
182+ // Remove .md extension from last part
183+ if len(parts) > 0 {
184+ lastIdx := len(parts) - 1
185+ parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
186+ }
187+
188+ return prefix + strings.Join(parts, ":")
189+}
190+
191+func getXDGCommandsDir() string {
192+ xdgHome := os.Getenv("XDG_CONFIG_HOME")
193+ if xdgHome == "" {
194+ if home := home.Dir(); home != "" {
195+ xdgHome = filepath.Join(home, ".config")
196+ }
197+ }
198+ if xdgHome != "" {
199+ return filepath.Join(xdgHome, "crush", "commands")
200+ }
201+ return ""
202+}
203+
204+func ensureDir(path string) error {
205+ if _, err := os.Stat(path); os.IsNotExist(err) {
206+ return os.MkdirAll(path, 0o755)
207+ }
208+ return nil
209+}
210+
211+func isMarkdownFile(name string) bool {
212+ return strings.HasSuffix(strings.ToLower(name), ".md")
213+}
internal/ui/dialog/actions.go+20
@@ -8,6 +8,7 @@ import (
8
8
9 tea "charm.land/bubbletea/v2"
9 tea "charm.land/bubbletea/v2"
10 "github.com/charmbracelet/catwalk/pkg/catwalk"
10 "github.com/charmbracelet/catwalk/pkg/catwalk"
11+ "github.com/charmbracelet/crush/internal/commands"
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/permission"
14 "github.com/charmbracelet/crush/internal/permission"
@@ -47,6 +48,8 @@ type (
47 ActionToggleThinking struct{}
48 ActionToggleThinking struct{}
48 ActionExternalEditor struct{}
49 ActionExternalEditor struct{}
49 ActionToggleYoloMode struct{}
50 ActionToggleYoloMode struct{}
51+ // ActionInitializeProject is a message to initialize a project.
52+ ActionInitializeProject struct{}
50 ActionSummarize struct {
53 ActionSummarize struct {
51 SessionID string
54 SessionID string
52 }
55 }
@@ -54,6 +57,23 @@ type (
54 Permission permission.PermissionRequest
57 Permission permission.PermissionRequest
55 Action PermissionAction
58 Action PermissionAction
56 }
59 }
60+ // ActionRunCustomCommand is a message to run a custom command.
61+ ActionRunCustomCommand struct {
62+ CommandID string
63+ // Used when running a user-defined command
64+ Content string
65+ // Used when running a prompt from MCP
66+ Client string
67+ }
68+ // ActionOpenCustomCommandArgumentsDialog is a message to open the custom command arguments dialog.
69+ ActionOpenCustomCommandArgumentsDialog struct {
70+ CommandID string
71+ // Used when running a user-defined command
72+ Content string
73+ // Used when running a prompt from MCP
74+ Client string
75+ Arguments []commands.Argument
76+ }
57)
77)
58
78
59// Messages for API key input dialog.
79// Messages for API key input dialog.
internal/ui/dialog/commands.go+177−209
@@ -1,9 +1,7 @@
1package dialog
1package dialog
2
2
3import (
3import (
4− "fmt"
5 "os"
4 "os"
6− "slices"
7 "strings"
5 "strings"
8
6
9 "charm.land/bubbles/v2/help"
7 "charm.land/bubbles/v2/help"
@@ -12,15 +10,11 @@ import (
12 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
13 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
14 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
15− "github.com/charmbracelet/crush/internal/agent"
13+ "github.com/charmbracelet/crush/internal/commands"
16 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
17− "github.com/charmbracelet/crush/internal/csync"
18− "github.com/charmbracelet/crush/internal/ui/chat"
19 "github.com/charmbracelet/crush/internal/ui/common"
15 "github.com/charmbracelet/crush/internal/ui/common"
20 "github.com/charmbracelet/crush/internal/ui/list"
16 "github.com/charmbracelet/crush/internal/ui/list"
21 "github.com/charmbracelet/crush/internal/ui/styles"
17 "github.com/charmbracelet/crush/internal/ui/styles"
22− "github.com/charmbracelet/crush/internal/uicmd"
23− "github.com/charmbracelet/crush/internal/uiutil"
24 uv "github.com/charmbracelet/ultraviolet"
18 uv "github.com/charmbracelet/ultraviolet"
25 "github.com/charmbracelet/x/ansi"
19 "github.com/charmbracelet/x/ansi"
26)
20)
@@ -28,6 +22,20 @@ import (
28// CommandsID is the identifier for the commands dialog.
22// CommandsID is the identifier for the commands dialog.
29const CommandsID = "commands"
23const CommandsID = "commands"
30
24
25+// CommandType represents the type of commands being displayed.
26+type CommandType uint
27+
28+// String returns the string representation of the CommandType.
29+func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
30+
31+const sidebarCompactModeBreakpoint = 120
32+
33+const (
34+ SystemCommands CommandType = iota
35+ UserCommands
36+ MCPPrompts
37+)
38+
31// Commands represents a dialog that shows available commands.
39// Commands represents a dialog that shows available commands.
32type Commands struct {
40type Commands struct {
33 com *common.Common
41 com *common.Common
@@ -37,39 +45,33 @@ type Commands struct {
37 Next,
45 Next,
38 Previous,
46 Previous,
39 Tab,
47 Tab,
48+ ShiftTab,
40 Close key.Binding
49 Close key.Binding
41 }
50 }
42
51
43− sessionID string // can be empty for non-session-specific commands
52+ sessionID string // can be empty for non-session-specific commands
44− selected uicmd.CommandType
53+ selected CommandType
45− userCmds []uicmd.Command
46− mcpPrompts *csync.Slice[uicmd.Command]
47
54
48 help help.Model
55 help help.Model
49 input textinput.Model
56 input textinput.Model
50 list *list.FilterableList
57 list *list.FilterableList
51
58
52− width int
59+ windowWidth int
60+
61+ customCommands []commands.CustomCommand
62+ mcpCustomCommands []commands.MCPCustomCommand
53}
63}
54
64
55var _ Dialog = (*Commands)(nil)
65var _ Dialog = (*Commands)(nil)
56
66
57// NewCommands creates a new commands dialog.
67// NewCommands creates a new commands dialog.
58−func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
68+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpCustomCommands []commands.MCPCustomCommand) (*Commands, error) {
59− commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
60− if err != nil {
61− return nil, err
62− }
63−
64− mcpPrompts := csync.NewSlice[uicmd.Command]()
65− mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
66−
67 c := &Commands{
69 c := &Commands{
68− com: com,
70+ com: com,
69− userCmds: commands,
70− selected: uicmd.SystemCommands,
71+ selected: SystemCommands,
71− mcpPrompts: mcpPrompts,
72+ sessionID: sessionID,
72− sessionID: sessionID,
73+ customCommands: customCommands,
74+ mcpCustomCommands: mcpCustomCommands,
73 }
75 }
74
76
75 help := help.New()
77 help := help.New()
@@ -96,7 +98,7 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
96 key.WithHelp("↑/↓", "choose"),
98 key.WithHelp("↑/↓", "choose"),
97 )
99 )
98 c.keyMap.Next = key.NewBinding(
100 c.keyMap.Next = key.NewBinding(
99− key.WithKeys("down", "ctrl+n"),
101+ key.WithKeys("down"),
100 key.WithHelp("↓", "next item"),
102 key.WithHelp("↓", "next item"),
101 )
103 )
102 c.keyMap.Previous = key.NewBinding(
104 c.keyMap.Previous = key.NewBinding(
@@ -107,12 +109,16 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
107 key.WithKeys("tab"),
109 key.WithKeys("tab"),
108 key.WithHelp("tab", "switch selection"),
110 key.WithHelp("tab", "switch selection"),
109 )
111 )
112+ c.keyMap.ShiftTab = key.NewBinding(
113+ key.WithKeys("shift+tab"),
114+ key.WithHelp("shift+tab", "switch selection prev"),
115+ )
110 closeKey := CloseKey
116 closeKey := CloseKey
111 closeKey.SetHelp("esc", "cancel")
117 closeKey.SetHelp("esc", "cancel")
112 c.keyMap.Close = closeKey
118 c.keyMap.Close = closeKey
113
119
114 // Set initial commands
120 // Set initial commands
115− c.setCommandType(c.selected)
121+ c.setCommandItems(c.selected)
116
122
117 return c, nil
123 return c, nil
118}
124}
@@ -150,20 +156,28 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
150 case key.Matches(msg, c.keyMap.Select):
156 case key.Matches(msg, c.keyMap.Select):
151 if selectedItem := c.list.SelectedItem(); selectedItem != nil {
157 if selectedItem := c.list.SelectedItem(); selectedItem != nil {
152 if item, ok := selectedItem.(*CommandItem); ok && item != nil {
158 if item, ok := selectedItem.(*CommandItem); ok && item != nil {
153− // TODO: Please unravel this mess later and the Command
154− // Handler design.
155− if cmd := item.Cmd.Handler(item.Cmd); cmd != nil { // Huh??
156− return cmd()
159+ return item.Action()
157− }
158 }
160 }
159 }
161 }
160 case key.Matches(msg, c.keyMap.Tab):
162 case key.Matches(msg, c.keyMap.Tab):
161− if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
163+ if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
162 c.selected = c.nextCommandType()
164 c.selected = c.nextCommandType()
163− c.setCommandType(c.selected)
165+ c.setCommandItems(c.selected)
166+ }
167+ case key.Matches(msg, c.keyMap.ShiftTab):
168+ if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
169+ c.selected = c.previousCommandType()
170+ c.setCommandItems(c.selected)
164 }
171 }
165 default:
172 default:
166 var cmd tea.Cmd
173 var cmd tea.Cmd
174+ for _, item := range c.list.VisibleItems() {
175+ if item, ok := item.(*CommandItem); ok && item != nil {
176+ if msg.String() == item.Shortcut() {
177+ return item.Action()
178+ }
179+ }
180+ }
167 c.input, cmd = c.input.Update(msg)
181 c.input, cmd = c.input.Update(msg)
168 value := c.input.Value()
182 value := c.input.Value()
169 c.list.SetFilter(value)
183 c.list.SetFilter(value)
@@ -175,28 +189,18 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
175 return nil
189 return nil
176}
190}
177
191
178−// ReloadMCPPrompts reloads the MCP prompts.
179−func (c *Commands) ReloadMCPPrompts() tea.Cmd {
180− c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
181− // If we're currently viewing MCP prompts, refresh the list
182− if c.selected == uicmd.MCPPrompts {
183− c.setCommandType(uicmd.MCPPrompts)
184− }
185− return nil
186−}
187−
188// Cursor returns the cursor position relative to the dialog.
192// Cursor returns the cursor position relative to the dialog.
189func (c *Commands) Cursor() *tea.Cursor {
193func (c *Commands) Cursor() *tea.Cursor {
190 return InputCursor(c.com.Styles, c.input.Cursor())
194 return InputCursor(c.com.Styles, c.input.Cursor())
191}
195}
192
196
193// commandsRadioView generates the command type selector radio buttons.
197// commandsRadioView generates the command type selector radio buttons.
194−func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
198+func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
195 if !hasUserCmds && !hasMCPPrompts {
199 if !hasUserCmds && !hasMCPPrompts {
196 return ""
200 return ""
197 }
201 }
198
202
199− selectedFn := func(t uicmd.CommandType) string {
203+ selectedFn := func(t CommandType) string {
200 if t == selected {
204 if t == selected {
201 return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
205 return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
202 }
206 }
@@ -204,14 +208,14 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
204 }
208 }
205
209
206 parts := []string{
210 parts := []string{
207− selectedFn(uicmd.SystemCommands),
211+ selectedFn(SystemCommands),
208 }
212 }
209
213
210 if hasUserCmds {
214 if hasUserCmds {
211− parts = append(parts, selectedFn(uicmd.UserCommands))
215+ parts = append(parts, selectedFn(UserCommands))
212 }
216 }
213 if hasMCPPrompts {
217 if hasMCPPrompts {
214− parts = append(parts, selectedFn(uicmd.MCPPrompts))
218+ parts = append(parts, selectedFn(MCPPrompts))
215 }
219 }
216
220
217 return strings.Join(parts, " ")
221 return strings.Join(parts, " ")
@@ -222,7 +226,12 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
222 t := c.com.Styles
226 t := c.com.Styles
223 width := max(0, min(defaultDialogMaxWidth, area.Dx()))
227 width := max(0, min(defaultDialogMaxWidth, area.Dx()))
224 height := max(0, min(defaultDialogHeight, area.Dy()))
228 height := max(0, min(defaultDialogHeight, area.Dy()))
225− c.width = width
229+ if area.Dx() != c.windowWidth && c.selected == SystemCommands {
230+ c.windowWidth = area.Dx()
231+ // since some items in the list depend on width (e.g. toggle sidebar command),
232+ // we need to reset the command items when width changes
233+ c.setCommandItems(c.selected)
234+ }
226 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
235 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
227 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
236 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
228 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
237 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
@@ -233,7 +242,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
233 c.list.SetSize(innerWidth, height-heightOffset)
242 c.list.SetSize(innerWidth, height-heightOffset)
234 c.help.SetWidth(innerWidth)
243 c.help.SetWidth(innerWidth)
235
244
236− radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
245+ radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpCustomCommands) > 0)
237 titleStyle := t.Dialog.Title
246 titleStyle := t.Dialog.Title
238 dialogStyle := t.Dialog.View.Width(width)
247 dialogStyle := t.Dialog.View.Width(width)
239 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
248 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
@@ -265,99 +274,116 @@ func (c *Commands) FullHelp() [][]key.Binding {
265 }
274 }
266}
275}
267
276
277+// nextCommandType returns the next command type in the cycle.
268−func (c *Commands) nextCommandType() uicmd.CommandType {
278+func (c *Commands) nextCommandType() CommandType {
269 switch c.selected {
279 switch c.selected {
270− case uicmd.SystemCommands:
280+ case SystemCommands:
271− if len(c.userCmds) > 0 {
281+ if len(c.customCommands) > 0 {
272− return uicmd.UserCommands
282+ return UserCommands
273 }
283 }
274− if c.mcpPrompts.Len() > 0 {
284+ if len(c.mcpCustomCommands) > 0 {
275− return uicmd.MCPPrompts
285+ return MCPPrompts
276 }
286 }
277 fallthrough
287 fallthrough
278− case uicmd.UserCommands:
288+ case UserCommands:
279− if c.mcpPrompts.Len() > 0 {
289+ if len(c.mcpCustomCommands) > 0 {
280− return uicmd.MCPPrompts
290+ return MCPPrompts
281 }
291 }
282 fallthrough
292 fallthrough
283− case uicmd.MCPPrompts:
293+ case MCPPrompts:
284− return uicmd.SystemCommands
294+ return SystemCommands
285 default:
295 default:
286− return uicmd.SystemCommands
296+ return SystemCommands
287 }
297 }
288}
298}
289
299
300+// previousCommandType returns the previous command type in the cycle.
290−func (c *Commands) setCommandType(commandType uicmd.CommandType) {
301+func (c *Commands) previousCommandType() CommandType {
291− c.selected = commandType
292−
293− var commands []uicmd.Command
294 switch c.selected {
302 switch c.selected {
295− case uicmd.SystemCommands:
303+ case SystemCommands:
296− commands = c.defaultCommands()
304+ if len(c.mcpCustomCommands) > 0 {
305+ return MCPPrompts
306+ }
307+ if len(c.customCommands) > 0 {
308+ return UserCommands
309+ }
310+ return SystemCommands
297− case uicmd.UserCommands:
311+ case UserCommands:
298− commands = c.userCmds
312+ return SystemCommands
299− case uicmd.MCPPrompts:
313+ case MCPPrompts:
300− commands = slices.Collect(c.mcpPrompts.Seq())
314+ if len(c.customCommands) > 0 {
315+ return UserCommands
316+ }
317+ return SystemCommands
318+ default:
319+ return SystemCommands
301 }
320 }
321+}
322+
323+// setCommandItems sets the command items based on the specified command type.
324+func (c *Commands) setCommandItems(commandType CommandType) {
325+ c.selected = commandType
302
326
303 commandItems := []list.FilterableItem{}
327 commandItems := []list.FilterableItem{}
328+ switch c.selected {
329+ case SystemCommands:
330+ for _, cmd := range c.defaultCommands() {
331+ commandItems = append(commandItems, cmd)
332+ }
333+ case UserCommands:
304− for _, cmd := range commands {
334+ for _, cmd := range c.customCommands {
335+ var action Action
336+ if len(cmd.Arguments) > 0 {
337+ action = ActionOpenCustomCommandArgumentsDialog{
338+ CommandID: cmd.ID,
339+ Content: cmd.Content,
340+ Arguments: cmd.Arguments,
341+ }
342+ } else {
343+ action = ActionRunCustomCommand{
344+ CommandID: cmd.ID,
345+ Content: cmd.Content,
346+ }
347+ }
348+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
349+ }
350+ case MCPPrompts:
351+ for _, cmd := range c.mcpCustomCommands {
352+ var action Action
353+ if len(cmd.Arguments) > 0 {
354+ action = ActionOpenCustomCommandArgumentsDialog{
355+ CommandID: cmd.ID,
356+ Client: cmd.Client,
357+ Arguments: cmd.Arguments,
358+ }
359+ } else {
360+ action = ActionRunCustomCommand{
361+ CommandID: cmd.ID,
362+ Client: cmd.Client,
363+ }
364+ }
305− commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
365+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.Name, "", action))
366+ }
306 }
367 }
307
368
308 c.list.SetItems(commandItems...)
369 c.list.SetItems(commandItems...)
309− c.list.SetSelected(0)
310 c.list.SetFilter("")
370 c.list.SetFilter("")
311 c.list.ScrollToTop()
371 c.list.ScrollToTop()
312 c.list.SetSelected(0)
372 c.list.SetSelected(0)
313 c.input.SetValue("")
373 c.input.SetValue("")
314}
374}
315
375
316−// TODO: Rethink this
376+// defaultCommands returns the list of default system commands.
317−func (c *Commands) defaultCommands() []uicmd.Command {
377+func (c *Commands) defaultCommands() []*CommandItem {
318− commands := []uicmd.Command{
378+ commands := []*CommandItem{
319− {
379+ NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
320− ID: "new_session",
321− Title: "New Session",
322− Description: "start a new session",
323− Shortcut: "ctrl+n",
324− Handler: func(cmd uicmd.Command) tea.Cmd {
325− return uiutil.CmdHandler(ActionNewSession{})
326− },
327− },
328− {
329− ID: "switch_session",
330− Title: "Switch Session",
331− Description: "Switch to a different session",
332− Shortcut: "ctrl+s",
333− Handler: func(cmd uicmd.Command) tea.Cmd {
334− return uiutil.CmdHandler(ActionOpenDialog{SessionsID})
380+ NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
335− },
381+ NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
336− },
337− {
338− ID: "switch_model",
339− Title: "Switch Model",
340− Description: "Switch to a different model",
341− // FIXME: The shortcut might get updated if enhanced keyboard is supported.
342− Shortcut: "ctrl+l",
343− Handler: func(cmd uicmd.Command) tea.Cmd {
344− return uiutil.CmdHandler(ActionOpenDialog{ModelsID})
345− },
346− },
347 }
382 }
348
383
349 // Only show compact command if there's an active session
384 // Only show compact command if there's an active session
350 if c.sessionID != "" {
385 if c.sessionID != "" {
351− commands = append(commands, uicmd.Command{
386+ commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
352− ID: "Summarize",
353− Title: "Summarize Session",
354− Description: "Summarize the current session and create a new one with the summary",
355− Handler: func(cmd uicmd.Command) tea.Cmd {
356− return uiutil.CmdHandler(ActionSummarize{
357− SessionID: c.sessionID,
358− })
359− },
360− })
361 }
387 }
362
388
363 // Add reasoning toggle for models that support it
389 // Add reasoning toggle for models that support it
@@ -374,116 +400,58 @@ func (c *Commands) defaultCommands() []uicmd.Command {
374 if selectedModel.Think {
400 if selectedModel.Think {
375 status = "Disable"
401 status = "Disable"
376 }
402 }
377− commands = append(commands, uicmd.Command{
403+ commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
378− ID: "toggle_thinking",
379− Title: status + " Thinking Mode",
380− Description: "Toggle model thinking for reasoning-capable models",
381− Handler: func(cmd uicmd.Command) tea.Cmd {
382− return uiutil.CmdHandler(ActionToggleThinking{})
383− },
384− })
385 }
404 }
386
405
387 // OpenAI models: reasoning effort dialog
406 // OpenAI models: reasoning effort dialog
388 if len(model.ReasoningLevels) > 0 {
407 if len(model.ReasoningLevels) > 0 {
389− commands = append(commands, uicmd.Command{
408+ commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
390− ID: "select_reasoning_effort",
391− Title: "Select Reasoning Effort",
392− Description: "Choose reasoning effort level (low/medium/high)",
393− Handler: func(cmd uicmd.Command) tea.Cmd {
394− return uiutil.CmdHandler(ActionOpenDialog{
395− // TODO: Pass reasoning dialog id
409+ // TODO: Pass in the reasoning effort dialog id
396− })
397− },
398− })
410+ }))
399 }
411 }
400 }
412 }
401 }
413 }
402− // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
414+ // Only show toggle compact mode command if window width is larger than compact breakpoint (120)
403− // TODO: Get. Rid. Of. Magic. Numbers!
415+ if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
404− if c.width > 120 && c.sessionID != "" {
416+ commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
405− commands = append(commands, uicmd.Command{
406− ID: "toggle_sidebar",
407− Title: "Toggle Sidebar",
408− Description: "Toggle between compact and normal layout",
409− Handler: func(cmd uicmd.Command) tea.Cmd {
410− return uiutil.CmdHandler(ActionToggleCompactMode{})
411− },
412− })
413 }
417 }
414 if c.sessionID != "" {
418 if c.sessionID != "" {
415 cfg := c.com.Config()
419 cfg := c.com.Config()
416 agentCfg := cfg.Agents[config.AgentCoder]
420 agentCfg := cfg.Agents[config.AgentCoder]
417 model := cfg.GetModelByType(agentCfg.Model)
421 model := cfg.GetModelByType(agentCfg.Model)
418 if model != nil && model.SupportsImages {
422 if model != nil && model.SupportsImages {
419− commands = append(commands, uicmd.Command{
423+ commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
420− ID: "file_picker",
421− Title: "Open File Picker",
422− Shortcut: "ctrl+f",
423− Description: "Open file picker",
424− Handler: func(cmd uicmd.Command) tea.Cmd {
425− return uiutil.CmdHandler(ActionOpenDialog{
426− // TODO: Pass file picker dialog id
424+ // TODO: Pass in the file picker dialog id
427− })
428− },
429− })
425+ }))
430 }
426 }
431 }
427 }
432
428
433 // Add external editor command if $EDITOR is available
429 // Add external editor command if $EDITOR is available
434 // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
430 // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
435 if os.Getenv("EDITOR") != "" {
431 if os.Getenv("EDITOR") != "" {
436− commands = append(commands, uicmd.Command{
432+ commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
437− ID: "open_external_editor",
438− Title: "Open External Editor",
439− Shortcut: "ctrl+o",
440− Description: "Open external editor to compose message",
441− Handler: func(cmd uicmd.Command) tea.Cmd {
442− return uiutil.CmdHandler(ActionExternalEditor{})
443− },
444− })
445 }
433 }
446
434
447− return append(commands, []uicmd.Command{
435+ return append(commands,
448− {
436+ NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
449− ID: "toggle_yolo",
437+ NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
450− Title: "Toggle Yolo Mode",
438+ NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
451− Description: "Toggle yolo mode",
439+ NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
452− Handler: func(cmd uicmd.Command) tea.Cmd {
440+ )
453− return uiutil.CmdHandler(ActionToggleYoloMode{})
454− },
455− },
456− {
457− ID: "toggle_help",
458− Title: "Toggle Help",
459− Shortcut: "ctrl+g",
460− Description: "Toggle help",
461− Handler: func(cmd uicmd.Command) tea.Cmd {
462− return uiutil.CmdHandler(ActionToggleHelp{})
463− },
464− },
465− {
466− ID: "init",
467− Title: "Initialize Project",
468− Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
469− Handler: func(cmd uicmd.Command) tea.Cmd {
470− initPrompt, err := agent.InitializePrompt(*c.com.Config())
471− if err != nil {
472− return uiutil.ReportError(err)
473− }
474− return uiutil.CmdHandler(chat.SendMsg{
475− Text: initPrompt,
476− })
477− },
478− },
479− {
480− ID: "quit",
481− Title: "Quit",
482− Description: "Quit",
483− Shortcut: "ctrl+c",
484− Handler: func(cmd uicmd.Command) tea.Cmd {
485− return uiutil.CmdHandler(tea.QuitMsg{})
486− },
441+}
487− },
442+
488− }...)
443+// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
444+func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
445+ c.customCommands = customCommands
446+ if c.selected == UserCommands {
447+ c.setCommandItems(c.selected)
448+ }
449+}
450+
451+// SetMCPCustomCommands sets the MCP custom commands and refreshes the view if MCP prompts are currently displayed.
452+func (c *Commands) SetMCPCustomCommands(mcpCustomCommands []commands.MCPCustomCommand) {
453+ c.mcpCustomCommands = mcpCustomCommands
454+ if c.selected == MCPPrompts {
455+ c.setCommandItems(c.selected)
456+ }
489}
457}
internal/ui/dialog/commands_item.go+27−12
@@ -2,37 +2,42 @@ package dialog
2
2
3import (
3import (
4 "github.com/charmbracelet/crush/internal/ui/styles"
4 "github.com/charmbracelet/crush/internal/ui/styles"
5− "github.com/charmbracelet/crush/internal/uicmd"
6 "github.com/sahilm/fuzzy"
5 "github.com/sahilm/fuzzy"
7)
6)
8
7
9// CommandItem wraps a uicmd.Command to implement the ListItem interface.
8// CommandItem wraps a uicmd.Command to implement the ListItem interface.
10type CommandItem struct {
9type CommandItem struct {
11− Cmd uicmd.Command
10+ id string
11+ title string
12+ shortcut string
13+ action Action
12− t *styles.Styles
14+ t *styles.Styles
13− m fuzzy.Match
15+ m fuzzy.Match
14− cache map[int]string
16+ cache map[int]string
15− focused bool
17+ focused bool
16}
18}
17
19
18var _ ListItem = &CommandItem{}
20var _ ListItem = &CommandItem{}
19
21
20// NewCommandItem creates a new CommandItem.
22// NewCommandItem creates a new CommandItem.
21−func NewCommandItem(t *styles.Styles, cmd uicmd.Command) *CommandItem {
23+func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem {
22 return &CommandItem{
24 return &CommandItem{
23− Cmd: cmd,
25+ id: id,
24− t: t,
26+ t: t,
27+ title: title,
28+ shortcut: shortcut,
29+ action: action,
25 }
30 }
26}
31}
27
32
28// Filter implements ListItem.
33// Filter implements ListItem.
29func (c *CommandItem) Filter() string {
34func (c *CommandItem) Filter() string {
30− return c.Cmd.Title
35+ return c.title
31}
36}
32
37
33// ID implements ListItem.
38// ID implements ListItem.
34func (c *CommandItem) ID() string {
39func (c *CommandItem) ID() string {
35− return c.Cmd.ID
40+ return c.id
36}
41}
37
42
38// SetFocused implements ListItem.
43// SetFocused implements ListItem.
@@ -49,7 +54,17 @@ func (c *CommandItem) SetMatch(m fuzzy.Match) {
49 c.m = m
54 c.m = m
50}
55}
51
56
57+// Action returns the action associated with the command item.
58+func (c *CommandItem) Action() Action {
59+ return c.action
60+}
61+
62+// Shortcut returns the shortcut associated with the command item.
63+func (c *CommandItem) Shortcut() string {
64+ return c.shortcut
65+}
66+
52// Render implements ListItem.
67// Render implements ListItem.
53func (c *CommandItem) Render(width int) string {
68func (c *CommandItem) Render(width int) string {
54− return renderItem(c.t, c.Cmd.Title, c.Cmd.Shortcut, c.focused, width, c.cache, &c.m)
69+ return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
55}
70}
internal/ui/model/header.gonew file+112
@@ -0,0 +1,112 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/csync"
10+ "github.com/charmbracelet/crush/internal/fsext"
11+ "github.com/charmbracelet/crush/internal/lsp"
12+ "github.com/charmbracelet/crush/internal/session"
13+ "github.com/charmbracelet/crush/internal/ui/common"
14+ "github.com/charmbracelet/crush/internal/ui/styles"
15+ "github.com/charmbracelet/x/ansi"
16+)
17+
18+const (
19+ headerDiag = "╱"
20+ minHeaderDiags = 3
21+ leftPadding = 1
22+ rightPadding = 1
23+)
24+
25+// renderCompactHeader renders the compact header for the given session.
26+func renderCompactHeader(
27+ com *common.Common,
28+ session *session.Session,
29+ lspClients *csync.Map[string, *lsp.Client],
30+ detailsOpen bool,
31+ width int,
32+) string {
33+ if session == nil || session.ID == "" {
34+ return ""
35+ }
36+
37+ t := com.Styles
38+
39+ var b strings.Builder
40+
41+ b.WriteString(t.Header.Charm.Render("Charm™"))
42+ b.WriteString(" ")
43+ b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary))
44+ b.WriteString(" ")
45+
46+ availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
47+ details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth)
48+
49+ remainingWidth := width -
50+ lipgloss.Width(b.String()) -
51+ lipgloss.Width(details) -
52+ leftPadding -
53+ rightPadding
54+
55+ if remainingWidth > 0 {
56+ b.WriteString(t.Header.Diagonals.Render(
57+ strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)),
58+ ))
59+ b.WriteString(" ")
60+ }
61+
62+ b.WriteString(details)
63+
64+ return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
65+}
66+
67+// renderHeaderDetails renders the details section of the header.
68+func renderHeaderDetails(
69+ com *common.Common,
70+ session *session.Session,
71+ lspClients *csync.Map[string, *lsp.Client],
72+ detailsOpen bool,
73+ availWidth int,
74+) string {
75+ t := com.Styles
76+
77+ var parts []string
78+
79+ errorCount := 0
80+ for l := range lspClients.Seq() {
81+ errorCount += l.GetDiagnosticCounts().Error
82+ }
83+
84+ if errorCount > 0 {
85+ parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
86+ }
87+
88+ agentCfg := config.Get().Agents[config.AgentCoder]
89+ model := config.Get().GetModelByType(agentCfg.Model)
90+ percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
91+ formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
92+ parts = append(parts, formattedPercentage)
93+
94+ const keystroke = "ctrl+d"
95+ if detailsOpen {
96+ parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close"))
97+ } else {
98+ parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open "))
99+ }
100+
101+ dot := t.Header.Separator.Render(" • ")
102+ metadata := strings.Join(parts, dot)
103+ metadata = dot + metadata
104+
105+ const dirTrimLimit = 4
106+ cfg := com.Config()
107+ cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit)
108+ cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
109+ cwd = t.Header.WorkingDir.Render(cwd)
110+
111+ return cwd + metadata
112+}
internal/ui/model/lsp.go+3
@@ -72,6 +72,9 @@ func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverit
72// lspList renders a list of LSP clients with their status and diagnostics,
72// lspList renders a list of LSP clients with their status and diagnostics,
73// truncating to maxItems if needed.
73// truncating to maxItems if needed.
74func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
74func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
75+ if maxItems <= 0 {
76+ return ""
77+ }
75 var renderedLsps []string
78 var renderedLsps []string
76 for _, l := range lsps {
79 for _, l := range lsps {
77 var icon string
80 var icon string
internal/ui/model/mcp.go+3
@@ -49,6 +49,9 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
49// mcpList renders a list of MCP clients with their status and counts,
49// mcpList renders a list of MCP clients with their status and counts,
50// truncating to maxItems if needed.
50// truncating to maxItems if needed.
51func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
51func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
52+ if maxItems <= 0 {
53+ return ""
54+ }
52 var renderedMcps []string
55 var renderedMcps []string
53
56
54 for _, m := range mcps {
57 for _, m := range mcps {
internal/ui/model/session.go+9−2
@@ -169,9 +169,13 @@ func (m *UI) handleFileEvent(file history.File) tea.Cmd {
169
169
170// filesInfo renders the modified files section for the sidebar, showing files
170// filesInfo renders the modified files section for the sidebar, showing files
171// with their addition/deletion counts.
171// with their addition/deletion counts.
172−func (m *UI) filesInfo(cwd string, width, maxItems int) string {
172+func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
173 t := m.com.Styles
173 t := m.com.Styles
174+
175+ title := t.Subtle.Render("Modified Files")
176+ if isSection {
174− title := common.Section(t, "Modified Files", width)
177+ title = common.Section(t, "Modified Files", width)
178+ }
175 list := t.Subtle.Render("None")
179 list := t.Subtle.Render("None")
176
180
177 if len(m.sessionFiles) > 0 {
181 if len(m.sessionFiles) > 0 {
@@ -184,6 +188,9 @@ func (m *UI) filesInfo(cwd string, width, maxItems int) string {
184// fileList renders a list of files with their diff statistics, truncating to
188// fileList renders a list of files with their diff statistics, truncating to
185// maxItems and showing a "...and N more" message if needed.
189// maxItems and showing a "...and N more" message if needed.
186func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
190func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
191+ if maxItems <= 0 {
192+ return ""
193+ }
187 var renderedFiles []string
194 var renderedFiles []string
188 filesShown := 0
195 filesShown := 0
189
196
internal/ui/model/sidebar.go+1−1
@@ -133,7 +133,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
133
133
134 lspSection := m.lspInfo(width, maxLSPs, true)
134 lspSection := m.lspInfo(width, maxLSPs, true)
135 mcpSection := m.mcpInfo(width, maxMCPs, true)
135 mcpSection := m.mcpInfo(width, maxMCPs, true)
136− filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles)
136+ filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true)
137
137
138 uv.NewStyledString(
138 uv.NewStyledString(
139 lipgloss.NewStyle().
139 lipgloss.NewStyle().
internal/ui/model/ui.go+294−80
@@ -5,6 +5,7 @@ import (
5 "errors"
5 "errors"
6 "fmt"
6 "fmt"
7 "image"
7 "image"
8+ "log/slog"
8 "math/rand"
9 "math/rand"
9 "net/http"
10 "net/http"
10 "os"
11 "os"
@@ -23,6 +24,7 @@ import (
23 "github.com/charmbracelet/catwalk/pkg/catwalk"
24 "github.com/charmbracelet/catwalk/pkg/catwalk"
24 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
25 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
25 "github.com/charmbracelet/crush/internal/app"
26 "github.com/charmbracelet/crush/internal/app"
27+ "github.com/charmbracelet/crush/internal/commands"
26 "github.com/charmbracelet/crush/internal/config"
28 "github.com/charmbracelet/crush/internal/config"
27 "github.com/charmbracelet/crush/internal/filetracker"
29 "github.com/charmbracelet/crush/internal/filetracker"
28 "github.com/charmbracelet/crush/internal/history"
30 "github.com/charmbracelet/crush/internal/history"
@@ -46,6 +48,15 @@ import (
46 "github.com/charmbracelet/x/editor"
48 "github.com/charmbracelet/x/editor"
47)
49)
48
50
51+// Compact mode breakpoints.
52+const (
53+ compactModeWidthBreakpoint = 120
54+ compactModeHeightBreakpoint = 30
55+)
56+
57+// Session details panel max height.
58+const sessionDetailsMaxHeight = 20
59+
49// uiFocusState represents the current focus state of the UI.
60// uiFocusState represents the current focus state of the UI.
50type uiFocusState uint8
61type uiFocusState uint8
51
62
@@ -64,14 +75,24 @@ const (
64 uiInitialize
75 uiInitialize
65 uiLanding
76 uiLanding
66 uiChat
77 uiChat
67− uiChatCompact
68)
78)
69
79
70type openEditorMsg struct {
80type openEditorMsg struct {
71 Text string
81 Text string
72}
82}
73
83
84+type (
85+ // cancelTimerExpiredMsg is sent when the cancel timer expires.
74−type cancelTimerExpiredMsg struct{}
86+ cancelTimerExpiredMsg struct{}
87+ // userCommandsLoadedMsg is sent when user commands are loaded.
88+ userCommandsLoadedMsg struct {
89+ Commands []commands.CustomCommand
90+ }
91+ // mcpCustomCommandsLoadedMsg is sent when mcp prompts are loaded.
92+ mcpCustomCommandsLoadedMsg struct {
93+ Prompts []commands.MCPCustomCommand
94+ }
95+)
75
96
76// UI represents the main user interface model.
97// UI represents the main user interface model.
77type UI struct {
98type UI struct {
@@ -142,6 +163,20 @@ type UI struct {
142
163
143 // imgCaps stores the terminal image capabilities.
164 // imgCaps stores the terminal image capabilities.
144 imgCaps timage.Capabilities
165 imgCaps timage.Capabilities
166+
167+ // custom commands & mcp commands
168+ customCommands []commands.CustomCommand
169+ mcpCustomCommands []commands.MCPCustomCommand
170+
171+ // forceCompactMode tracks whether compact mode is forced by user toggle
172+ forceCompactMode bool
173+
174+ // isCompact tracks whether we're currently in compact layout mode (either
175+ // by user toggle or auto-switch based on window size)
176+ isCompact bool
177+
178+ // detailsOpen tracks whether the details panel is open (in compact mode)
179+ detailsOpen bool
145}
180}
146
181
147// New creates a new instance of the [UI] model.
182// New creates a new instance of the [UI] model.
@@ -214,6 +249,9 @@ func New(com *common.Common) *UI {
214 ui.textarea.Placeholder = ui.readyPlaceholder
249 ui.textarea.Placeholder = ui.readyPlaceholder
215 ui.status = status
250 ui.status = status
216
251
252+ // Initialize compact mode from config
253+ ui.forceCompactMode = com.Config().Options.TUI.CompactMode
254+
217 return ui
255 return ui
218}
256}
219
257
@@ -228,9 +266,37 @@ func (m *UI) Init() tea.Cmd {
228 // sequences.
266 // sequences.
229 cmds = append(cmds, timage.RequestCapabilities())
267 cmds = append(cmds, timage.RequestCapabilities())
230 }
268 }
269+ // load the user commands async
270+ cmds = append(cmds, m.loadCustomCommands())
231 return tea.Batch(cmds...)
271 return tea.Batch(cmds...)
232}
272}
233
273
274+// loadCustomCommands loads the custom commands asynchronously.
275+func (m *UI) loadCustomCommands() tea.Cmd {
276+ return func() tea.Msg {
277+ customCommands, err := commands.LoadCustomCommands(m.com.Config())
278+ if err != nil {
279+ slog.Error("failed to load custom commands", "error", err)
280+ }
281+ return userCommandsLoadedMsg{Commands: customCommands}
282+ }
283+}
284+
285+// loadMCPrompts loads the MCP prompts asynchronously.
286+func (m *UI) loadMCPrompts() tea.Cmd {
287+ return func() tea.Msg {
288+ prompts, err := commands.LoadMCPCustomCommands()
289+ if err != nil {
290+ slog.Error("failed to load mcp prompts", "error", err)
291+ }
292+ if prompts == nil {
293+ // flag them as loaded even if there is none or an error
294+ prompts = []commands.MCPCustomCommand{}
295+ }
296+ return mcpCustomCommandsLoadedMsg{Prompts: prompts}
297+ }
298+}
299+
234// Update handles updates to the UI model.
300// Update handles updates to the UI model.
235func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
301func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
236 var cmds []tea.Cmd
302 var cmds []tea.Cmd
@@ -242,6 +308,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
242 }
308 }
243 case loadSessionMsg:
309 case loadSessionMsg:
244 m.state = uiChat
310 m.state = uiChat
311+ if m.forceCompactMode {
312+ m.isCompact = true
313+ }
245 m.session = msg.session
314 m.session = msg.session
246 m.sessionFiles = msg.files
315 m.sessionFiles = msg.files
247 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
316 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) {
253 cmds = append(cmds, cmd)
322 cmds = append(cmds, cmd)
254 }
323 }
255
324
325+ case userCommandsLoadedMsg:
326+ m.customCommands = msg.Commands
327+ dia := m.dialog.Dialog(dialog.CommandsID)
328+ if dia == nil {
329+ break
330+ }
331+
332+ commands, ok := dia.(*dialog.Commands)
333+ if ok {
334+ commands.SetCustomCommands(m.customCommands)
335+ }
336+ case mcpCustomCommandsLoadedMsg:
337+ m.mcpCustomCommands = msg.Prompts
338+ dia := m.dialog.Dialog(dialog.CommandsID)
339+ if dia == nil {
340+ break
341+ }
342+
343+ commands, ok := dia.(*dialog.Commands)
344+ if ok {
345+ commands.SetMCPCustomCommands(m.mcpCustomCommands)
346+ }
347+
256 case pubsub.Event[message.Message]:
348 case pubsub.Event[message.Message]:
257 // Check if this is a child session message for an agent tool.
349 // Check if this is a child session message for an agent tool.
258 if m.session == nil {
350 if m.session == nil {
@@ -277,18 +369,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
277 m.lspStates = app.GetLSPStates()
369 m.lspStates = app.GetLSPStates()
278 case pubsub.Event[mcp.Event]:
370 case pubsub.Event[mcp.Event]:
279 m.mcpStates = mcp.GetStates()
371 m.mcpStates = mcp.GetStates()
280− if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) {
372+ // check if all mcps are initialized
281− dia := m.dialog.Dialog(dialog.CommandsID)
373+ initialized := true
282− if dia == nil {
374+ for _, state := range m.mcpStates {
375+ if state.State == mcp.StateStarting {
376+ initialized = false
283 break
377 break
284 }
378 }
285−
379+ }
286− commands, ok := dia.(*dialog.Commands)
380+ if initialized && m.mcpCustomCommands == nil {
287− if ok {
288− if cmd := commands.ReloadMCPPrompts(); cmd != nil {
289− cmds = append(cmds, cmd)
381+ cmds = append(cmds, m.loadMCPrompts())
290− }
291− }
292 }
382 }
293 case pubsub.Event[permission.PermissionRequest]:
383 case pubsub.Event[permission.PermissionRequest]:
294 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
384 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -307,6 +397,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
307 return m, nil
397 return m, nil
308 case tea.WindowSizeMsg:
398 case tea.WindowSizeMsg:
309 m.width, m.height = msg.Width, msg.Height
399 m.width, m.height = msg.Width, msg.Height
400+ m.handleCompactMode(m.width, m.height)
310 m.updateLayoutAndSize()
401 m.updateLayoutAndSize()
311 // XXX: We need to store cell dimensions for image rendering.
402 // XXX: We need to store cell dimensions for image rendering.
312 m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
403 m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
@@ -789,8 +880,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
789 case dialog.ActionToggleHelp:
880 case dialog.ActionToggleHelp:
790 m.status.ToggleHelp()
881 m.status.ToggleHelp()
791 m.dialog.CloseDialog(dialog.CommandsID)
882 m.dialog.CloseDialog(dialog.CommandsID)
883+ case dialog.ActionToggleCompactMode:
884+ cmds = append(cmds, m.toggleCompactMode())
885+ m.dialog.CloseDialog(dialog.CommandsID)
792 case dialog.ActionQuit:
886 case dialog.ActionQuit:
793 cmds = append(cmds, tea.Quit)
887 cmds = append(cmds, tea.Quit)
888+ case dialog.ActionInitializeProject:
889+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
890+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
891+ break
892+ }
893+ cmds = append(cmds, m.initializeProject())
894+
794 case dialog.ActionSelectModel:
895 case dialog.ActionSelectModel:
795 if m.com.App.AgentCoordinator.IsBusy() {
896 if m.com.App.AgentCoordinator.IsBusy() {
796 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
897 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
@@ -890,6 +991,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
890 cmds = append(cmds, cmd)
991 cmds = append(cmds, cmd)
891 }
992 }
892 return true
993 return true
994+ case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
995+ m.detailsOpen = !m.detailsOpen
996+ m.updateLayoutAndSize()
997+ return true
893 }
998 }
894 return false
999 return false
895 }
1000 }
@@ -924,7 +1029,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
924 case uiInitialize:
1029 case uiInitialize:
925 cmds = append(cmds, m.updateInitializeView(msg)...)
1030 cmds = append(cmds, m.updateInitializeView(msg)...)
926 return tea.Batch(cmds...)
1031 return tea.Batch(cmds...)
927− case uiChat, uiLanding, uiChatCompact:
1032+ case uiChat, uiLanding:
928 switch m.focus {
1033 switch m.focus {
929 case uiFocusEditor:
1034 case uiFocusEditor:
930 // Handle completions if open.
1035 // Handle completions if open.
@@ -1027,6 +1132,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1027 }
1132 }
1028 }
1133 }
1029
1134
1135+ // remove the details if they are open when user starts typing
1136+ if m.detailsOpen {
1137+ m.detailsOpen = false
1138+ m.updateLayoutAndSize()
1139+ }
1140+
1030 ta, cmd := m.textarea.Update(msg)
1141 ta, cmd := m.textarea.Update(msg)
1031 m.textarea = ta
1142 m.textarea = ta
1032 cmds = append(cmds, cmd)
1143 cmds = append(cmds, cmd)
@@ -1177,28 +1288,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1177 editor.Draw(scr, layout.editor)
1288 editor.Draw(scr, layout.editor)
1178
1289
1179 case uiChat:
1290 case uiChat:
1291+ if m.isCompact {
1292+ header := uv.NewStyledString(m.header)
1180− m.chat.Draw(scr, layout.main)
1293+ header.Draw(scr, layout.header)
1294+ } else {
1295+ m.drawSidebar(scr, layout.sidebar)
1296+ }
1181
1297
1182− header := uv.NewStyledString(m.header)
1183− header.Draw(scr, layout.header)
1298+ m.chat.Draw(scr, layout.main)
1184− m.drawSidebar(scr, layout.sidebar)
1185
1299
1186− editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx()))
1300+ editorWidth := scr.Bounds().Dx()
1301+ if !m.isCompact {
1302+ editorWidth -= layout.sidebar.Dx()
1303+ }
1304+ editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1187 editor.Draw(scr, layout.editor)
1305 editor.Draw(scr, layout.editor)
1188
1306
1189− case uiChatCompact:
1307+ // Draw details overlay in compact mode when open
1190− header := uv.NewStyledString(m.header)
1308+ if m.isCompact && m.detailsOpen {
1191− header.Draw(scr, layout.header)
1309+ m.drawSessionDetails(scr, layout.sessionDetails)
1192−
1310+ }
1193− mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1194− Height(layout.main.Dy()).
1195− Background(lipgloss.ANSIColor(rand.Intn(256))).
1196− Render(" Compact Chat Messages ")
1197− main := uv.NewStyledString(mainView)
1198− main.Draw(scr, layout.main)
1199−
1200− editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1201− editor.Draw(scr, layout.editor)
1202 }
1311 }
1203
1312
1204 // Add status and help layer
1313 // Add status and help layer
@@ -1247,6 +1356,10 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1247 // Don't show cursor if editor is not visible
1356 // Don't show cursor if editor is not visible
1248 return nil
1357 return nil
1249 }
1358 }
1359+ if m.detailsOpen && m.isCompact {
1360+ // Don't show cursor if details overlay is open
1361+ return nil
1362+ }
1250
1363
1251 if m.textarea.Focused() {
1364 if m.textarea.Focused() {
1252 cur := m.textarea.Cursor()
1365 cur := m.textarea.Cursor()
@@ -1494,6 +1607,36 @@ func (m *UI) FullHelp() [][]key.Binding {
1494 return binds
1607 return binds
1495}
1608}
1496
1609
1610+// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1611+func (m *UI) toggleCompactMode() tea.Cmd {
1612+ m.forceCompactMode = !m.forceCompactMode
1613+
1614+ err := m.com.Config().SetCompactMode(m.forceCompactMode)
1615+ if err != nil {
1616+ return uiutil.ReportError(err)
1617+ }
1618+
1619+ m.handleCompactMode(m.width, m.height)
1620+ m.updateLayoutAndSize()
1621+
1622+ return nil
1623+}
1624+
1625+// handleCompactMode updates the UI state based on window size and compact mode setting.
1626+func (m *UI) handleCompactMode(newWidth, newHeight int) {
1627+ if m.state == uiChat {
1628+ if m.forceCompactMode {
1629+ m.isCompact = true
1630+ return
1631+ }
1632+ if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1633+ m.isCompact = true
1634+ } else {
1635+ m.isCompact = false
1636+ }
1637+ }
1638+}
1639+
1497// updateLayoutAndSize updates the layout and sizes of UI components.
1640// updateLayoutAndSize updates the layout and sizes of UI components.
1498func (m *UI) updateLayoutAndSize() {
1641func (m *UI) updateLayoutAndSize() {
1499 m.layout = m.generateLayout(m.width, m.height)
1642 m.layout = m.generateLayout(m.width, m.height)
@@ -1515,11 +1658,11 @@ func (m *UI) updateSize() {
1515 m.renderHeader(false, m.layout.header.Dx())
1658 m.renderHeader(false, m.layout.header.Dx())
1516
1659
1517 case uiChat:
1660 case uiChat:
1518− m.renderSidebarLogo(m.layout.sidebar.Dx())
1661+ if m.isCompact {
1519−
1520− case uiChatCompact:
1521− // TODO: set the width and heigh of the chat component
1522− m.renderHeader(true, m.layout.header.Dx())
1662+ m.renderHeader(true, m.layout.header.Dx())
1663+ } else {
1664+ m.renderSidebarLogo(m.layout.sidebar.Dx())
1665+ }
1523 }
1666 }
1524}
1667}
1525
1668
@@ -1536,8 +1679,7 @@ func (m *UI) generateLayout(w, h int) layout {
1536 // The sidebar width
1679 // The sidebar width
1537 sidebarWidth := 30
1680 sidebarWidth := 30
1538 // The header height
1681 // The header height
1539− // TODO: handle compact
1682+ const landingHeaderHeight = 4
1540− headerHeight := 4
1541
1683
1542 var helpKeyMap help.KeyMap = m
1684 var helpKeyMap help.KeyMap = m
1543 if m.status.ShowingAll() {
1685 if m.status.ShowingAll() {
@@ -1576,7 +1718,7 @@ func (m *UI) generateLayout(w, h int) layout {
1576 // ------
1718 // ------
1577 // help
1719 // help
1578
1720
1579− headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1721+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1580 layout.header = headerRect
1722 layout.header = headerRect
1581 layout.main = mainRect
1723 layout.main = mainRect
1582
1724
@@ -1590,7 +1732,7 @@ func (m *UI) generateLayout(w, h int) layout {
1590 // editor
1732 // editor
1591 // ------
1733 // ------
1592 // help
1734 // help
1593− headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1735+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1594 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1736 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1595 // Remove extra padding from editor (but keep it for header and main)
1737 // Remove extra padding from editor (but keep it for header and main)
1596 editorRect.Min.X -= 1
1738 editorRect.Min.X -= 1
@@ -1600,41 +1742,52 @@ func (m *UI) generateLayout(w, h int) layout {
1600 layout.editor = editorRect
1742 layout.editor = editorRect
1601
1743
1602 case uiChat:
1744 case uiChat:
1745+ if m.isCompact {
1603− // Layout
1746+ // Layout
1604− //
1747+ //
1748+ // compact-header
1749+ // ------
1750+ // main
1751+ // ------
1752+ // editor
1753+ // ------
1754+ // help
1755+ const compactHeaderHeight = 1
1756+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
1757+ detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
1758+ sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
1759+ layout.sessionDetails = sessionDetailsArea
1760+ layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
1761+ // Add one line gap between header and main content
1762+ mainRect.Min.Y += 1
1763+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1764+ mainRect.Max.X -= 1 // Add padding right
1765+ // Add bottom margin to main
1766+ mainRect.Max.Y -= 1
1767+ layout.header = headerRect
1768+ layout.main = mainRect
1769+ layout.editor = editorRect
1770+ } else {
1771+ // Layout
1772+ //
1605− // ------|---
1773+ // ------|---
1606− // main |
1774+ // main |
1607− // ------| side
1775+ // ------| side
1608− // editor|
1776+ // editor|
1609− // ----------
1777+ // ----------
1610− // help
1778+ // help
1611−
1779+
1612− mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1780+ mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1613− // Add padding left
1781+ // Add padding left
1614− sideRect.Min.X += 1
1782+ sideRect.Min.X += 1
1615− mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1783+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1616− mainRect.Max.X -= 1 // Add padding right
1784+ mainRect.Max.X -= 1 // Add padding right
1617− // Add bottom margin to main
1785+ // Add bottom margin to main
1618− mainRect.Max.Y -= 1
1786+ mainRect.Max.Y -= 1
1619− layout.sidebar = sideRect
1787+ layout.sidebar = sideRect
1620− layout.main = mainRect
1788+ layout.main = mainRect
1621− layout.editor = editorRect
1789+ layout.editor = editorRect
1622−
1790+ }
1623− case uiChatCompact:
1624− // Layout
1625− //
1626− // compact-header
1627− // ------
1628− // main
1629− // ------
1630− // editor
1631− // ------
1632− // help
1633− headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
1634− mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1635− layout.header = headerRect
1636− layout.main = mainRect
1637− layout.editor = editorRect
1638 }
1791 }
1639
1792
1640 if !layout.editor.Empty() {
1793 if !layout.editor.Empty() {
@@ -1668,6 +1821,9 @@ type layout struct {
1668
1821
1669 // status is the area for the status view.
1822 // status is the area for the status view.
1670 status uv.Rectangle
1823 status uv.Rectangle
1824+
1825+ // session details is the area for the session details overlay in compact mode.
1826+ sessionDetails uv.Rectangle
1671}
1827}
1672
1828
1673func (m *UI) openEditor(value string) tea.Cmd {
1829func (m *UI) openEditor(value string) tea.Cmd {
@@ -1873,8 +2029,11 @@ func (m *UI) renderEditorView(width int) string {
1873
2029
1874// renderHeader renders and caches the header logo at the specified width.
2030// renderHeader renders and caches the header logo at the specified width.
1875func (m *UI) renderHeader(compact bool, width int) {
2031func (m *UI) renderHeader(compact bool, width int) {
1876− // TODO: handle the compact case differently
2032+ if compact && m.session != nil && m.com.App != nil {
2033+ m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2034+ } else {
1877− m.header = renderLogo(m.com.Styles, compact, width)
2035+ m.header = renderLogo(m.com.Styles, compact, width)
2036+ }
1878}
2037}
1879
2038
1880// renderSidebarLogo renders and caches the sidebar logo at the specified
2039// 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
1896 return uiutil.ReportError(err)
2055 return uiutil.ReportError(err)
1897 }
2056 }
1898 m.state = uiChat
2057 m.state = uiChat
2058+ if m.forceCompactMode {
2059+ m.isCompact = true
2060+ }
2061+ if newSession.ID != "" {
1899− m.session = &newSession
2062+ m.session = &newSession
1900− cmds = append(cmds, m.loadSession(newSession.ID))
2063+ cmds = append(cmds, m.loadSession(newSession.ID))
2064+ }
1901 }
2065 }
1902
2066
1903 // Capture session ID to avoid race with main goroutine updating m.session.
2067 // Capture session ID to avoid race with main goroutine updating m.session.
@@ -2031,7 +2195,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
2031 sessionID = m.session.ID
2195 sessionID = m.session.ID
2032 }
2196 }
2033
2197
2034− commands, err := dialog.NewCommands(m.com, sessionID)
2198+ commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpCustomCommands)
2035 if err != nil {
2199 if err != nil {
2036 return uiutil.ReportError(err)
2200 return uiutil.ReportError(err)
2037 }
2201 }
@@ -2228,6 +2392,56 @@ func (m *UI) pasteIdx() int {
2228 return result + 1
2392 return result + 1
2229}
2393}
2230
2394
2395+// drawSessionDetails draws the session details in compact mode.
2396+func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2397+ if m.session == nil {
2398+ return
2399+ }
2400+
2401+ s := m.com.Styles
2402+
2403+ width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2404+ height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2405+
2406+ title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2407+ blocks := []string{
2408+ title,
2409+ "",
2410+ m.modelInfo(width),
2411+ "",
2412+ }
2413+
2414+ detailsHeader := lipgloss.JoinVertical(
2415+ lipgloss.Left,
2416+ blocks...,
2417+ )
2418+
2419+ version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2420+
2421+ remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2422+
2423+ const maxSectionWidth = 50
2424+ sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2425+ maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
2426+
2427+ lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2428+ mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2429+ filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2430+ sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2431+ uv.NewStyledString(
2432+ s.CompactDetails.View.
2433+ Width(area.Dx()).
2434+ Render(
2435+ lipgloss.JoinVertical(
2436+ lipgloss.Left,
2437+ detailsHeader,
2438+ sections,
2439+ version,
2440+ ),
2441+ ),
2442+ ).Draw(scr, area)
2443+}
2444+
2231// renderLogo renders the Crush logo with the given styles and dimensions.
2445// renderLogo renders the Crush logo with the given styles and dimensions.
2232func renderLogo(t *styles.Styles, compact bool, width int) string {
2446func renderLogo(t *styles.Styles, compact bool, width int) string {
2233 return logo.Render(version.Version, compact, logo.Opts{
2447 return logo.Render(version.Version, compact, logo.Opts{
internal/ui/styles/styles.go+28−6
@@ -69,9 +69,22 @@ type Styles struct {
69 TagError lipgloss.Style
69 TagError lipgloss.Style
70 TagInfo lipgloss.Style
70 TagInfo lipgloss.Style
71
71
72− // Headers
72+ // Header
73+ Header struct {
73− HeaderTool lipgloss.Style
74+ Charm lipgloss.Style // Style for "Charm™" label
75+ Diagonals lipgloss.Style // Style for diagonal separators (╱)
76+ Percentage lipgloss.Style // Style for context percentage
77+ Keystroke lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d")
78+ KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close")
79+ WorkingDir lipgloss.Style // Style for current working directory
80+ Separator lipgloss.Style // Style for separator dots (•)
81+ }
82+
83+ CompactDetails struct {
74− HeaderToolNested lipgloss.Style
84+ View lipgloss.Style
85+ Version lipgloss.Style
86+ Title lipgloss.Style
87+ }
75
88
76 // Panels
89 // Panels
77 PanelMuted lipgloss.Style
90 PanelMuted lipgloss.Style
@@ -997,9 +1010,18 @@ func DefaultStyles() Styles {
997 s.TagError = s.TagBase.Background(redDark)
1010 s.TagError = s.TagBase.Background(redDark)
998 s.TagInfo = s.TagBase.Background(blueLight)
1011 s.TagInfo = s.TagBase.Background(blueLight)
999
1012
1000− // headers
1013+ // Compact header styles
1001− s.HeaderTool = lipgloss.NewStyle().Foreground(blue)
1014+ s.Header.Charm = base.Foreground(secondary)
1002− s.HeaderToolNested = lipgloss.NewStyle().Foreground(fgHalfMuted)
1015+ s.Header.Diagonals = base.Foreground(primary)
1016+ s.Header.Percentage = s.Muted
1017+ s.Header.Keystroke = s.Muted
1018+ s.Header.KeystrokeTip = s.Subtle
1019+ s.Header.WorkingDir = s.Muted
1020+ s.Header.Separator = s.Subtle
1021+
1022+ s.CompactDetails.Title = s.Base
1023+ s.CompactDetails.View = s.Base.Padding(0, 1, 1, 1).Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
1024+ s.CompactDetails.Version = s.Muted
1003
1025
1004 // panels
1026 // panels
1005 s.PanelMuted = s.Muted.Background(bgBaseLighter)
1027 s.PanelMuted = s.Muted.Background(bgBaseLighter)
internal/uicmd/uicmd.go+1
@@ -1,6 +1,7 @@
1// Package uicmd provides functionality to load and handle custom commands
1// Package uicmd provides functionality to load and handle custom commands
2// from markdown files and MCP prompts.
2// from markdown files and MCP prompts.
3// TODO: Move this into internal/ui after refactoring.
3// TODO: Move this into internal/ui after refactoring.
4+// TODO: DELETE when we delete the old tui
4package uicmd
5package uicmd
5
6
6import (
7import (