Detailed changes
@@ -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
}
@@ -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
}
@@ -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"),
+ ),
}
}
@@ -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,
- })
+ }
}
}
@@ -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,
),
},
)