fix(cmds): small improvements in user commands

Carlos Alexandro Becker created

- ESC now works
- better handling of size events
- properly set input's width
- generic arguments input that will be used in #1209 (in fact this is
  extracted from #1209)
- improved code a bit

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

internal/tui/components/dialogs/commands/arguments.go | 160 +++++++-----
internal/tui/components/dialogs/commands/commands.go  |  78 ++++-
internal/tui/components/dialogs/commands/keys.go      |   5 
internal/tui/components/dialogs/commands/loader.go    |  45 ++-
internal/tui/tui.go                                   |  17 +
5 files changed, 197 insertions(+), 108 deletions(-)

Detailed changes

internal/tui/components/dialogs/commands/arguments.go 🔗

@@ -1,8 +1,7 @@
 package commands
 
 import (
-	"fmt"
-	"strings"
+	"cmp"
 
 	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/key"
@@ -20,9 +19,10 @@ const (
 
 // ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
 type ShowArgumentsDialogMsg struct {
-	CommandID string
-	Content   string
-	ArgNames  []string
+	CommandID   string
+	Description string
+	ArgNames    []string
+	OnSubmit    func(args map[string]string) tea.Cmd
 }
 
 // CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
@@ -39,26 +39,39 @@ type CommandArgumentsDialog interface {
 }
 
 type commandArgumentsDialogCmp struct {
-	width   int
-	wWidth  int // Width of the terminal window
-	wHeight int // Height of the terminal window
-
-	inputs     []textinput.Model
-	focusIndex int
-	keys       ArgumentsDialogKeyMap
-	commandID  string
-	content    string
-	argNames   []string
-	help       help.Model
+	wWidth, wHeight int
+	width, height   int
+
+	inputs    []textinput.Model
+	focused   int
+	keys      ArgumentsDialogKeyMap
+	arguments []Argument
+	help      help.Model
+
+	id          string
+	title       string
+	name        string
+	description string
+
+	onSubmit func(args map[string]string) tea.Cmd
+}
+
+type Argument struct {
+	Name, Title, Description string
+	Required                 bool
 }
 
-func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog {
+func NewCommandArgumentsDialog(
+	id, title, name, description string,
+	arguments []Argument,
+	onSubmit func(args map[string]string) tea.Cmd,
+) CommandArgumentsDialog {
 	t := styles.CurrentTheme()
-	inputs := make([]textinput.Model, len(argNames))
+	inputs := make([]textinput.Model, len(arguments))
 
-	for i, name := range argNames {
+	for i, arg := range arguments {
 		ti := textinput.New()
-		ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
+		ti.Placeholder = cmp.Or(arg.Description, "Enter value for "+arg.Title)
 		ti.SetWidth(40)
 		ti.SetVirtualCursor(false)
 		ti.Prompt = ""
@@ -75,14 +88,16 @@ func NewCommandArgumentsDialog(commandID, content string, argNames []string) Com
 	}
 
 	return &commandArgumentsDialogCmp{
-		inputs:     inputs,
-		keys:       DefaultArgumentsDialogKeyMap(),
-		commandID:  commandID,
-		content:    content,
-		argNames:   argNames,
-		focusIndex: 0,
-		width:      60,
-		help:       help.New(),
+		inputs:      inputs,
+		keys:        DefaultArgumentsDialogKeyMap(),
+		id:          id,
+		name:        name,
+		title:       title,
+		description: description,
+		arguments:   arguments,
+		width:       60,
+		help:        help.New(),
+		onSubmit:    onSubmit,
 	}
 }
 
@@ -97,41 +112,45 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width
 		c.wHeight = msg.Height
+		c.width = min(90, c.wWidth)
+		c.height = min(15, c.wHeight)
+		for i := range c.inputs {
+			c.inputs[i].SetWidth(c.width - (paddingHorizontal * 2))
+		}
 	case tea.KeyPressMsg:
 		switch {
+		case key.Matches(msg, c.keys.Cancel):
+			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
 		case key.Matches(msg, c.keys.Confirm):
-			if c.focusIndex == len(c.inputs)-1 {
-				content := c.content
-				for i, name := range c.argNames {
+			if c.focused == len(c.inputs)-1 {
+				args := make(map[string]string)
+				for i, arg := range c.arguments {
 					value := c.inputs[i].Value()
-					placeholder := "$" + name
-					content = strings.ReplaceAll(content, placeholder, value)
+					args[arg.Name] = value
 				}
 				return c, tea.Sequence(
 					util.CmdHandler(dialogs.CloseDialogMsg{}),
-					util.CmdHandler(CommandRunCustomMsg{
-						Content: content,
-					}),
+					c.onSubmit(args),
 				)
 			}
 			// Otherwise, move to the next input
-			c.inputs[c.focusIndex].Blur()
-			c.focusIndex++
-			c.inputs[c.focusIndex].Focus()
+			c.inputs[c.focused].Blur()
+			c.focused++
+			c.inputs[c.focused].Focus()
 		case key.Matches(msg, c.keys.Next):
 			// Move to the next input
-			c.inputs[c.focusIndex].Blur()
-			c.focusIndex = (c.focusIndex + 1) % len(c.inputs)
-			c.inputs[c.focusIndex].Focus()
+			c.inputs[c.focused].Blur()
+			c.focused = (c.focused + 1) % len(c.inputs)
+			c.inputs[c.focused].Focus()
 		case key.Matches(msg, c.keys.Previous):
 			// Move to the previous input
-			c.inputs[c.focusIndex].Blur()
-			c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
-			c.inputs[c.focusIndex].Focus()
+			c.inputs[c.focused].Blur()
+			c.focused = (c.focused - 1 + len(c.inputs)) % len(c.inputs)
+			c.inputs[c.focused].Focus()
 
 		default:
 			var cmd tea.Cmd
-			c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
+			c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
 			return c, cmd
 		}
 	}
@@ -147,26 +166,28 @@ func (c *commandArgumentsDialogCmp) View() string {
 		Foreground(t.Primary).
 		Bold(true).
 		Padding(0, 1).
-		Render("Command Arguments")
+		Render(cmp.Or(c.title, c.name))
 
-	explanation := t.S().Text.
+	promptName := t.S().Text.
 		Padding(0, 1).
-		Render("This command requires arguments.")
+		Render(c.description)
 
-	// Create input fields for each argument
 	inputFields := make([]string, len(c.inputs))
 	for i, input := range c.inputs {
-		// Highlight the label of the focused input
-		labelStyle := baseStyle.
-			Padding(1, 1, 0, 1)
+		labelStyle := baseStyle.Padding(1, 1, 0, 1)
 
-		if i == c.focusIndex {
+		if i == c.focused {
 			labelStyle = labelStyle.Foreground(t.FgBase).Bold(true)
 		} else {
 			labelStyle = labelStyle.Foreground(t.FgMuted)
 		}
 
-		label := labelStyle.Render(c.argNames[i] + ":")
+		arg := c.arguments[i]
+		argName := cmp.Or(arg.Title, arg.Name)
+		if arg.Required {
+			argName += "*"
+		}
+		label := labelStyle.Render(argName + ":")
 
 		field := t.S().Text.
 			Padding(0, 1).
@@ -175,18 +196,14 @@ func (c *commandArgumentsDialogCmp) View() string {
 		inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
 	}
 
-	// Join all elements vertically
-	elements := []string{title, explanation}
+	elements := []string{title, promptName}
 	elements = append(elements, inputFields...)
 
 	c.help.ShowAll = false
 	helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys))
 	elements = append(elements, "", helpText)
 
-	content := lipgloss.JoinVertical(
-		lipgloss.Left,
-		elements...,
-	)
+	content := lipgloss.JoinVertical(lipgloss.Left, elements...)
 
 	return baseStyle.Padding(1, 1, 0, 1).
 		Border(lipgloss.RoundedBorder()).
@@ -196,26 +213,33 @@ func (c *commandArgumentsDialogCmp) View() string {
 }
 
 func (c *commandArgumentsDialogCmp) Cursor() *tea.Cursor {
-	cursor := c.inputs[c.focusIndex].Cursor()
+	if len(c.inputs) == 0 {
+		return nil
+	}
+	cursor := c.inputs[c.focused].Cursor()
 	if cursor != nil {
 		cursor = c.moveCursor(cursor)
 	}
 	return cursor
 }
 
+const (
+	headerHeight      = 3
+	itemHeight        = 3
+	paddingHorizontal = 3
+)
+
 func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
 	row, col := c.Position()
-	offset := row + 3 + (1+c.focusIndex)*3
+	offset := row + headerHeight + (1+c.focused)*itemHeight
 	cursor.Y += offset
-	cursor.X = cursor.X + col + 3
+	cursor.X = cursor.X + col + paddingHorizontal
 	return cursor
 }
 
 func (c *commandArgumentsDialogCmp) Position() (int, int) {
-	row := c.wHeight / 2
-	row -= c.wHeight / 2
-	col := c.wWidth / 2
-	col -= c.width / 2
+	row := (c.wHeight / 2) - (c.height / 2)
+	col := (c.wWidth / 2) - (c.width / 2)
 	return row, col
 }
 

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -1,7 +1,9 @@
 package commands
 
 import (
+	"context"
 	"os"
+	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/key"
@@ -25,8 +27,12 @@ const (
 	defaultWidth int = 70
 )
 
+type CommandType uint
+
+func (c CommandType) String() string { return []string{"System", "User"}[c] }
+
 const (
-	SystemCommands int = iota
+	SystemCommands CommandType = iota
 	UserCommands
 )
 
@@ -54,9 +60,11 @@ type commandDialogCmp struct {
 	commandList  listModel
 	keyMap       CommandsDialogKeyMap
 	help         help.Model
-	commandType  int       // SystemCommands or UserCommands
-	userCommands []Command // User-defined commands
-	sessionID    string    // Current session ID
+	selected     CommandType // Selected SystemCommands or UserCommands
+	userCommands []Command   // User-defined commands
+	sessionID    string      // Current session ID
+	ctx          context.Context
+	cancel       context.CancelFunc
 }
 
 type (
@@ -102,7 +110,7 @@ func NewCommandDialog(sessionID string) CommandsDialog {
 		width:       defaultWidth,
 		keyMap:      DefaultCommandsDialogKeyMap(),
 		help:        help,
-		commandType: SystemCommands,
+		selected:    SystemCommands,
 		sessionID:   sessionID,
 	}
 }
@@ -113,7 +121,7 @@ func (c *commandDialogCmp) Init() tea.Cmd {
 		return util.ReportError(err)
 	}
 	c.userCommands = commands
-	return c.SetCommandType(c.commandType)
+	return c.setCommandType(c.selected)
 }
 
 func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -122,7 +130,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		c.wWidth = msg.Width
 		c.wHeight = msg.Height
 		return c, tea.Batch(
-			c.SetCommandType(c.commandType),
+			c.setCommandType(c.selected),
 			c.commandList.SetSize(c.listWidth(), c.listHeight()),
 		)
 	case tea.KeyPressMsg:
@@ -133,6 +141,9 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return c, nil // No item selected, do nothing
 			}
 			command := (*selectedItem).Value()
+			if c.cancel != nil {
+				c.cancel()
+			}
 			return c, tea.Sequence(
 				util.CmdHandler(dialogs.CloseDialogMsg{}),
 				command.Handler(command),
@@ -141,13 +152,11 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if len(c.userCommands) == 0 {
 				return c, nil
 			}
-			// Toggle command type between System and User commands
-			if c.commandType == SystemCommands {
-				return c, c.SetCommandType(UserCommands)
-			} else {
-				return c, c.SetCommandType(SystemCommands)
-			}
+			return c, c.setCommandType(c.next())
 		case key.Matches(msg, c.keyMap.Close):
+			if c.cancel != nil {
+				c.cancel()
+			}
 			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
 		default:
 			u, cmd := c.commandList.Update(msg)
@@ -158,6 +167,20 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return c, nil
 }
 
+func (c *commandDialogCmp) next() CommandType {
+	switch c.selected {
+	case SystemCommands:
+		if len(c.userCommands) > 0 {
+			return UserCommands
+		}
+		fallthrough
+	case UserCommands:
+		fallthrough
+	default:
+		return SystemCommands
+	}
+}
+
 func (c *commandDialogCmp) View() string {
 	t := styles.CurrentTheme()
 	listView := c.commandList
@@ -190,26 +213,35 @@ func (c *commandDialogCmp) Cursor() *tea.Cursor {
 
 func (c *commandDialogCmp) commandTypeRadio() string {
 	t := styles.CurrentTheme()
-	choices := []string{"System", "User"}
-	iconSelected := "◉"
-	iconUnselected := "○"
-	if c.commandType == SystemCommands {
-		return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
+
+	fn := func(i CommandType) string {
+		if i == c.selected {
+			return "◉ " + i.String()
+		}
+		return "○ " + i.String()
+	}
+
+	parts := []string{
+		fn(SystemCommands),
+	}
+	if len(c.userCommands) > 0 {
+		parts = append(parts, fn(UserCommands))
 	}
-	return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
+	return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
 }
 
 func (c *commandDialogCmp) listWidth() int {
 	return defaultWidth - 2 // 4 for padding
 }
 
-func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
-	c.commandType = commandType
+func (c *commandDialogCmp) setCommandType(commandType CommandType) tea.Cmd {
+	c.selected = commandType
 
 	var commands []Command
-	if c.commandType == SystemCommands {
+	switch c.selected {
+	case SystemCommands:
 		commands = c.defaultCommands()
-	} else {
+	case UserCommands:
 		commands = c.userCommands
 	}
 

internal/tui/components/dialogs/commands/keys.go 🔗

@@ -76,6 +76,7 @@ type ArgumentsDialogKeyMap struct {
 	Confirm  key.Binding
 	Next     key.Binding
 	Previous key.Binding
+	Cancel   key.Binding
 }
 
 func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
@@ -93,6 +94,10 @@ func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
 			key.WithKeys("shift+tab", "up"),
 			key.WithHelp("shift+tab/↑", "previous"),
 		),
+		Cancel: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
 	}
 }
 

internal/tui/components/dialogs/commands/loader.go 🔗

@@ -15,8 +15,8 @@ import (
 )
 
 const (
-	UserCommandPrefix    = "user:"
-	ProjectCommandPrefix = "project:"
+	userCommandPrefix    = "user:"
+	projectCommandPrefix = "project:"
 )
 
 var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
@@ -50,7 +50,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
 	if dir := getXDGCommandsDir(); dir != "" {
 		sources = append(sources, commandSource{
 			path:   dir,
-			prefix: UserCommandPrefix,
+			prefix: userCommandPrefix,
 		})
 	}
 
@@ -58,14 +58,14 @@ func buildCommandSources(cfg *config.Config) []commandSource {
 	if home := home.Dir(); home != "" {
 		sources = append(sources, commandSource{
 			path:   filepath.Join(home, ".crush", "commands"),
-			prefix: UserCommandPrefix,
+			prefix: userCommandPrefix,
 		})
 	}
 
 	// Project directory
 	sources = append(sources, commandSource{
 		path:   filepath.Join(cfg.Options.DataDirectory, "commands"),
-		prefix: ProjectCommandPrefix,
+		prefix: projectCommandPrefix,
 	})
 
 	return sources
@@ -127,12 +127,13 @@ func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, erro
 	}
 
 	id := buildCommandID(path, baseDir, prefix)
+	desc := fmt.Sprintf("Custom command from %s", filepath.Base(path))
 
 	return Command{
 		ID:          id,
 		Title:       id,
-		Description: fmt.Sprintf("Custom command from %s", filepath.Base(path)),
-		Handler:     createCommandHandler(id, string(content)),
+		Description: desc,
+		Handler:     createCommandHandler(id, desc, string(content)),
 	}, nil
 }
 
@@ -149,21 +150,35 @@ func buildCommandID(path, baseDir, prefix string) string {
 	return prefix + strings.Join(parts, ":")
 }
 
-func createCommandHandler(id string, content string) func(Command) tea.Cmd {
+func createCommandHandler(id, desc, content string) func(Command) tea.Cmd {
 	return func(cmd Command) tea.Cmd {
 		args := extractArgNames(content)
 
-		if len(args) > 0 {
-			return util.CmdHandler(ShowArgumentsDialogMsg{
-				CommandID: id,
-				Content:   content,
-				ArgNames:  args,
+		if len(args) == 0 {
+			return util.CmdHandler(CommandRunCustomMsg{
+				Content: content,
 			})
 		}
+		return util.CmdHandler(ShowArgumentsDialogMsg{
+			CommandID:   id,
+			Description: desc,
+			ArgNames:    args,
+			OnSubmit: func(args map[string]string) tea.Cmd {
+				return execUserPrompt(content, args)
+			},
+		})
+	}
+}
 
-		return util.CmdHandler(CommandRunCustomMsg{
+func execUserPrompt(content string, args map[string]string) tea.Cmd {
+	return func() tea.Msg {
+		for name, value := range args {
+			placeholder := "$" + name
+			content = strings.ReplaceAll(content, placeholder, value)
+		}
+		return CommandRunCustomMsg{
 			Content: content,
-		})
+		}
 	}
 }
 

internal/tui/tui.go 🔗

@@ -34,6 +34,8 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 )
 
 var lastMouseEvent time.Time
@@ -138,12 +140,23 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		a.dialog = u.(dialogs.DialogCmp)
 		return a, tea.Batch(completionCmd, dialogCmd)
 	case commands.ShowArgumentsDialogMsg:
+		var args []commands.Argument
+		for _, arg := range msg.ArgNames {
+			args = append(args, commands.Argument{
+				Name:     arg,
+				Title:    cases.Title(language.English).String(arg),
+				Required: true,
+			})
+		}
 		return a, util.CmdHandler(
 			dialogs.OpenDialogMsg{
 				Model: commands.NewCommandArgumentsDialog(
 					msg.CommandID,
-					msg.Content,
-					msg.ArgNames,
+					msg.CommandID,
+					msg.CommandID,
+					msg.Description,
+					args,
+					msg.OnSubmit,
 				),
 			},
 		)