diff --git a/.gitignore b/.gitignore index 01510713f6c6886781775f35d27d95fa96d3ef2f..008dcff3153d850de53e4e792fb320355f0009ea 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,5 @@ Thumbs.db /tmp/ manpages/ -completions/ -!internal/tui/components/completions/ +completions/crush.*sh .prettierignore diff --git a/CRUSH.md b/AGENTS.md similarity index 96% rename from CRUSH.md rename to AGENTS.md index b98b8813fde6109bd5fdbbc21b1c2f92dee602af..7fab72afb836136020500b7f27e905f3dcfc72da 100644 --- a/CRUSH.md +++ b/AGENTS.md @@ -70,3 +70,6 @@ func TestYourFunction(t *testing.T) { - ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc). - Try to keep commits to one line, not including your attribution. Only use multi-line commits when additional context is truly necessary. + +## Working on the TUI (UI) +Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file diff --git a/go.mod b/go.mod index e7068c241fbc3d5c78c166445b77f240d437ea2b..770cc9e8e1f6374c8552cf618562d2c3ce81f852 100644 --- a/go.mod +++ b/go.mod @@ -31,10 +31,13 @@ require ( github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59 + github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec + github.com/disintegration/imaging v1.6.2 + github.com/dustin/go-humanize v1.0.1 github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 @@ -109,8 +112,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/disintegration/gift v1.1.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 // indirect + github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect diff --git a/go.sum b/go.sum index 8bb193020a32665c5189fa7c16acb9fa4995bb0e..7c720ae03b5354fc3dd148b1033b3efec118999a 100644 --- a/go.sum +++ b/go.sum @@ -122,6 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59 h1:cvP github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= +github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y= +github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY= github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8= github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -150,12 +152,14 @@ github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4G github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg= github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 h1:7LxHj6bTGLfcjjDMZyTH8ZDB8nQrcwoFNr1s4yiWtac= -github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA= +github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= @@ -397,6 +401,7 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index edb2512171348b0c9a1156683ecb398d73657ccf..4146244553b76ba4c0c2967636d7a077b706ee0d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -22,6 +22,8 @@ import ( "github.com/charmbracelet/crush/internal/projects" "github.com/charmbracelet/crush/internal/stringext" "github.com/charmbracelet/crush/internal/tui" + "github.com/charmbracelet/crush/internal/ui/common" + ui "github.com/charmbracelet/crush/internal/ui/model" "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/fang" uv "github.com/charmbracelet/ultraviolet" @@ -86,11 +88,21 @@ crush -y // Set up the TUI. var env uv.Environ = os.Environ() - ui := tui.New(app) - ui.QueryVersion = shouldQueryTerminalVersion(env) + var model tea.Model + if v, _ := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); v { + slog.Info("New UI in control!") + com := common.DefaultCommon(app) + ui := ui.New(com) + ui.QueryVersion = shouldQueryTerminalVersion(env) + model = ui + } else { + ui := tui.New(app) + ui.QueryVersion = shouldQueryTerminalVersion(env) + model = ui + } program := tea.NewProgram( - ui, + model, tea.WithEnvironment(env), tea.WithContext(cmd.Context()), tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..b3fd3915182fa293aefc1fe60ec54e5b369fa591 --- /dev/null +++ b/internal/commands/commands.go @@ -0,0 +1,237 @@ +package commands + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/home" +) + +var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`) + +const ( + userCommandPrefix = "user:" + projectCommandPrefix = "project:" +) + +// Argument represents a command argument with its metadata. +type Argument struct { + ID string + Title string + Description string + Required bool +} + +// MCPPrompt represents a custom command loaded from an MCP server. +type MCPPrompt struct { + ID string + Title string + Description string + PromptID string + ClientID string + Arguments []Argument +} + +// CustomCommand represents a user-defined custom command loaded from markdown files. +type CustomCommand struct { + ID string + Name string + Content string + Arguments []Argument +} + +type commandSource struct { + path string + prefix string +} + +// LoadCustomCommands loads custom commands from multiple sources including +// XDG config directory, home directory, and project directory. +func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) { + return loadAll(buildCommandSources(cfg)) +} + +// LoadMCPPrompts loads custom commands from available MCP servers. +func LoadMCPPrompts() ([]MCPPrompt, error) { + var commands []MCPPrompt + for mcpName, prompts := range mcp.Prompts() { + for _, prompt := range prompts { + key := mcpName + ":" + prompt.Name + var args []Argument + for _, arg := range prompt.Arguments { + title := arg.Title + if title == "" { + title = arg.Name + } + args = append(args, Argument{ + ID: arg.Name, + Title: title, + Description: arg.Description, + Required: arg.Required, + }) + } + commands = append(commands, MCPPrompt{ + ID: key, + Title: prompt.Title, + Description: prompt.Description, + PromptID: prompt.Name, + ClientID: mcpName, + Arguments: args, + }) + } + } + return commands, nil +} + +func buildCommandSources(cfg *config.Config) []commandSource { + var sources []commandSource + + // XDG config directory + if dir := getXDGCommandsDir(); dir != "" { + sources = append(sources, commandSource{ + path: dir, + prefix: userCommandPrefix, + }) + } + + // Home directory + if home := home.Dir(); home != "" { + sources = append(sources, commandSource{ + path: filepath.Join(home, ".crush", "commands"), + prefix: userCommandPrefix, + }) + } + + // Project directory + sources = append(sources, commandSource{ + path: filepath.Join(cfg.Options.DataDirectory, "commands"), + prefix: projectCommandPrefix, + }) + + return sources +} + +func loadAll(sources []commandSource) ([]CustomCommand, error) { + var commands []CustomCommand + + for _, source := range sources { + if cmds, err := loadFromSource(source); err == nil { + commands = append(commands, cmds...) + } + } + + return commands, nil +} + +func loadFromSource(source commandSource) ([]CustomCommand, error) { + if err := ensureDir(source.path); err != nil { + return nil, err + } + + var commands []CustomCommand + + err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) { + return err + } + + cmd, err := loadCommand(path, source.path, source.prefix) + if err != nil { + return nil // Skip invalid files + } + + commands = append(commands, cmd) + return nil + }) + + return commands, err +} + +func loadCommand(path, baseDir, prefix string) (CustomCommand, error) { + content, err := os.ReadFile(path) + if err != nil { + return CustomCommand{}, err + } + + id := buildCommandID(path, baseDir, prefix) + + return CustomCommand{ + ID: id, + Name: id, + Content: string(content), + Arguments: extractArgNames(string(content)), + }, nil +} + +func extractArgNames(content string) []Argument { + matches := namedArgPattern.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return nil + } + + seen := make(map[string]bool) + var args []Argument + + for _, match := range matches { + arg := match[1] + if !seen[arg] { + seen[arg] = true + // for normal custom commands, all args are required + args = append(args, Argument{ID: arg, Title: arg, Required: true}) + } + } + + return args +} + +func buildCommandID(path, baseDir, prefix string) string { + relPath, _ := filepath.Rel(baseDir, path) + parts := strings.Split(relPath, string(filepath.Separator)) + + // Remove .md extension from last part + if len(parts) > 0 { + lastIdx := len(parts) - 1 + parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx])) + } + + return prefix + strings.Join(parts, ":") +} + +func getXDGCommandsDir() string { + xdgHome := os.Getenv("XDG_CONFIG_HOME") + if xdgHome == "" { + if home := home.Dir(); home != "" { + xdgHome = filepath.Join(home, ".config") + } + } + if xdgHome != "" { + return filepath.Join(xdgHome, "crush", "commands") + } + return "" +} + +func ensureDir(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return os.MkdirAll(path, 0o755) + } + return nil +} + +func isMarkdownFile(name string) bool { + return strings.HasSuffix(strings.ToLower(name), ".md") +} + +func GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) { + // TODO: we should pass the context down + result, err := mcp.GetPromptMessages(context.Background(), clientID, promptID, args) + if err != nil { + return "", err + } + return strings.Join(result, " "), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index dcff778d7cdc79494dfcbd54071fd3f2dff7cfc8..f1dd94655a76bded8f5e9071b543fb09f8600e02 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,6 +53,11 @@ var defaultContextPaths = []string{ type SelectedModelType string +// String returns the string representation of the [SelectedModelType]. +func (s SelectedModelType) String() string { + return string(s) +} + const ( SelectedModelTypeLarge SelectedModelType = "large" SelectedModelTypeSmall SelectedModelType = "small" diff --git a/internal/message/attachment.go b/internal/message/attachment.go index b04863f39cc5b266662395344d5227cfa12f4188..c3c04aaea237e9ad060a8687c123a82643edba24 100644 --- a/internal/message/attachment.go +++ b/internal/message/attachment.go @@ -15,7 +15,7 @@ type Attachment struct { func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") } func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") } -// ContainsTextAttachment returns true if any of the attachments is a text attachments. +// ContainsTextAttachment returns true if any of the attachments is a text attachment. func ContainsTextAttachment(attachments []Attachment) bool { return slices.ContainsFunc(attachments, func(a Attachment) bool { return a.IsText() diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..7fce65ce12d69d2d1be0268c9acbd45fd7605851 --- /dev/null +++ b/internal/ui/AGENTS.md @@ -0,0 +1,61 @@ +# UI Development Instructions + +## General Guidelines +- Never use commands to send messages when you can directly mutate children or state. +- Keep things simple; do not overcomplicate. +- Create files if needed to separate logic; do not nest models. +- Always do IO in commands +- Never change the model state inside of a command use messages and than update the state in the main loop + +## Architecture + +### Main Model (`model/ui.go`) +Keep most of the logic and state in the main model. This is where: +- Message routing happens +- Focus and UI state is managed +- Layout calculations are performed +- Dialogs are orchestrated + +### Components Should Be Dumb +Components should not handle bubbletea messages directly. Instead: +- Expose methods for state changes +- Return `tea.Cmd` from methods when side effects are needed +- Handle their own rendering via `Render(width int) string` + +### Chat Logic (`model/chat.go`) +Most chat-related logic belongs here. Individual chat items in `chat/` should be simple renderers that cache their output and invalidate when data changes (see `cachedMessageItem` in `chat/messages.go`). + +## Key Patterns + +### Composition Over Inheritance +Use struct embedding for shared behaviors. See `chat/messages.go` for examples of reusable embedded structs for highlighting, caching, and focus. + +### Interfaces +- List item interfaces are in `list/item.go` +- Chat message interfaces are in `chat/messages.go` +- Dialog interface is in `dialog/dialog.go` + +### Styling +- All styles are defined in `styles/styles.go` +- Access styles via `*common.Common` passed to components +- Use semantic color fields rather than hardcoded colors + +### Dialogs +- Implement the dialog interface in `dialog/dialog.go` +- Return message types from `Update()` to signal actions to the main model +- Use the overlay system for managing dialog lifecycle + +## File Organization +- `model/` - Main UI model and major components (chat, sidebar, etc.) +- `chat/` - Chat message item types and renderers +- `dialog/` - Dialog implementations +- `list/` - Generic list component with lazy rendering +- `common/` - Shared utilities and the Common struct +- `styles/` - All style definitions +- `anim/` - Animation system +- `logo/` - Logo rendering + +## Common Gotchas +- Always account for padding/borders in width calculations +- Use `tea.Batch()` when returning multiple commands +- Pass `*common.Common` to components that need styles or app access diff --git a/internal/ui/anim/anim.go b/internal/ui/anim/anim.go new file mode 100644 index 0000000000000000000000000000000000000000..3e159b102324a68bb93b8f9cbd3e128bf60dcf0f --- /dev/null +++ b/internal/ui/anim/anim.go @@ -0,0 +1,445 @@ +// Package anim provides an animated spinner. +package anim + +import ( + "fmt" + "image/color" + "math/rand/v2" + "strings" + "sync/atomic" + "time" + + "github.com/zeebo/xxh3" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/lucasb-eyer/go-colorful" + + "github.com/charmbracelet/crush/internal/csync" +) + +const ( + fps = 20 + initialChar = '.' + labelGap = " " + labelGapWidth = 1 + + // Periods of ellipsis animation speed in steps. + // + // If the FPS is 20 (50 milliseconds) this means that the ellipsis will + // change every 8 frames (400 milliseconds). + ellipsisAnimSpeed = 8 + + // The maximum amount of time that can pass before a character appears. + // This is used to create a staggered entrance effect. + maxBirthOffset = time.Second + + // Number of frames to prerender for the animation. After this number + // of frames, the animation will loop. This only applies when color + // cycling is disabled. + prerenderedFrames = 10 + + // Default number of cycling chars. + defaultNumCyclingChars = 10 +) + +// Default colors for gradient. +var ( + defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff} + defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff} + defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff} +) + +var ( + availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_") + ellipsisFrames = []string{".", "..", "...", ""} +) + +// Internal ID management. Used during animating to ensure that frame messages +// are received only by spinner components that sent them. +var lastID int64 + +func nextID() int { + return int(atomic.AddInt64(&lastID, 1)) +} + +// Cache for expensive animation calculations +type animCache struct { + initialFrames [][]string + cyclingFrames [][]string + width int + labelWidth int + label []string + ellipsisFrames []string +} + +var animCacheMap = csync.NewMap[string, *animCache]() + +// settingsHash creates a hash key for the settings to use for caching +func settingsHash(opts Settings) string { + h := xxh3.New() + fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t", + opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// StepMsg is a message type used to trigger the next step in the animation. +type StepMsg struct{ ID string } + +// Settings defines settings for the animation. +type Settings struct { + ID string + Size int + Label string + LabelColor color.Color + GradColorA color.Color + GradColorB color.Color + CycleColors bool +} + +// Default settings. +const () + +// Anim is a Bubble for an animated spinner. +type Anim struct { + width int + cyclingCharWidth int + label *csync.Slice[string] + labelWidth int + labelColor color.Color + startTime time.Time + birthOffsets []time.Duration + initialFrames [][]string // frames for the initial characters + initialized atomic.Bool + cyclingFrames [][]string // frames for the cycling characters + step atomic.Int64 // current main frame step + ellipsisStep atomic.Int64 // current ellipsis frame step + ellipsisFrames *csync.Slice[string] // ellipsis animation frames + id string +} + +// New creates a new Anim instance with the specified width and label. +func New(opts Settings) *Anim { + a := &Anim{} + // Validate settings. + if opts.Size < 1 { + opts.Size = defaultNumCyclingChars + } + if colorIsUnset(opts.GradColorA) { + opts.GradColorA = defaultGradColorA + } + if colorIsUnset(opts.GradColorB) { + opts.GradColorB = defaultGradColorB + } + if colorIsUnset(opts.LabelColor) { + opts.LabelColor = defaultLabelColor + } + + if opts.ID != "" { + a.id = opts.ID + } else { + a.id = fmt.Sprintf("%d", nextID()) + } + a.startTime = time.Now() + a.cyclingCharWidth = opts.Size + a.labelColor = opts.LabelColor + + // Check cache first + cacheKey := settingsHash(opts) + cached, exists := animCacheMap.Get(cacheKey) + + if exists { + // Use cached values + a.width = cached.width + a.labelWidth = cached.labelWidth + a.label = csync.NewSliceFrom(cached.label) + a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames) + a.initialFrames = cached.initialFrames + a.cyclingFrames = cached.cyclingFrames + } else { + // Generate new values and cache them + a.labelWidth = lipgloss.Width(opts.Label) + + // Total width of anim, in cells. + a.width = opts.Size + if opts.Label != "" { + a.width += labelGapWidth + lipgloss.Width(opts.Label) + } + + // Render the label + a.renderLabel(opts.Label) + + // Pre-generate gradient. + var ramp []color.Color + numFrames := prerenderedFrames + if opts.CycleColors { + ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB) + numFrames = a.width * 2 + } else { + ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB) + } + + // Pre-render initial characters. + a.initialFrames = make([][]string, numFrames) + offset := 0 + for i := range a.initialFrames { + a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth) + for j := range a.initialFrames[i] { + if j+offset >= len(ramp) { + continue // skip if we run out of colors + } + + var c color.Color + if j <= a.cyclingCharWidth { + c = ramp[j+offset] + } else { + c = opts.LabelColor + } + + // Also prerender the initial character with Lip Gloss to avoid + // processing in the render loop. + a.initialFrames[i][j] = lipgloss.NewStyle(). + Foreground(c). + Render(string(initialChar)) + } + if opts.CycleColors { + offset++ + } + } + + // Prerender scrambled rune frames for the animation. + a.cyclingFrames = make([][]string, numFrames) + offset = 0 + for i := range a.cyclingFrames { + a.cyclingFrames[i] = make([]string, a.width) + for j := range a.cyclingFrames[i] { + if j+offset >= len(ramp) { + continue // skip if we run out of colors + } + + // Also prerender the color with Lip Gloss here to avoid processing + // in the render loop. + r := availableRunes[rand.IntN(len(availableRunes))] + a.cyclingFrames[i][j] = lipgloss.NewStyle(). + Foreground(ramp[j+offset]). + Render(string(r)) + } + if opts.CycleColors { + offset++ + } + } + + // Cache the results + labelSlice := make([]string, a.label.Len()) + for i, v := range a.label.Seq2() { + labelSlice[i] = v + } + ellipsisSlice := make([]string, a.ellipsisFrames.Len()) + for i, v := range a.ellipsisFrames.Seq2() { + ellipsisSlice[i] = v + } + cached = &animCache{ + initialFrames: a.initialFrames, + cyclingFrames: a.cyclingFrames, + width: a.width, + labelWidth: a.labelWidth, + label: labelSlice, + ellipsisFrames: ellipsisSlice, + } + animCacheMap.Set(cacheKey, cached) + } + + // Random assign a birth to each character for a stagged entrance effect. + a.birthOffsets = make([]time.Duration, a.width) + for i := range a.birthOffsets { + a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond + } + + return a +} + +// SetLabel updates the label text and re-renders it. +func (a *Anim) SetLabel(newLabel string) { + a.labelWidth = lipgloss.Width(newLabel) + + // Update total width + a.width = a.cyclingCharWidth + if newLabel != "" { + a.width += labelGapWidth + a.labelWidth + } + + // Re-render the label + a.renderLabel(newLabel) +} + +// renderLabel renders the label with the current label color. +func (a *Anim) renderLabel(label string) { + if a.labelWidth > 0 { + // Pre-render the label. + labelRunes := []rune(label) + a.label = csync.NewSlice[string]() + for i := range labelRunes { + rendered := lipgloss.NewStyle(). + Foreground(a.labelColor). + Render(string(labelRunes[i])) + a.label.Append(rendered) + } + + // Pre-render the ellipsis frames which come after the label. + a.ellipsisFrames = csync.NewSlice[string]() + for _, frame := range ellipsisFrames { + rendered := lipgloss.NewStyle(). + Foreground(a.labelColor). + Render(frame) + a.ellipsisFrames.Append(rendered) + } + } else { + a.label = csync.NewSlice[string]() + a.ellipsisFrames = csync.NewSlice[string]() + } +} + +// Width returns the total width of the animation. +func (a *Anim) Width() (w int) { + w = a.width + if a.labelWidth > 0 { + w += labelGapWidth + a.labelWidth + + var widestEllipsisFrame int + for _, f := range ellipsisFrames { + fw := lipgloss.Width(f) + if fw > widestEllipsisFrame { + widestEllipsisFrame = fw + } + } + w += widestEllipsisFrame + } + return w +} + +// Start starts the animation. +func (a *Anim) Start() tea.Cmd { + return a.Step() +} + +// Animate advances the animation to the next step. +func (a *Anim) Animate(msg StepMsg) tea.Cmd { + if msg.ID != a.id { + return nil + } + + step := a.step.Add(1) + if int(step) >= len(a.cyclingFrames) { + a.step.Store(0) + } + + if a.initialized.Load() && a.labelWidth > 0 { + // Manage the ellipsis animation. + ellipsisStep := a.ellipsisStep.Add(1) + if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) { + a.ellipsisStep.Store(0) + } + } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset { + a.initialized.Store(true) + } + return a.Step() +} + +// Render renders the current state of the animation. +func (a *Anim) Render() string { + var b strings.Builder + step := int(a.step.Load()) + for i := range a.width { + switch { + case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]: + // Birth offset not reached: render initial character. + b.WriteString(a.initialFrames[step][i]) + case i < a.cyclingCharWidth: + // Render a cycling character. + b.WriteString(a.cyclingFrames[step][i]) + case i == a.cyclingCharWidth: + // Render label gap. + b.WriteString(labelGap) + case i > a.cyclingCharWidth: + // Label. + if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok { + b.WriteString(labelChar) + } + } + } + // Render animated ellipsis at the end of the label if all characters + // have been initialized. + if a.initialized.Load() && a.labelWidth > 0 { + ellipsisStep := int(a.ellipsisStep.Load()) + if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok { + b.WriteString(ellipsisFrame) + } + } + + return b.String() +} + +// Step is a command that triggers the next step in the animation. +func (a *Anim) Step() tea.Cmd { + return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg { + return StepMsg{ID: a.id} + }) +} + +// makeGradientRamp() returns a slice of colors blended between the given keys. +// Blending is done as Hcl to stay in gamut. +func makeGradientRamp(size int, stops ...color.Color) []color.Color { + if len(stops) < 2 { + return nil + } + + points := make([]colorful.Color, len(stops)) + for i, k := range stops { + points[i], _ = colorful.MakeColor(k) + } + + numSegments := len(stops) - 1 + if numSegments == 0 { + return nil + } + blended := make([]color.Color, 0, size) + + // Calculate how many colors each segment should have. + segmentSizes := make([]int, numSegments) + baseSize := size / numSegments + remainder := size % numSegments + + // Distribute the remainder across segments. + for i := range numSegments { + segmentSizes[i] = baseSize + if i < remainder { + segmentSizes[i]++ + } + } + + // Generate colors for each segment. + for i := range numSegments { + c1 := points[i] + c2 := points[i+1] + segmentSize := segmentSizes[i] + + for j := range segmentSize { + if segmentSize == 0 { + continue + } + t := float64(j) / float64(segmentSize) + c := c1.BlendHcl(c2, t) + blended = append(blended, c) + } + } + + return blended +} + +func colorIsUnset(c color.Color) bool { + if c == nil { + return true + } + _, _, _, a := c.RGBA() + return a == 0 +} diff --git a/internal/ui/attachments/attachments.go b/internal/ui/attachments/attachments.go new file mode 100644 index 0000000000000000000000000000000000000000..558c7576ee1edb3756be3dc7b4ccfcb89a5597b7 --- /dev/null +++ b/internal/ui/attachments/attachments.go @@ -0,0 +1,135 @@ +package attachments + +import ( + "fmt" + "math" + "path/filepath" + "slices" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/x/ansi" +) + +const maxFilename = 15 + +type Keymap struct { + DeleteMode, + DeleteAll, + Escape key.Binding +} + +func New(renderer *Renderer, keyMap Keymap) *Attachments { + return &Attachments{ + keyMap: keyMap, + renderer: renderer, + } +} + +type Attachments struct { + renderer *Renderer + keyMap Keymap + list []message.Attachment + deleting bool +} + +func (m *Attachments) List() []message.Attachment { return m.list } +func (m *Attachments) Reset() { m.list = nil } + +func (m *Attachments) Update(msg tea.Msg) bool { + switch msg := msg.(type) { + case message.Attachment: + m.list = append(m.list, msg) + return true + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.DeleteMode): + if len(m.list) > 0 { + m.deleting = true + } + return true + case m.deleting && key.Matches(msg, m.keyMap.Escape): + m.deleting = false + return true + case m.deleting && key.Matches(msg, m.keyMap.DeleteAll): + m.deleting = false + m.list = nil + return true + case m.deleting: + // Handle digit keys for individual attachment deletion. + r := msg.Code + if r >= '0' && r <= '9' { + num := int(r - '0') + if num < len(m.list) { + m.list = slices.Delete(m.list, num, num+1) + } + m.deleting = false + } + return true + } + } + return false +} + +func (m *Attachments) Render(width int) string { + return m.renderer.Render(m.list, m.deleting, width) +} + +func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer { + return &Renderer{ + normalStyle: normalStyle, + textStyle: textStyle, + imageStyle: imageStyle, + deletingStyle: deletingStyle, + } +} + +type Renderer struct { + normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style +} + +func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string { + var chips []string + + maxItemWidth := lipgloss.Width(r.imageStyle.String() + r.normalStyle.Render(strings.Repeat("x", maxFilename))) + fits := int(math.Floor(float64(width)/float64(maxItemWidth))) - 1 + + for i, att := range attachments { + filename := filepath.Base(att.FileName) + // Truncate if needed. + if ansi.StringWidth(filename) > maxFilename { + filename = ansi.Truncate(filename, maxFilename, "…") + } + + if deleting { + chips = append( + chips, + r.deletingStyle.Render(fmt.Sprintf("%d", i)), + r.normalStyle.Render(filename), + ) + } else { + chips = append( + chips, + r.icon(att).String(), + r.normalStyle.Render(filename), + ) + } + + if i == fits && len(attachments) > i { + chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits))) + break + } + } + + return lipgloss.JoinHorizontal(lipgloss.Left, chips...) +} + +func (r *Renderer) icon(a message.Attachment) lipgloss.Style { + if a.IsImage() { + return r.imageStyle + } + return r.textStyle +} diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go new file mode 100644 index 0000000000000000000000000000000000000000..c2a439ff23d0bd046b75076ea30de68b60cdcc54 --- /dev/null +++ b/internal/ui/chat/agent.go @@ -0,0 +1,302 @@ +package chat + +import ( + "encoding/json" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/tree" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Agent Tool +// ----------------------------------------------------------------------------- + +// NestedToolContainer is an interface for tool items that can contain nested tool calls. +type NestedToolContainer interface { + NestedTools() []ToolMessageItem + SetNestedTools(tools []ToolMessageItem) + AddNestedTool(tool ToolMessageItem) +} + +// AgentToolMessageItem is a message item that represents an agent tool call. +type AgentToolMessageItem struct { + *baseToolMessageItem + + nestedTools []ToolMessageItem +} + +var ( + _ ToolMessageItem = (*AgentToolMessageItem)(nil) + _ NestedToolContainer = (*AgentToolMessageItem)(nil) +) + +// NewAgentToolMessageItem creates a new [AgentToolMessageItem]. +func NewAgentToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) *AgentToolMessageItem { + t := &AgentToolMessageItem{} + t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled) + // For the agent tool we keep spinning until the tool call is finished. + t.spinningFunc = func(state SpinningState) bool { + return !state.HasResult() && !state.IsCanceled() + } + return t +} + +// Animate progresses the message animation if it should be spinning. +func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { + if a.result != nil || a.Status() == ToolStatusCanceled { + return nil + } + if msg.ID == a.ID() { + return a.anim.Animate(msg) + } + for _, nestedTool := range a.nestedTools { + if msg.ID != nestedTool.ID() { + continue + } + if s, ok := nestedTool.(Animatable); ok { + return s.Animate(msg) + } + } + return nil +} + +// NestedTools returns the nested tools. +func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem { + return a.nestedTools +} + +// SetNestedTools sets the nested tools. +func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) { + a.nestedTools = tools + a.clearCache() +} + +// AddNestedTool adds a nested tool. +func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) { + // Mark nested tools as simple (compact) rendering. + if s, ok := tool.(Compactable); ok { + s.SetCompact(true) + } + a.nestedTools = append(a.nestedTools, tool) + a.clearCache() +} + +// AgentToolRenderContext renders agent tool messages. +type AgentToolRenderContext struct { + agent *AgentToolMessageItem +} + +// RenderTool implements the [ToolRenderer] interface. +func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 { + return pendingTool(sty, "Agent", opts.Anim) + } + + var params agent.AgentParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + prompt := params.Prompt + prompt = strings.ReplaceAll(prompt, "\n", " ") + + header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact) + if opts.Compact { + return header + } + + // Build the task tag and prompt. + taskTag := sty.Tool.AgentTaskTag.Render("Task") + taskTagWidth := lipgloss.Width(taskTag) + + // Calculate remaining width for prompt. + remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing + + promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) + + header = lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + lipgloss.JoinHorizontal( + lipgloss.Left, + taskTag, + " ", + promptText, + ), + ) + + // Build tree with nested tool calls. + childTools := tree.Root(header) + + for _, nestedTool := range r.agent.nestedTools { + childView := nestedTool.Render(remainingWidth) + childTools.Child(childView) + } + + // Build parts. + var parts []string + parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String()) + + // Show animation if still running. + if !opts.HasResult() && !opts.IsCanceled() { + parts = append(parts, "", opts.Anim.Render()) + } + + result := lipgloss.JoinVertical(lipgloss.Left, parts...) + + // Add body content when completed. + if opts.HasResult() && opts.Result.Content != "" { + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) + return joinToolParts(result, body) + } + + return result +} + +// ----------------------------------------------------------------------------- +// Agentic Fetch Tool +// ----------------------------------------------------------------------------- + +// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call. +type AgenticFetchToolMessageItem struct { + *baseToolMessageItem + + nestedTools []ToolMessageItem +} + +var ( + _ ToolMessageItem = (*AgenticFetchToolMessageItem)(nil) + _ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil) +) + +// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem]. +func NewAgenticFetchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) *AgenticFetchToolMessageItem { + t := &AgenticFetchToolMessageItem{} + t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled) + // For the agentic fetch tool we keep spinning until the tool call is finished. + t.spinningFunc = func(state SpinningState) bool { + return !state.HasResult() && !state.IsCanceled() + } + return t +} + +// NestedTools returns the nested tools. +func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem { + return a.nestedTools +} + +// SetNestedTools sets the nested tools. +func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) { + a.nestedTools = tools + a.clearCache() +} + +// AddNestedTool adds a nested tool. +func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) { + // Mark nested tools as simple (compact) rendering. + if s, ok := tool.(Compactable); ok { + s.SetCompact(true) + } + a.nestedTools = append(a.nestedTools, tool) + a.clearCache() +} + +// AgenticFetchToolRenderContext renders agentic fetch tool messages. +type AgenticFetchToolRenderContext struct { + fetch *AgenticFetchToolMessageItem +} + +// agenticFetchParams matches tools.AgenticFetchParams. +type agenticFetchParams struct { + URL string `json:"url,omitempty"` + Prompt string `json:"prompt"` +} + +// RenderTool implements the [ToolRenderer] interface. +func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 { + return pendingTool(sty, "Agentic Fetch", opts.Anim) + } + + var params agenticFetchParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + prompt := params.Prompt + prompt = strings.ReplaceAll(prompt, "\n", " ") + + // Build header with optional URL param. + toolParams := []string{} + if params.URL != "" { + toolParams = append(toolParams, params.URL) + } + + header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + // Build the prompt tag. + promptTag := sty.Tool.AgenticFetchPromptTag.Render("Prompt") + promptTagWidth := lipgloss.Width(promptTag) + + // Calculate remaining width for prompt text. + remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing + + promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt) + + header = lipgloss.JoinVertical( + lipgloss.Left, + header, + "", + lipgloss.JoinHorizontal( + lipgloss.Left, + promptTag, + " ", + promptText, + ), + ) + + // Build tree with nested tool calls. + childTools := tree.Root(header) + + for _, nestedTool := range r.fetch.nestedTools { + childView := nestedTool.Render(remainingWidth) + childTools.Child(childView) + } + + // Build parts. + var parts []string + parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String()) + + // Show animation if still running. + if !opts.HasResult() && !opts.IsCanceled() { + parts = append(parts, "", opts.Anim.Render()) + } + + result := lipgloss.JoinVertical(lipgloss.Left, parts...) + + // Add body content when completed. + if opts.HasResult() && opts.Result.Content != "" { + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent) + return joinToolParts(result, body) + } + + return result +} diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go new file mode 100644 index 0000000000000000000000000000000000000000..7ff53264ead1b2e264cec981ef6aa5cb541247d3 --- /dev/null +++ b/internal/ui/chat/assistant.go @@ -0,0 +1,257 @@ +package chat + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// assistantMessageTruncateFormat is the text shown when an assistant message is +// truncated. +const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]" + +// maxCollapsedThinkingHeight defines the maximum height of the thinking +const maxCollapsedThinkingHeight = 10 + +// AssistantMessageItem represents an assistant message in the chat UI. +// +// This item includes thinking, and the content but does not include the tool calls. +type AssistantMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + *focusableMessageItem + + message *message.Message + sty *styles.Styles + anim *anim.Anim + thinkingExpanded bool + thinkingBoxHeight int // Tracks the rendered thinking box height for click detection. +} + +// NewAssistantMessageItem creates a new AssistantMessageItem. +func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem { + a := &AssistantMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + focusableMessageItem: &focusableMessageItem{}, + message: message, + sty: sty, + } + + a.anim = anim.New(anim.Settings{ + ID: a.ID(), + Size: 15, + GradColorA: sty.Primary, + GradColorB: sty.Secondary, + LabelColor: sty.FgBase, + CycleColors: true, + }) + return a +} + +// StartAnimation starts the assistant message animation if it should be spinning. +func (a *AssistantMessageItem) StartAnimation() tea.Cmd { + if !a.isSpinning() { + return nil + } + return a.anim.Start() +} + +// Animate progresses the assistant message animation if it should be spinning. +func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd { + if !a.isSpinning() { + return nil + } + return a.anim.Animate(msg) +} + +// ID implements MessageItem. +func (a *AssistantMessageItem) ID() string { + return a.message.ID +} + +// RawRender implements [MessageItem]. +func (a *AssistantMessageItem) RawRender(width int) string { + cappedWidth := cappedMessageWidth(width) + + var spinner string + if a.isSpinning() { + spinner = a.renderSpinning() + } + + content, height, ok := a.getCachedRender(cappedWidth) + if !ok { + content = a.renderMessageContent(cappedWidth) + height = lipgloss.Height(content) + // cache the rendered content + a.setCachedRender(content, cappedWidth, height) + } + + highlightedContent := a.renderHighlighted(content, cappedWidth, height) + if spinner != "" { + if highlightedContent != "" { + highlightedContent += "\n\n" + } + return highlightedContent + spinner + } + + return highlightedContent +} + +// Render implements MessageItem. +func (a *AssistantMessageItem) Render(width int) string { + style := a.sty.Chat.Message.AssistantBlurred + if a.focused { + style = a.sty.Chat.Message.AssistantFocused + } + return style.Render(a.RawRender(width)) +} + +// renderMessageContent renders the message content including thinking, main content, and finish reason. +func (a *AssistantMessageItem) renderMessageContent(width int) string { + var messageParts []string + thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking) + content := strings.TrimSpace(a.message.Content().Text) + // if the massage has reasoning content add that first + if thinking != "" { + messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width)) + } + + // then add the main content + if content != "" { + // add a spacer between thinking and content + if thinking != "" { + messageParts = append(messageParts, "") + } + messageParts = append(messageParts, a.renderMarkdown(content, width)) + } + + // finally add any finish reason info + if a.message.IsFinished() { + switch a.message.FinishReason() { + case message.FinishReasonCanceled: + messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled")) + case message.FinishReasonError: + messageParts = append(messageParts, a.renderError(width)) + } + } + + return strings.Join(messageParts, "\n") +} + +// renderThinking renders the thinking/reasoning content with footer. +func (a *AssistantMessageItem) renderThinking(thinking string, width int) string { + renderer := common.PlainMarkdownRenderer(a.sty, width) + rendered, err := renderer.Render(thinking) + if err != nil { + rendered = thinking + } + rendered = strings.TrimSpace(rendered) + + lines := strings.Split(rendered, "\n") + totalLines := len(lines) + + isTruncated := totalLines > maxCollapsedThinkingHeight + if !a.thinkingExpanded && isTruncated { + lines = lines[totalLines-maxCollapsedThinkingHeight:] + hint := a.sty.Chat.Message.ThinkingTruncationHint.Render( + fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight), + ) + lines = append([]string{hint, ""}, lines...) + } + + thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width) + result := thinkingStyle.Render(strings.Join(lines, "\n")) + a.thinkingBoxHeight = lipgloss.Height(result) + + var footer string + // if thinking is done add the thought for footer + if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 { + duration := a.message.ThinkingDuration() + if duration.String() != "0s" { + footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") + + a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String()) + } + } + + if footer != "" { + result += "\n\n" + footer + } + + return result +} + +// renderMarkdown renders content as markdown. +func (a *AssistantMessageItem) renderMarkdown(content string, width int) string { + renderer := common.MarkdownRenderer(a.sty, width) + result, err := renderer.Render(content) + if err != nil { + return content + } + return strings.TrimSuffix(result, "\n") +} + +func (a *AssistantMessageItem) renderSpinning() string { + if a.message.IsThinking() { + a.anim.SetLabel("Thinking") + } else if a.message.IsSummaryMessage { + a.anim.SetLabel("Summarizing") + } + return a.anim.Render() +} + +// renderError renders an error message. +func (a *AssistantMessageItem) renderError(width int) string { + finishPart := a.message.FinishPart() + errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR") + truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...") + title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated)) + details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details) + return fmt.Sprintf("%s\n\n%s", title, details) +} + +// isSpinning returns true if the assistant message is still generating. +func (a *AssistantMessageItem) isSpinning() bool { + isThinking := a.message.IsThinking() + isFinished := a.message.IsFinished() + hasContent := strings.TrimSpace(a.message.Content().Text) != "" + hasToolCalls := len(a.message.ToolCalls()) > 0 + return (isThinking || !isFinished) && !hasContent && !hasToolCalls +} + +// SetMessage is used to update the underlying message. +func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd { + wasSpinning := a.isSpinning() + a.message = message + a.clearCache() + if !wasSpinning && a.isSpinning() { + return a.StartAnimation() + } + return nil +} + +// ToggleExpanded toggles the expanded state of the thinking box. +func (a *AssistantMessageItem) ToggleExpanded() { + a.thinkingExpanded = !a.thinkingExpanded + a.clearCache() +} + +// HandleMouseClick implements MouseClickable. +func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { + if btn != ansi.MouseLeft { + return false + } + // check if the click is within the thinking box + if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight { + a.ToggleExpanded() + return true + } + return false +} diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go new file mode 100644 index 0000000000000000000000000000000000000000..18be27ee01b4fcc21749789fc65ec0b71c2b0d4b --- /dev/null +++ b/internal/ui/chat/bash.go @@ -0,0 +1,248 @@ +package chat + +import ( + "cmp" + "encoding/json" + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// ----------------------------------------------------------------------------- +// Bash Tool +// ----------------------------------------------------------------------------- + +// BashToolMessageItem is a message item that represents a bash tool call. +type BashToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*BashToolMessageItem)(nil) + +// NewBashToolMessageItem creates a new [BashToolMessageItem]. +func NewBashToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled) +} + +// BashToolRenderContext renders bash tool messages. +type BashToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Bash", opts.Anim) + } + + var params tools.BashParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + params.Command = "failed to parse command" + } + + // Check if this is a background job. + var meta tools.BashResponseMetadata + if opts.HasResult() { + _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta) + } + + if meta.Background { + description := cmp.Or(meta.Description, params.Command) + content := "Command: " + params.Command + "\n" + opts.Result.Content + return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content) + } + + // Regular bash command. + cmd := strings.ReplaceAll(params.Command, "\n", " ") + cmd = strings.ReplaceAll(cmd, "\t", " ") + toolParams := []string{cmd} + if params.RunInBackground { + toolParams = append(toolParams, "background", "true") + } + + header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() { + return header + } + + output := meta.Output + if output == "" && opts.Result.Content != tools.BashNoOutput { + output = opts.Result.Content + } + if output == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Job Output Tool +// ----------------------------------------------------------------------------- + +// JobOutputToolMessageItem is a message item for job_output tool calls. +type JobOutputToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil) + +// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem]. +func NewJobOutputToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled) +} + +// JobOutputToolRenderContext renders job_output tool messages. +type JobOutputToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Job", opts.Anim) + } + + var params tools.JobOutputParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + var description string + if opts.HasResult() && opts.Result.Metadata != "" { + var meta tools.JobOutputResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { + description = cmp.Or(meta.Description, meta.Command) + } + } + + content := "" + if opts.HasResult() { + content = opts.Result.Content + } + return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content) +} + +// ----------------------------------------------------------------------------- +// Job Kill Tool +// ----------------------------------------------------------------------------- + +// JobKillToolMessageItem is a message item for job_kill tool calls. +type JobKillToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*JobKillToolMessageItem)(nil) + +// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem]. +func NewJobKillToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled) +} + +// JobKillToolRenderContext renders job_kill tool messages. +type JobKillToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Job", opts.Anim) + } + + var params tools.JobKillParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + var description string + if opts.HasResult() && opts.Result.Metadata != "" { + var meta tools.JobKillResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { + description = cmp.Or(meta.Description, meta.Command) + } + } + + content := "" + if opts.HasResult() { + content = opts.Result.Content + } + return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content) +} + +// renderJobTool renders a job-related tool with the common pattern: +// header → nested check → early state → body. +func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string { + header := jobHeader(sty, opts.Status, action, shellID, description, width) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + return joinToolParts(header, earlyState) + } + + if content == "" { + return header + } + + bodyWidth := width - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} + +// jobHeader builds a header for job-related tools. +// Format: "● Job (Action) PID shellID description..." +func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string { + icon := toolIcon(sty, status) + jobPart := sty.Tool.JobToolName.Render("Job") + actionPart := sty.Tool.JobAction.Render("(" + action + ")") + pidPart := sty.Tool.JobPID.Render("PID " + shellID) + + prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart) + + if description == "" { + return prefix + } + + prefixWidth := lipgloss.Width(prefix) + availableWidth := width - prefixWidth - 1 + if availableWidth < 10 { + return prefix + } + + truncatedDesc := ansi.Truncate(description, availableWidth, "…") + return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc) +} + +// joinToolParts joins header and body with a blank line separator. +func joinToolParts(header, body string) string { + return strings.Join([]string{header, "", body}, "\n") +} diff --git a/internal/ui/chat/diagnostics.go b/internal/ui/chat/diagnostics.go new file mode 100644 index 0000000000000000000000000000000000000000..68d2ac4a00dc880c27904468008fb8f6b2fcf9c5 --- /dev/null +++ b/internal/ui/chat/diagnostics.go @@ -0,0 +1,68 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Diagnostics Tool +// ----------------------------------------------------------------------------- + +// DiagnosticsToolMessageItem is a message item that represents a diagnostics tool call. +type DiagnosticsToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*DiagnosticsToolMessageItem)(nil) + +// NewDiagnosticsToolMessageItem creates a new [DiagnosticsToolMessageItem]. +func NewDiagnosticsToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled) +} + +// DiagnosticsToolRenderContext renders diagnostics tool messages. +type DiagnosticsToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Diagnostics", opts.Anim) + } + + var params tools.DiagnosticsParams + _ = json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms) + + // Show "project" if no file path, otherwise show the file path. + mainParam := "project" + if params.FilePath != "" { + mainParam = fsext.PrettyPath(params.FilePath) + } + + header := toolHeader(sty, opts.Status, "Diagnostics", cappedWidth, opts.Compact, mainParam) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/fetch.go b/internal/ui/chat/fetch.go new file mode 100644 index 0000000000000000000000000000000000000000..e3f3a809550385dfd0ec557e98151ffc731acc93 --- /dev/null +++ b/internal/ui/chat/fetch.go @@ -0,0 +1,192 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Fetch Tool +// ----------------------------------------------------------------------------- + +// FetchToolMessageItem is a message item that represents a fetch tool call. +type FetchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*FetchToolMessageItem)(nil) + +// NewFetchToolMessageItem creates a new [FetchToolMessageItem]. +func NewFetchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled) +} + +// FetchToolRenderContext renders fetch tool messages. +type FetchToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Fetch", opts.Anim) + } + + var params tools.FetchParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.URL} + if params.Format != "" { + toolParams = append(toolParams, "format", params.Format) + } + if params.Timeout != 0 { + toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) + } + + header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + // Determine file extension for syntax highlighting based on format. + file := getFileExtensionForFormat(params.Format) + body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// getFileExtensionForFormat returns a filename with appropriate extension for syntax highlighting. +func getFileExtensionForFormat(format string) string { + switch format { + case "text": + return "fetch.txt" + case "html": + return "fetch.html" + default: + return "fetch.md" + } +} + +// ----------------------------------------------------------------------------- +// WebFetch Tool +// ----------------------------------------------------------------------------- + +// WebFetchToolMessageItem is a message item that represents a web_fetch tool call. +type WebFetchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*WebFetchToolMessageItem)(nil) + +// NewWebFetchToolMessageItem creates a new [WebFetchToolMessageItem]. +func NewWebFetchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled) +} + +// WebFetchToolRenderContext renders web_fetch tool messages. +type WebFetchToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Fetch", opts.Anim) + } + + var params tools.WebFetchParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.URL} + header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// WebSearch Tool +// ----------------------------------------------------------------------------- + +// WebSearchToolMessageItem is a message item that represents a web_search tool call. +type WebSearchToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*WebSearchToolMessageItem)(nil) + +// NewWebSearchToolMessageItem creates a new [WebSearchToolMessageItem]. +func NewWebSearchToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled) +} + +// WebSearchToolRenderContext renders web_search tool messages. +type WebSearchToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Search", opts.Anim) + } + + var params tools.WebSearchParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.Query} + header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go new file mode 100644 index 0000000000000000000000000000000000000000..d558f79d597871bf6074d33c76b44549ee6725d5 --- /dev/null +++ b/internal/ui/chat/file.go @@ -0,0 +1,340 @@ +package chat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// View Tool +// ----------------------------------------------------------------------------- + +// ViewToolMessageItem is a message item that represents a view tool call. +type ViewToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*ViewToolMessageItem)(nil) + +// NewViewToolMessageItem creates a new [ViewToolMessageItem]. +func NewViewToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled) +} + +// ViewToolRenderContext renders view tool messages. +type ViewToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "View", opts.Anim) + } + + var params tools.ViewParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + file := fsext.PrettyPath(params.FilePath) + toolParams := []string{file} + if params.Limit != 0 { + toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit)) + } + if params.Offset != 0 { + toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset)) + } + + header := toolHeader(sty, opts.Status, "View", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() { + return header + } + + // Handle image content. + if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") { + body := toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType) + return joinToolParts(header, body) + } + + // Try to get content from metadata first (contains actual file content). + var meta tools.ViewResponseMetadata + content := opts.Result.Content + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Content != "" { + content = meta.Content + } + + if content == "" { + return header + } + + // Render code content with syntax highlighting. + body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Write Tool +// ----------------------------------------------------------------------------- + +// WriteToolMessageItem is a message item that represents a write tool call. +type WriteToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*WriteToolMessageItem)(nil) + +// NewWriteToolMessageItem creates a new [WriteToolMessageItem]. +func NewWriteToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled) +} + +// WriteToolRenderContext renders write tool messages. +type WriteToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Write", opts.Anim) + } + + var params tools.WriteParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + file := fsext.PrettyPath(params.FilePath) + header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if params.Content == "" { + return header + } + + // Render code content with syntax highlighting. + body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Edit Tool +// ----------------------------------------------------------------------------- + +// EditToolMessageItem is a message item that represents an edit tool call. +type EditToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*EditToolMessageItem)(nil) + +// NewEditToolMessageItem creates a new [EditToolMessageItem]. +func NewEditToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled) +} + +// EditToolRenderContext renders edit tool messages. +type EditToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + // Edit tool uses full width for diffs. + if opts.IsPending() { + return pendingTool(sty, "Edit", opts.Anim) + } + + var params tools.EditParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + } + + file := fsext.PrettyPath(params.FilePath) + header := toolHeader(sty, opts.Status, "Edit", width, opts.Compact, file) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() { + return header + } + + // Get diff content from metadata. + var meta tools.EditResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil { + bodyWidth := width - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) + } + + // Render diff. + body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// MultiEdit Tool +// ----------------------------------------------------------------------------- + +// MultiEditToolMessageItem is a message item that represents a multi-edit tool call. +type MultiEditToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*MultiEditToolMessageItem)(nil) + +// NewMultiEditToolMessageItem creates a new [MultiEditToolMessageItem]. +func NewMultiEditToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled) +} + +// MultiEditToolRenderContext renders multi-edit tool messages. +type MultiEditToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + // MultiEdit tool uses full width for diffs. + if opts.IsPending() { + return pendingTool(sty, "Multi-Edit", opts.Anim) + } + + var params tools.MultiEditParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width) + } + + file := fsext.PrettyPath(params.FilePath) + toolParams := []string{file} + if len(params.Edits) > 0 { + toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits))) + } + + header := toolHeader(sty, opts.Status, "Multi-Edit", width, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() { + return header + } + + // Get diff content from metadata. + var meta tools.MultiEditResponseMetadata + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil { + bodyWidth := width - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) + } + + // Render diff with optional failed edits note. + body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Download Tool +// ----------------------------------------------------------------------------- + +// DownloadToolMessageItem is a message item that represents a download tool call. +type DownloadToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*DownloadToolMessageItem)(nil) + +// NewDownloadToolMessageItem creates a new [DownloadToolMessageItem]. +func NewDownloadToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled) +} + +// DownloadToolRenderContext renders download tool messages. +type DownloadToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Download", opts.Anim) + } + + var params tools.DownloadParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.URL} + if params.FilePath != "" { + toolParams = append(toolParams, "file_path", fsext.PrettyPath(params.FilePath)) + } + if params.Timeout != 0 { + toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout)) + } + + header := toolHeader(sty, opts.Status, "Download", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/mcp.go b/internal/ui/chat/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..c4d124e7381a9ddaa39f56750367d3f2cf4d207f --- /dev/null +++ b/internal/ui/chat/mcp.go @@ -0,0 +1,121 @@ +package chat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// MCPToolMessageItem is a message item that represents a bash tool call. +type MCPToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*MCPToolMessageItem)(nil) + +// NewMCPToolMessageItem creates a new [MCPToolMessageItem]. +func NewMCPToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled) +} + +// MCPToolRenderContext renders bash tool messages. +type MCPToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + toolNameParts := strings.SplitN(opts.ToolCall.Name, "_", 3) + if len(toolNameParts) != 3 { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth) + } + mcpName := prettyName(toolNameParts[1]) + toolName := prettyName(toolNameParts[2]) + + mcpName = sty.Tool.MCPName.Render(mcpName) + toolName = sty.Tool.MCPToolName.Render(toolName) + + name := fmt.Sprintf("%s %s %s", mcpName, sty.Tool.MCPArrow.String(), toolName) + + if opts.IsPending() { + return pendingTool(sty, name, opts.Anim) + } + + var params map[string]any + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + var toolParams []string + if len(params) > 0 { + parsed, _ := json.Marshal(params) + toolParams = append(toolParams, string(parsed)) + } + + header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + // see if the result is json + var result json.RawMessage + var body string + if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil { + prettyResult, err := json.MarshalIndent(result, "", " ") + if err == nil { + body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent)) + } else { + body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + } + } else if looksLikeMarkdown(opts.Result.Content) { + body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent)) + } else { + body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + } + return joinToolParts(header, body) +} + +func prettyName(name string) string { + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return stringext.Capitalize(name) +} + +// looksLikeMarkdown checks if content appears to be markdown by looking for +// common markdown patterns. +func looksLikeMarkdown(content string) bool { + patterns := []string{ + "# ", // headers + "## ", // headers + "**", // bold + "```", // code fence + "- ", // unordered list + "1. ", // ordered list + "> ", // blockquote + "---", // horizontal rule + "***", // horizontal rule + } + for _, p := range patterns { + if strings.Contains(content, p) { + return true + } + } + return false +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go new file mode 100644 index 0000000000000000000000000000000000000000..6be07e4759020c9aed25f042668d89e96584cccc --- /dev/null +++ b/internal/ui/chat/messages.go @@ -0,0 +1,312 @@ +package chat + +import ( + "fmt" + "image" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/attachments" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// this is the total width that is taken up by the border + padding +// we also cap the width so text is readable to the maxTextWidth(120) +const messageLeftPaddingTotal = 2 + +// maxTextWidth is the maximum width text messages can be +const maxTextWidth = 120 + +// Identifiable is an interface for items that can provide a unique identifier. +type Identifiable interface { + ID() string +} + +// Animatable is an interface for items that support animation. +type Animatable interface { + StartAnimation() tea.Cmd + Animate(msg anim.StepMsg) tea.Cmd +} + +// Expandable is an interface for items that can be expanded or collapsed. +type Expandable interface { + ToggleExpanded() +} + +// MessageItem represents a [message.Message] item that can be displayed in the +// UI and be part of a [list.List] identifiable by a unique ID. +type MessageItem interface { + list.Item + list.RawRenderable + Identifiable +} + +// HighlightableMessageItem is a message item that supports highlighting. +type HighlightableMessageItem interface { + MessageItem + list.Highlightable +} + +// FocusableMessageItem is a message item that supports focus. +type FocusableMessageItem interface { + MessageItem + list.Focusable +} + +// SendMsg represents a message to send a chat message. +type SendMsg struct { + Text string + Attachments []message.Attachment +} + +type highlightableMessageItem struct { + startLine int + startCol int + endLine int + endCol int + highlighter list.Highlighter +} + +var _ list.Highlightable = (*highlightableMessageItem)(nil) + +// isHighlighted returns true if the item has a highlight range set. +func (h *highlightableMessageItem) isHighlighted() bool { + return h.startLine != -1 || h.endLine != -1 +} + +// renderHighlighted highlights the content if necessary. +func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string { + if !h.isHighlighted() { + return content + } + area := image.Rect(0, 0, width, height) + return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter) +} + +// SetHighlight implements list.Highlightable. +func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) { + // Adjust columns for the style's left inset (border + padding) since we + // highlight the content only. + offset := messageLeftPaddingTotal + h.startLine = startLine + h.startCol = max(0, startCol-offset) + h.endLine = endLine + if endCol >= 0 { + h.endCol = max(0, endCol-offset) + } else { + h.endCol = endCol + } +} + +// Highlight implements list.Highlightable. +func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) { + return h.startLine, h.startCol, h.endLine, h.endCol +} + +func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem { + return &highlightableMessageItem{ + startLine: -1, + startCol: -1, + endLine: -1, + endCol: -1, + highlighter: list.ToHighlighter(sty.TextSelection), + } +} + +// cachedMessageItem caches rendered message content to avoid re-rendering. +// +// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on +// +// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths +// the issue with that could be memory usage +type cachedMessageItem struct { + // rendered is the cached rendered string + rendered string + // width and height are the dimensions of the cached render + width int + height int +} + +// getCachedRender returns the cached render if it exists for the given width. +func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) { + if c.width == width && c.rendered != "" { + return c.rendered, c.height, true + } + return "", 0, false +} + +// setCachedRender sets the cached render. +func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) { + c.rendered = rendered + c.width = width + c.height = height +} + +// clearCache clears the cached render. +func (c *cachedMessageItem) clearCache() { + c.rendered = "" + c.width = 0 + c.height = 0 +} + +// focusableMessageItem is a base struct for message items that can be focused. +type focusableMessageItem struct { + focused bool +} + +// SetFocused implements MessageItem. +func (f *focusableMessageItem) SetFocused(focused bool) { + f.focused = focused +} + +// AssistantInfoID returns a stable ID for assistant info items. +func AssistantInfoID(messageID string) string { + return fmt.Sprintf("%s:assistant-info", messageID) +} + +// AssistantInfoItem renders model info and response time after assistant completes. +type AssistantInfoItem struct { + *cachedMessageItem + + id string + message *message.Message + sty *styles.Styles + lastUserMessageTime time.Time +} + +// NewAssistantInfoItem creates a new AssistantInfoItem. +func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem { + return &AssistantInfoItem{ + cachedMessageItem: &cachedMessageItem{}, + id: AssistantInfoID(message.ID), + message: message, + sty: sty, + lastUserMessageTime: lastUserMessageTime, + } +} + +// ID implements MessageItem. +func (a *AssistantInfoItem) ID() string { + return a.id +} + +// RawRender implements MessageItem. +func (a *AssistantInfoItem) RawRender(width int) string { + innerWidth := max(0, width-messageLeftPaddingTotal) + content, _, ok := a.getCachedRender(innerWidth) + if !ok { + content = a.renderContent(innerWidth) + height := lipgloss.Height(content) + a.setCachedRender(content, innerWidth, height) + } + return content +} + +// Render implements MessageItem. +func (a *AssistantInfoItem) Render(width int) string { + return a.sty.Chat.Message.SectionHeader.Render(a.RawRender(width)) +} + +func (a *AssistantInfoItem) renderContent(width int) string { + finishData := a.message.FinishPart() + if finishData == nil { + return "" + } + finishTime := time.Unix(finishData.Time, 0) + duration := finishTime.Sub(a.lastUserMessageTime) + infoMsg := a.sty.Chat.Message.AssistantInfoDuration.Render(duration.String()) + icon := a.sty.Chat.Message.AssistantInfoIcon.Render(styles.ModelIcon) + model := config.Get().GetModel(a.message.Provider, a.message.Model) + if model == nil { + model = &catwalk.Model{Name: "Unknown Model"} + } + modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name) + providerName := a.message.Provider + if providerConfig, ok := config.Get().Providers.Get(a.message.Provider); ok { + providerName = providerConfig.Name + } + provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName)) + assistant := fmt.Sprintf("%s %s %s %s", icon, modelFormatted, provider, infoMsg) + return common.Section(a.sty, assistant, width) +} + +// cappedMessageWidth returns the maximum width for message content for readability. +func cappedMessageWidth(availableWidth int) int { + return min(availableWidth-messageLeftPaddingTotal, maxTextWidth) +} + +// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It +// returns all parts of the message as [MessageItem]s. +// +// For assistant messages with tool calls, pass a toolResults map to link results. +// Use BuildToolResultMap to create this map from all messages in a session. +func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { + switch msg.Role { + case message.User: + r := attachments.NewRenderer( + sty.Attachments.Normal, + sty.Attachments.Deleting, + sty.Attachments.Image, + sty.Attachments.Text, + ) + return []MessageItem{NewUserMessageItem(sty, msg, r)} + case message.Assistant: + var items []MessageItem + if ShouldRenderAssistantMessage(msg) { + items = append(items, NewAssistantMessageItem(sty, msg)) + } + for _, tc := range msg.ToolCalls() { + var result *message.ToolResult + if tr, ok := toolResults[tc.ID]; ok { + result = &tr + } + items = append(items, NewToolMessageItem( + sty, + msg.ID, + tc, + result, + msg.FinishReason() == message.FinishReasonCanceled, + )) + } + return items + } + return []MessageItem{} +} + +// ShouldRenderAssistantMessage determines if an assistant message should be rendered +// +// In some cases the assistant message only has tools so we do not want to render an +// empty message. +func ShouldRenderAssistantMessage(msg *message.Message) bool { + content := strings.TrimSpace(msg.Content().Text) + thinking := strings.TrimSpace(msg.ReasoningContent().Thinking) + isError := msg.FinishReason() == message.FinishReasonError + isCancelled := msg.FinishReason() == message.FinishReasonCanceled + hasToolCalls := len(msg.ToolCalls()) > 0 + return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled +} + +// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages. +// Tool result messages (role == message.Tool) contain the results that should be linked +// to tool calls in assistant messages. +func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult { + resultMap := make(map[string]message.ToolResult) + for _, msg := range messages { + if msg.Role == message.Tool { + for _, result := range msg.ToolResults() { + if result.ToolCallID != "" { + resultMap[result.ToolCallID] = result + } + } + } + } + return resultMap +} diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go new file mode 100644 index 0000000000000000000000000000000000000000..2342f671fdaed3bfdcf56619864bd3b60987d8a6 --- /dev/null +++ b/internal/ui/chat/search.go @@ -0,0 +1,256 @@ +package chat + +import ( + "encoding/json" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ----------------------------------------------------------------------------- +// Glob Tool +// ----------------------------------------------------------------------------- + +// GlobToolMessageItem is a message item that represents a glob tool call. +type GlobToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*GlobToolMessageItem)(nil) + +// NewGlobToolMessageItem creates a new [GlobToolMessageItem]. +func NewGlobToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &GlobToolRenderContext{}, canceled) +} + +// GlobToolRenderContext renders glob tool messages. +type GlobToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Glob", opts.Anim) + } + + var params tools.GlobParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.Pattern} + if params.Path != "" { + toolParams = append(toolParams, "path", params.Path) + } + + header := toolHeader(sty, opts.Status, "Glob", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Grep Tool +// ----------------------------------------------------------------------------- + +// GrepToolMessageItem is a message item that represents a grep tool call. +type GrepToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*GrepToolMessageItem)(nil) + +// NewGrepToolMessageItem creates a new [GrepToolMessageItem]. +func NewGrepToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &GrepToolRenderContext{}, canceled) +} + +// GrepToolRenderContext renders grep tool messages. +type GrepToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Grep", opts.Anim) + } + + var params tools.GrepParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.Pattern} + if params.Path != "" { + toolParams = append(toolParams, "path", params.Path) + } + if params.Include != "" { + toolParams = append(toolParams, "include", params.Include) + } + if params.LiteralText { + toolParams = append(toolParams, "literal", "true") + } + + header := toolHeader(sty, opts.Status, "Grep", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// LS Tool +// ----------------------------------------------------------------------------- + +// LSToolMessageItem is a message item that represents an ls tool call. +type LSToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*LSToolMessageItem)(nil) + +// NewLSToolMessageItem creates a new [LSToolMessageItem]. +func NewLSToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &LSToolRenderContext{}, canceled) +} + +// LSToolRenderContext renders ls tool messages. +type LSToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "List", opts.Anim) + } + + var params tools.LSParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + path := params.Path + if path == "" { + path = "." + } + path = fsext.PrettyPath(path) + + header := toolHeader(sty, opts.Status, "List", cappedWidth, opts.Compact, path) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} + +// ----------------------------------------------------------------------------- +// Sourcegraph Tool +// ----------------------------------------------------------------------------- + +// SourcegraphToolMessageItem is a message item that represents a sourcegraph tool call. +type SourcegraphToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*SourcegraphToolMessageItem)(nil) + +// NewSourcegraphToolMessageItem creates a new [SourcegraphToolMessageItem]. +func NewSourcegraphToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled) +} + +// SourcegraphToolRenderContext renders sourcegraph tool messages. +type SourcegraphToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "Sourcegraph", opts.Anim) + } + + var params tools.SourcegraphParams + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + toolParams := []string{params.Query} + if params.Count != 0 { + toolParams = append(toolParams, "count", formatNonZero(params.Count)) + } + if params.ContextWindow != 0 { + toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow)) + } + + header := toolHeader(sty, opts.Status, "Sourcegraph", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if opts.HasEmptyResult() { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + return joinToolParts(header, body) +} diff --git a/internal/ui/chat/todos.go b/internal/ui/chat/todos.go new file mode 100644 index 0000000000000000000000000000000000000000..5678d0e47f4c3a808c13c1dc6209f9194e9f9482 --- /dev/null +++ b/internal/ui/chat/todos.go @@ -0,0 +1,192 @@ +package chat + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// ----------------------------------------------------------------------------- +// Todos Tool +// ----------------------------------------------------------------------------- + +// TodosToolMessageItem is a message item that represents a todos tool call. +type TodosToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*TodosToolMessageItem)(nil) + +// NewTodosToolMessageItem creates a new [TodosToolMessageItem]. +func NewTodosToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled) +} + +// TodosToolRenderContext renders todos tool messages. +type TodosToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + if opts.IsPending() { + return pendingTool(sty, "To-Do", opts.Anim) + } + + var params tools.TodosParams + var meta tools.TodosResponseMetadata + var headerText string + var body string + + // Parse params for pending state (before result is available). + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err == nil { + completedCount := 0 + inProgressTask := "" + for _, todo := range params.Todos { + if todo.Status == "completed" { + completedCount++ + } + if todo.Status == "in_progress" { + if todo.ActiveForm != "" { + inProgressTask = todo.ActiveForm + } else { + inProgressTask = todo.Content + } + } + } + + // Default display from params (used when pending or no metadata). + ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos))) + headerText = ratio + if inProgressTask != "" { + headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask) + } + + // If we have metadata, use it for richer display. + if opts.HasResult() && opts.Result.Metadata != "" { + if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil { + if meta.IsNew { + if meta.JustStarted != "" { + headerText = fmt.Sprintf("created %d todos, starting first", meta.Total) + } else { + headerText = fmt.Sprintf("created %d todos", meta.Total) + } + body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) + } else { + // Build header based on what changed. + hasCompleted := len(meta.JustCompleted) > 0 + hasStarted := meta.JustStarted != "" + allCompleted := meta.Completed == meta.Total + + ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total)) + if hasCompleted && hasStarted { + text := sty.Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted))) + headerText = fmt.Sprintf("%s%s", ratio, text) + } else if hasCompleted { + text := sty.Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted))) + if allCompleted { + text = sty.Subtle.Render(" · completed all") + } + headerText = fmt.Sprintf("%s%s", ratio, text) + } else if hasStarted { + headerText = fmt.Sprintf("%s%s", ratio, sty.Subtle.Render(" · starting task")) + } else { + headerText = ratio + } + + // Build body with details. + if allCompleted { + // Show all todos when all are completed, like when created. + body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth) + } else if meta.JustStarted != "" { + body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") + + sty.Base.Render(meta.JustStarted) + } + } + } + } + } + + toolParams := []string{headerText} + header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if body == "" { + return header + } + + return joinToolParts(header, sty.Tool.Body.Render(body)) +} + +// FormatTodosList formats a list of todos for display. +func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string { + if len(todos) == 0 { + return "" + } + + sorted := make([]session.Todo, len(todos)) + copy(sorted, todos) + sortTodos(sorted) + + var lines []string + for _, todo := range sorted { + var prefix string + textStyle := sty.Base + + switch todo.Status { + case session.TodoStatusCompleted: + prefix = sty.Tool.TodoCompletedIcon.Render(styles.TodoCompletedIcon) + " " + case session.TodoStatusInProgress: + prefix = sty.Tool.TodoInProgressIcon.Render(inProgressIcon + " ") + default: + prefix = sty.Tool.TodoPendingIcon.Render(styles.TodoPendingIcon) + " " + } + + text := todo.Content + if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" { + text = todo.ActiveForm + } + line := prefix + textStyle.Render(text) + line = ansi.Truncate(line, width, "…") + + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +// sortTodos sorts todos by status: completed, in_progress, pending. +func sortTodos(todos []session.Todo) { + slices.SortStableFunc(todos, func(a, b session.Todo) int { + return statusOrder(a.Status) - statusOrder(b.Status) + }) +} + +// statusOrder returns the sort order for a todo status. +func statusOrder(s session.TodoStatus) int { + switch s { + case session.TodoStatusCompleted: + return 0 + case session.TodoStatusInProgress: + return 1 + default: + return 2 + } +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..e703cb1c096c0fa889438a446a8f042b892d9e31 --- /dev/null +++ b/internal/ui/chat/tools.go @@ -0,0 +1,805 @@ +package chat + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/tree" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// responseContextHeight limits the number of lines displayed in tool output. +const responseContextHeight = 10 + +// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body +const toolBodyLeftPaddingTotal = 2 + +// ToolStatus represents the current state of a tool call. +type ToolStatus int + +const ( + ToolStatusAwaitingPermission ToolStatus = iota + ToolStatusRunning + ToolStatusSuccess + ToolStatusError + ToolStatusCanceled +) + +// ToolMessageItem represents a tool call message in the chat UI. +type ToolMessageItem interface { + MessageItem + + ToolCall() message.ToolCall + SetToolCall(tc message.ToolCall) + SetResult(res *message.ToolResult) + MessageID() string + SetMessageID(id string) + SetStatus(status ToolStatus) + Status() ToolStatus +} + +// Compactable is an interface for tool items that can render in a compacted mode. +// When compact mode is enabled, tools render as a compact single-line header. +type Compactable interface { + SetCompact(compact bool) +} + +// SpinningState contains the state passed to SpinningFunc for custom spinning logic. +type SpinningState struct { + ToolCall message.ToolCall + Result *message.ToolResult + Status ToolStatus +} + +// IsCanceled returns true if the tool status is canceled. +func (s *SpinningState) IsCanceled() bool { + return s.Status == ToolStatusCanceled +} + +// HasResult returns true if the result is not nil. +func (s *SpinningState) HasResult() bool { + return s.Result != nil +} + +// SpinningFunc is a function type for custom spinning logic. +// Returns true if the tool should show the spinning animation. +type SpinningFunc func(state SpinningState) bool + +// DefaultToolRenderContext implements the default [ToolRenderer] interface. +type DefaultToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name +} + +// ToolRenderOpts contains the data needed to render a tool call. +type ToolRenderOpts struct { + ToolCall message.ToolCall + Result *message.ToolResult + Anim *anim.Anim + ExpandedContent bool + Compact bool + IsSpinning bool + Status ToolStatus +} + +// IsPending returns true if the tool call is still pending (not finished and +// not canceled). +func (o *ToolRenderOpts) IsPending() bool { + return !o.ToolCall.Finished && !o.IsCanceled() +} + +// IsCanceled returns true if the tool status is canceled. +func (o *ToolRenderOpts) IsCanceled() bool { + return o.Status == ToolStatusCanceled +} + +// HasResult returns true if the result is not nil. +func (o *ToolRenderOpts) HasResult() bool { + return o.Result != nil +} + +// HasEmptyResult returns true if the result is nil or has empty content. +func (o *ToolRenderOpts) HasEmptyResult() bool { + return o.Result == nil || o.Result.Content == "" +} + +// ToolRenderer represents an interface for rendering tool calls. +type ToolRenderer interface { + RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string +} + +// ToolRendererFunc is a function type that implements the [ToolRenderer] interface. +type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string + +// RenderTool implements the ToolRenderer interface. +func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + return f(sty, width, opts) +} + +// baseToolMessageItem represents a tool call message that can be displayed in the UI. +type baseToolMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + *focusableMessageItem + + toolRenderer ToolRenderer + toolCall message.ToolCall + result *message.ToolResult + messageID string + status ToolStatus + // we use this so we can efficiently cache + // tools that have a capped width (e.x bash.. and others) + hasCappedWidth bool + // isCompact indicates this tool should render in compact mode. + isCompact bool + // spinningFunc allows tools to override the default spinning logic. + // If nil, uses the default: !toolCall.Finished && !canceled. + spinningFunc SpinningFunc + + sty *styles.Styles + anim *anim.Anim + expandedContent bool +} + +// newBaseToolMessageItem is the internal constructor for base tool message items. +func newBaseToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + toolRenderer ToolRenderer, + canceled bool, +) *baseToolMessageItem { + // we only do full width for diffs (as far as I know) + hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName + + status := ToolStatusRunning + if canceled { + status = ToolStatusCanceled + } + + t := &baseToolMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + focusableMessageItem: &focusableMessageItem{}, + sty: sty, + toolRenderer: toolRenderer, + toolCall: toolCall, + result: result, + status: status, + hasCappedWidth: hasCappedWidth, + } + t.anim = anim.New(anim.Settings{ + ID: toolCall.ID, + Size: 15, + GradColorA: sty.Primary, + GradColorB: sty.Secondary, + LabelColor: sty.FgBase, + CycleColors: true, + }) + + return t +} + +// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name. +// +// It returns a specific tool message item type if implemented, otherwise it +// returns a generic tool message item. The messageID is the ID of the assistant +// message containing this tool call. +func NewToolMessageItem( + sty *styles.Styles, + messageID string, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + var item ToolMessageItem + switch toolCall.Name { + case tools.BashToolName: + item = NewBashToolMessageItem(sty, toolCall, result, canceled) + case tools.JobOutputToolName: + item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled) + case tools.JobKillToolName: + item = NewJobKillToolMessageItem(sty, toolCall, result, canceled) + case tools.ViewToolName: + item = NewViewToolMessageItem(sty, toolCall, result, canceled) + case tools.WriteToolName: + item = NewWriteToolMessageItem(sty, toolCall, result, canceled) + case tools.EditToolName: + item = NewEditToolMessageItem(sty, toolCall, result, canceled) + case tools.MultiEditToolName: + item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled) + case tools.GlobToolName: + item = NewGlobToolMessageItem(sty, toolCall, result, canceled) + case tools.GrepToolName: + item = NewGrepToolMessageItem(sty, toolCall, result, canceled) + case tools.LSToolName: + item = NewLSToolMessageItem(sty, toolCall, result, canceled) + case tools.DownloadToolName: + item = NewDownloadToolMessageItem(sty, toolCall, result, canceled) + case tools.FetchToolName: + item = NewFetchToolMessageItem(sty, toolCall, result, canceled) + case tools.SourcegraphToolName: + item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled) + case tools.DiagnosticsToolName: + item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled) + case agent.AgentToolName: + item = NewAgentToolMessageItem(sty, toolCall, result, canceled) + case tools.AgenticFetchToolName: + item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled) + case tools.WebFetchToolName: + item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled) + case tools.WebSearchToolName: + item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled) + case tools.TodosToolName: + item = NewTodosToolMessageItem(sty, toolCall, result, canceled) + default: + if strings.HasPrefix(toolCall.Name, "mcp_") { + item = NewMCPToolMessageItem(sty, toolCall, result, canceled) + } else { + // TODO: Implement other tool items + item = newBaseToolMessageItem( + sty, + toolCall, + result, + &DefaultToolRenderContext{}, + canceled, + ) + } + } + item.SetMessageID(messageID) + return item +} + +// SetCompact implements the Compactable interface. +func (t *baseToolMessageItem) SetCompact(compact bool) { + t.isCompact = compact + t.clearCache() +} + +// ID returns the unique identifier for this tool message item. +func (t *baseToolMessageItem) ID() string { + return t.toolCall.ID +} + +// StartAnimation starts the assistant message animation if it should be spinning. +func (t *baseToolMessageItem) StartAnimation() tea.Cmd { + if !t.isSpinning() { + return nil + } + return t.anim.Start() +} + +// Animate progresses the assistant message animation if it should be spinning. +func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { + if !t.isSpinning() { + return nil + } + return t.anim.Animate(msg) +} + +// RawRender implements [MessageItem]. +func (t *baseToolMessageItem) RawRender(width int) string { + toolItemWidth := width - messageLeftPaddingTotal + if t.hasCappedWidth { + toolItemWidth = cappedMessageWidth(width) + } + + content, height, ok := t.getCachedRender(toolItemWidth) + // if we are spinning or there is no cache rerender + if !ok || t.isSpinning() { + content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{ + ToolCall: t.toolCall, + Result: t.result, + Anim: t.anim, + ExpandedContent: t.expandedContent, + Compact: t.isCompact, + IsSpinning: t.isSpinning(), + Status: t.computeStatus(), + }) + height = lipgloss.Height(content) + // cache the rendered content + t.setCachedRender(content, toolItemWidth, height) + } + + return t.renderHighlighted(content, toolItemWidth, height) +} + +// Render renders the tool message item at the given width. +func (t *baseToolMessageItem) Render(width int) string { + style := t.sty.Chat.Message.ToolCallBlurred + if t.focused { + style = t.sty.Chat.Message.ToolCallFocused + } + + if t.isCompact { + style = t.sty.Chat.Message.ToolCallCompact + } + + return style.Render(t.RawRender(width)) +} + +// ToolCall returns the tool call associated with this message item. +func (t *baseToolMessageItem) ToolCall() message.ToolCall { + return t.toolCall +} + +// SetToolCall sets the tool call associated with this message item. +func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) { + t.toolCall = tc + t.clearCache() +} + +// SetResult sets the tool result associated with this message item. +func (t *baseToolMessageItem) SetResult(res *message.ToolResult) { + t.result = res + t.clearCache() +} + +// MessageID returns the ID of the message containing this tool call. +func (t *baseToolMessageItem) MessageID() string { + return t.messageID +} + +// SetMessageID sets the ID of the message containing this tool call. +func (t *baseToolMessageItem) SetMessageID(id string) { + t.messageID = id +} + +// SetStatus sets the tool status. +func (t *baseToolMessageItem) SetStatus(status ToolStatus) { + t.status = status + t.clearCache() +} + +// Status returns the current tool status. +func (t *baseToolMessageItem) Status() ToolStatus { + return t.status +} + +// computeStatus computes the effective status considering the result. +func (t *baseToolMessageItem) computeStatus() ToolStatus { + if t.result != nil { + if t.result.IsError { + return ToolStatusError + } + return ToolStatusSuccess + } + return t.status +} + +// isSpinning returns true if the tool should show animation. +func (t *baseToolMessageItem) isSpinning() bool { + if t.spinningFunc != nil { + return t.spinningFunc(SpinningState{ + ToolCall: t.toolCall, + Result: t.result, + Status: t.status, + }) + } + return !t.toolCall.Finished && t.status != ToolStatusCanceled +} + +// SetSpinningFunc sets a custom function to determine if the tool should spin. +func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) { + t.spinningFunc = fn +} + +// ToggleExpanded toggles the expanded state of the thinking box. +func (t *baseToolMessageItem) ToggleExpanded() { + t.expandedContent = !t.expandedContent + t.clearCache() +} + +// HandleMouseClick implements MouseClickable. +func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { + if btn != ansi.MouseLeft { + return false + } + t.ToggleExpanded() + return true +} + +// pendingTool renders a tool that is still in progress with an animation. +func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string { + icon := sty.Tool.IconPending.Render() + toolName := sty.Tool.NameNormal.Render(name) + + var animView string + if anim != nil { + animView = anim.Render() + } + + return fmt.Sprintf("%s %s %s", icon, toolName, animView) +} + +// toolEarlyStateContent handles error/cancelled/pending states before content rendering. +// Returns the rendered output and true if early state was handled. +func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) { + var msg string + switch opts.Status { + case ToolStatusError: + msg = toolErrorContent(sty, opts.Result, width) + case ToolStatusCanceled: + msg = sty.Tool.StateCancelled.Render("Canceled.") + case ToolStatusAwaitingPermission: + msg = sty.Tool.StateWaiting.Render("Requesting permission...") + case ToolStatusRunning: + msg = sty.Tool.StateWaiting.Render("Waiting for tool response...") + default: + return "", false + } + return msg, true +} + +// toolErrorContent formats an error message with ERROR tag. +func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string { + if result == nil { + return "" + } + errContent := strings.ReplaceAll(result.Content, "\n", " ") + errTag := sty.Tool.ErrorTag.Render("ERROR") + tagWidth := lipgloss.Width(errTag) + errContent = ansi.Truncate(errContent, width-tagWidth-3, "…") + return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent)) +} + +// toolIcon returns the status icon for a tool call. +// toolIcon returns the status icon for a tool call based on its status. +func toolIcon(sty *styles.Styles, status ToolStatus) string { + switch status { + case ToolStatusSuccess: + return sty.Tool.IconSuccess.String() + case ToolStatusError: + return sty.Tool.IconError.String() + case ToolStatusCanceled: + return sty.Tool.IconCancelled.String() + default: + return sty.Tool.IconPending.String() + } +} + +// toolParamList formats parameters as "main (key=value, ...)" with truncation. +// toolParamList formats tool parameters as "main (key=value, ...)" with truncation. +func toolParamList(sty *styles.Styles, params []string, width int) string { + // minSpaceForMainParam is the min space required for the main param + // if this is less that the value set we will only show the main param nothing else + const minSpaceForMainParam = 30 + if len(params) == 0 { + return "" + } + + mainParam := params[0] + + // Build key=value pairs from remaining params (consecutive key, value pairs). + var kvPairs []string + for i := 1; i+1 < len(params); i += 2 { + if params[i+1] != "" { + kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1])) + } + } + + // Try to include key=value pairs if there's enough space. + output := mainParam + if len(kvPairs) > 0 { + partsStr := strings.Join(kvPairs, ", ") + if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam { + output = fmt.Sprintf("%s (%s)", mainParam, partsStr) + } + } + + if width >= 0 { + output = ansi.Truncate(output, width, "…") + } + return sty.Tool.ParamMain.Render(output) +} + +// toolHeader builds the tool header line: "● ToolName params..." +func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string { + icon := toolIcon(sty, status) + nameStyle := sty.Tool.NameNormal + if nested { + nameStyle = sty.Tool.NameNested + } + toolName := nameStyle.Render(name) + prefix := fmt.Sprintf("%s %s ", icon, toolName) + prefixWidth := lipgloss.Width(prefix) + remainingWidth := width - prefixWidth + paramsStr := toolParamList(sty, params, remainingWidth) + return prefix + paramsStr +} + +// toolOutputPlainContent renders plain text with optional expansion support. +func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + content = strings.TrimSpace(content) + lines := strings.Split(content, "\n") + + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) // Show all + } + + var out []string + for i, ln := range lines { + if i >= maxLines { + break + } + ln = " " + ln + if lipgloss.Width(ln) > width { + ln = ansi.Truncate(ln, width, "…") + } + out = append(out, sty.Tool.ContentLine.Width(width).Render(ln)) + } + + wasTruncated := len(lines) > responseContextHeight + + if !expanded && wasTruncated { + out = append(out, sty.Tool.ContentTruncation. + Width(width). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight))) + } + + return strings.Join(out, "\n") +} + +// toolOutputCodeContent renders code with syntax highlighting and line numbers. +func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + + lines := strings.Split(content, "\n") + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + // Truncate if needed. + displayLines := lines + if len(lines) > maxLines { + displayLines = lines[:maxLines] + } + + bg := sty.Tool.ContentCodeBg + highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg) + highlightedLines := strings.Split(highlighted, "\n") + + // Calculate line number width. + maxLineNumber := len(displayLines) + offset + maxDigits := getDigits(maxLineNumber) + numFmt := fmt.Sprintf("%%%dd", maxDigits) + + bodyWidth := width - toolBodyLeftPaddingTotal + codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding + + var out []string + for i, ln := range highlightedLines { + lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset)) + + if lipgloss.Width(ln) > codeWidth { + ln = ansi.Truncate(ln, codeWidth, "…") + } + + codeLine := sty.Tool.ContentCodeLine. + Width(codeWidth). + PaddingLeft(2). + Render(ln) + + out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine)) + } + + // Add truncation message if needed. + if len(lines) > maxLines && !expanded { + out = append(out, sty.Tool.ContentCodeTruncation. + Width(bodyWidth). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)), + ) + } + + return sty.Tool.Body.Render(strings.Join(out, "\n")) +} + +// toolOutputImageContent renders image data with size info. +func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string { + dataSize := len(data) * 3 / 4 + sizeStr := formatSize(dataSize) + + loaded := sty.Base.Foreground(sty.Green).Render("Loaded") + arrow := sty.Base.Foreground(sty.GreenDark).Render("→") + typeStyled := sty.Base.Render(mediaType) + sizeStyled := sty.Subtle.Render(sizeStr) + + return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)) +} + +// getDigits returns the number of digits in a number. +func getDigits(n int) int { + if n == 0 { + return 1 + } + if n < 0 { + n = -n + } + digits := 0 + for n > 0 { + n /= 10 + digits++ + } + return digits +} + +// formatSize formats byte size into human readable format. +func formatSize(bytes int) string { + const ( + kb = 1024 + mb = kb * 1024 + ) + switch { + case bytes >= mb: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) + case bytes >= kb: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +// toolOutputDiffContent renders a diff between old and new content. +func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string { + bodyWidth := width - toolBodyLeftPaddingTotal + + formatter := common.DiffFormatter(sty). + Before(file, oldContent). + After(file, newContent). + Width(bodyWidth) + + // Use split view for wide terminals. + if width > maxTextWidth { + formatter = formatter.Split() + } + + formatted := formatter.String() + lines := strings.Split(formatted, "\n") + + // Truncate if needed. + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + if len(lines) > maxLines && !expanded { + truncMsg := sty.Tool.DiffTruncation. + Width(bodyWidth). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)) + formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n") + } + + return sty.Tool.Body.Render(formatted) +} + +// formatTimeout converts timeout seconds to a duration string (e.g., "30s"). +// Returns empty string if timeout is 0. +func formatTimeout(timeout int) string { + if timeout == 0 { + return "" + } + return fmt.Sprintf("%ds", timeout) +} + +// formatNonZero returns string representation of non-zero integers, empty string for zero. +func formatNonZero(value int) string { + if value == 0 { + return "" + } + return fmt.Sprintf("%d", value) +} + +// toolOutputMultiEditDiffContent renders a diff with optional failed edits note. +func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string { + bodyWidth := width - toolBodyLeftPaddingTotal + + formatter := common.DiffFormatter(sty). + Before(file, meta.OldContent). + After(file, meta.NewContent). + Width(bodyWidth) + + // Use split view for wide terminals. + if width > maxTextWidth { + formatter = formatter.Split() + } + + formatted := formatter.String() + lines := strings.Split(formatted, "\n") + + // Truncate if needed. + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + if len(lines) > maxLines && !expanded { + truncMsg := sty.Tool.DiffTruncation. + Width(bodyWidth). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)) + formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n") + } + + // Add failed edits note if any exist. + if len(meta.EditsFailed) > 0 { + noteTag := sty.Tool.NoteTag.Render("Note") + noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits) + note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg)) + formatted = formatted + "\n\n" + note + } + + return sty.Tool.Body.Render(formatted) +} + +// roundedEnumerator creates a tree enumerator with rounded corners. +func roundedEnumerator(lPadding, width int) tree.Enumerator { + if width == 0 { + width = 2 + } + if lPadding == 0 { + lPadding = 1 + } + return func(children tree.Children, index int) string { + line := strings.Repeat("─", width) + padding := strings.Repeat(" ", lPadding) + if children.Length()-1 == index { + return padding + "╰" + line + } + return padding + "├" + line + } +} + +// toolOutputMarkdownContent renders markdown content with optional truncation. +func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + content = strings.TrimSpace(content) + + // Cap width for readability. + if width > maxTextWidth { + width = maxTextWidth + } + + renderer := common.PlainMarkdownRenderer(sty, width) + rendered, err := renderer.Render(content) + if err != nil { + return toolOutputPlainContent(sty, content, width, expanded) + } + + lines := strings.Split(rendered, "\n") + maxLines := responseContextHeight + if expanded { + maxLines = len(lines) + } + + var out []string + for i, ln := range lines { + if i >= maxLines { + break + } + out = append(out, ln) + } + + if len(lines) > maxLines && !expanded { + out = append(out, sty.Tool.ContentTruncation. + Width(width). + Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)), + ) + } + + return sty.Tool.Body.Render(strings.Join(out, "\n")) +} diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go new file mode 100644 index 0000000000000000000000000000000000000000..7383c841ae3e274bdfea8dcc4db37e1259dbbb21 --- /dev/null +++ b/internal/ui/chat/user.go @@ -0,0 +1,94 @@ +package chat + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/attachments" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// UserMessageItem represents a user message in the chat UI. +type UserMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + *focusableMessageItem + + attachments *attachments.Renderer + message *message.Message + sty *styles.Styles +} + +// NewUserMessageItem creates a new UserMessageItem. +func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem { + return &UserMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + focusableMessageItem: &focusableMessageItem{}, + attachments: attachments, + message: message, + sty: sty, + } +} + +// RawRender implements [MessageItem]. +func (m *UserMessageItem) RawRender(width int) string { + cappedWidth := cappedMessageWidth(width) + + content, height, ok := m.getCachedRender(cappedWidth) + // cache hit + if ok { + return m.renderHighlighted(content, cappedWidth, height) + } + + renderer := common.MarkdownRenderer(m.sty, cappedWidth) + + msgContent := strings.TrimSpace(m.message.Content().Text) + result, err := renderer.Render(msgContent) + if err != nil { + content = msgContent + } else { + content = strings.TrimSuffix(result, "\n") + } + + if len(m.message.BinaryContent()) > 0 { + attachmentsStr := m.renderAttachments(cappedWidth) + if content == "" { + content = attachmentsStr + } else { + content = strings.Join([]string{content, "", attachmentsStr}, "\n") + } + } + + height = lipgloss.Height(content) + m.setCachedRender(content, cappedWidth, height) + return m.renderHighlighted(content, cappedWidth, height) +} + +// Render implements MessageItem. +func (m *UserMessageItem) Render(width int) string { + style := m.sty.Chat.Message.UserBlurred + if m.focused { + style = m.sty.Chat.Message.UserFocused + } + return style.Render(m.RawRender(width)) +} + +// ID implements MessageItem. +func (m *UserMessageItem) ID() string { + return m.message.ID +} + +// renderAttachments renders attachments. +func (m *UserMessageItem) renderAttachments(width int) string { + var attachments []message.Attachment + for _, at := range m.message.BinaryContent() { + attachments = append(attachments, message.Attachment{ + FileName: at.Path, + MimeType: at.MIMEType, + }) + } + return m.attachments.Render(attachments, false, width) +} diff --git a/internal/ui/common/button.go b/internal/ui/common/button.go new file mode 100644 index 0000000000000000000000000000000000000000..90a2dc929a004e734a18e69b874b36cbd0f4f667 --- /dev/null +++ b/internal/ui/common/button.go @@ -0,0 +1,69 @@ +package common + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ButtonOpts defines the configuration for a single button +type ButtonOpts struct { + // Text is the button label + Text string + // UnderlineIndex is the 0-based index of the character to underline (-1 for none) + UnderlineIndex int + // Selected indicates whether this button is currently selected + Selected bool + // Padding inner horizontal padding defaults to 2 if this is 0 + Padding int +} + +// Button creates a button with an underlined character and selection state +func Button(t *styles.Styles, opts ButtonOpts) string { + // Select style based on selection state + style := t.ButtonBlur + if opts.Selected { + style = t.ButtonFocus + } + + text := opts.Text + if opts.Padding == 0 { + opts.Padding = 2 + } + + // the index is out of bound + if opts.UnderlineIndex > -1 && opts.UnderlineIndex > len(text)-1 { + opts.UnderlineIndex = -1 + } + + text = style.Padding(0, opts.Padding).Render(text) + + if opts.UnderlineIndex != -1 { + text = lipgloss.StyleRanges(text, lipgloss.NewRange(opts.Padding+opts.UnderlineIndex, opts.Padding+opts.UnderlineIndex+1, style.Underline(true))) + } + + return text +} + +// ButtonGroup creates a row of selectable buttons +// Spacing is the separator between buttons +// Use " " or similar for horizontal layout +// Use "\n" for vertical layout +// Defaults to " " (horizontal) +func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string { + if len(buttons) == 0 { + return "" + } + + if spacing == "" { + spacing = " " + } + + parts := make([]string, len(buttons)) + for i, button := range buttons { + parts[i] = Button(t, button) + } + + return strings.Join(parts, spacing) +} diff --git a/internal/ui/common/common.go b/internal/ui/common/common.go new file mode 100644 index 0000000000000000000000000000000000000000..21ab903c388adaa1f626bef46f09c3829f927086 --- /dev/null +++ b/internal/ui/common/common.go @@ -0,0 +1,65 @@ +package common + +import ( + "fmt" + "image" + "os" + + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" +) + +// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB). +const MaxAttachmentSize = int64(5 * 1024 * 1024) + +// AllowedImageTypes defines the permitted image file types. +var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"} + +// Common defines common UI options and configurations. +type Common struct { + App *app.App + Styles *styles.Styles +} + +// Config returns the configuration associated with this [Common] instance. +func (c *Common) Config() *config.Config { + return c.App.Config() +} + +// DefaultCommon returns the default common UI configurations. +func DefaultCommon(app *app.App) *Common { + s := styles.DefaultStyles() + return &Common{ + App: app, + Styles: &s, + } +} + +// CenterRect returns a new [Rectangle] centered within the given area with the +// specified width and height. +func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle { + centerX := area.Min.X + area.Dx()/2 + centerY := area.Min.Y + area.Dy()/2 + minX := centerX - width/2 + minY := centerY - height/2 + maxX := minX + width + maxY := minY + height + return image.Rect(minX, minY, maxX, maxY) +} + +// IsFileTooBig checks if the file at the given path exceeds the specified size +// limit. +func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) { + fileInfo, err := os.Stat(filePath) + if err != nil { + return false, fmt.Errorf("error getting file info: %w", err) + } + + if fileInfo.Size() > sizeLimit { + return true, nil + } + + return false, nil +} diff --git a/internal/ui/common/diff.go b/internal/ui/common/diff.go new file mode 100644 index 0000000000000000000000000000000000000000..8007cebce93a0d0833be779eb11cbb703bc8c1d6 --- /dev/null +++ b/internal/ui/common/diff.go @@ -0,0 +1,16 @@ +package common + +import ( + "github.com/alecthomas/chroma/v2" + "github.com/charmbracelet/crush/internal/tui/exp/diffview" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// DiffFormatter returns a diff formatter with the given styles that can be +// used to format diff outputs. +func DiffFormatter(s *styles.Styles) *diffview.DiffView { + formatDiff := diffview.New() + style := chroma.MustNewStyle("crush", s.ChromaTheme()) + diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4) + return diff +} diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go new file mode 100644 index 0000000000000000000000000000000000000000..ccb7f7cdb2677980ddac4a55e153354c9f220962 --- /dev/null +++ b/internal/ui/common/elements.go @@ -0,0 +1,190 @@ +package common + +import ( + "cmp" + "fmt" + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// PrettyPath formats a file path with home directory shortening and applies +// muted styling. +func PrettyPath(t *styles.Styles, path string, width int) string { + formatted := home.Short(path) + return t.Muted.Width(width).Render(formatted) +} + +// ModelContextInfo contains token usage and cost information for a model. +type ModelContextInfo struct { + ContextUsed int64 + ModelContext int64 + Cost float64 +} + +// ModelInfo renders model information including name, provider, reasoning +// settings, and optional context usage/cost. +func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int) string { + modelIcon := t.Subtle.Render(styles.ModelIcon) + modelName = t.Base.Render(modelName) + + // Build first line with model name and optionally provider on the same line + var firstLine string + if providerName != "" { + providerInfo := t.Muted.Render(fmt.Sprintf("via %s", providerName)) + modelWithProvider := fmt.Sprintf("%s %s %s", modelIcon, modelName, providerInfo) + + // Check if it fits on one line + if lipgloss.Width(modelWithProvider) <= width { + firstLine = modelWithProvider + } else { + // If it doesn't fit, put provider on next line + firstLine = fmt.Sprintf("%s %s", modelIcon, modelName) + } + } else { + firstLine = fmt.Sprintf("%s %s", modelIcon, modelName) + } + + parts := []string{firstLine} + + // If provider didn't fit on first line, add it as second line + if providerName != "" && !strings.Contains(firstLine, "via") { + providerInfo := fmt.Sprintf("via %s", providerName) + parts = append(parts, t.Muted.PaddingLeft(2).Render(providerInfo)) + } + + if reasoningInfo != "" { + parts = append(parts, t.Subtle.PaddingLeft(2).Render(reasoningInfo)) + } + + if context != nil { + formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost) + parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo)) + } + + return lipgloss.NewStyle().Width(width).Render( + lipgloss.JoinVertical(lipgloss.Left, parts...), + ) +} + +// formatTokensAndCost formats token usage and cost with appropriate units +// (K/M) and percentage of context window. +func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string { + var formattedTokens string + switch { + case tokens >= 1_000_000: + formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) + case tokens >= 1_000: + formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000) + default: + formattedTokens = fmt.Sprintf("%d", tokens) + } + + if strings.HasSuffix(formattedTokens, ".0K") { + formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1) + } + if strings.HasSuffix(formattedTokens, ".0M") { + formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1) + } + + percentage := (float64(tokens) / float64(contextWindow)) * 100 + + formattedCost := t.Muted.Render(fmt.Sprintf("$%.2f", cost)) + + formattedTokens = t.Subtle.Render(fmt.Sprintf("(%s)", formattedTokens)) + formattedPercentage := t.Muted.Render(fmt.Sprintf("%d%%", int(percentage))) + formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens) + if percentage > 80 { + formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens) + } + + return fmt.Sprintf("%s %s", formattedTokens, formattedCost) +} + +// StatusOpts defines options for rendering a status line with icon, title, +// description, and optional extra content. +type StatusOpts struct { + Icon string // if empty no icon will be shown + Title string + TitleColor color.Color + Description string + DescriptionColor color.Color + ExtraContent string // additional content to append after the description +} + +// Status renders a status line with icon, title, description, and extra +// content. The description is truncated if it exceeds the available width. +func Status(t *styles.Styles, opts StatusOpts, width int) string { + icon := opts.Icon + title := opts.Title + description := opts.Description + + titleColor := cmp.Or(opts.TitleColor, t.Muted.GetForeground()) + descriptionColor := cmp.Or(opts.DescriptionColor, t.Subtle.GetForeground()) + + title = t.Base.Foreground(titleColor).Render(title) + + if description != "" { + extraContentWidth := lipgloss.Width(opts.ExtraContent) + if extraContentWidth > 0 { + extraContentWidth += 1 + } + description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…") + description = t.Base.Foreground(descriptionColor).Render(description) + } + + content := []string{} + if icon != "" { + content = append(content, icon) + } + content = append(content, title) + if description != "" { + content = append(content, description) + } + if opts.ExtraContent != "" { + content = append(content, opts.ExtraContent) + } + + return strings.Join(content, " ") +} + +// Section renders a section header with a title and a horizontal line filling +// the remaining width. +func Section(t *styles.Styles, text string, width int, info ...string) string { + char := styles.SectionSeparator + length := lipgloss.Width(text) + 1 + remainingWidth := width - length + + var infoText string + if len(info) > 0 { + infoText = strings.Join(info, " ") + if len(infoText) > 0 { + infoText = " " + infoText + remainingWidth -= lipgloss.Width(infoText) + } + } + + text = t.Section.Title.Render(text) + if remainingWidth > 0 { + text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + infoText + } + return text +} + +// DialogTitle renders a dialog title with a decorative line filling the +// remaining width. +func DialogTitle(t *styles.Styles, title string, width int) string { + char := "╱" + length := lipgloss.Width(title) + 1 + remainingWidth := width - length + if remainingWidth > 0 { + lines := strings.Repeat(char, remainingWidth) + lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary) + title = title + " " + lines + } + return title +} diff --git a/internal/ui/common/highlight.go b/internal/ui/common/highlight.go new file mode 100644 index 0000000000000000000000000000000000000000..642a7859d110a86af57feeb447907612a6b12098 --- /dev/null +++ b/internal/ui/common/highlight.go @@ -0,0 +1,57 @@ +package common + +import ( + "bytes" + "image/color" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + chromastyles "github.com/alecthomas/chroma/v2/styles" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// SyntaxHighlight applies syntax highlighting to the given source code based +// on the file name and background color. It returns the highlighted code as a +// string. +func SyntaxHighlight(st *styles.Styles, source, fileName string, bg color.Color) (string, error) { + // Determine the language lexer to use + l := lexers.Match(fileName) + if l == nil { + l = lexers.Analyse(source) + } + if l == nil { + l = lexers.Fallback + } + l = chroma.Coalesce(l) + + // Get the formatter + f := formatters.Get("terminal16m") + if f == nil { + f = formatters.Fallback + } + + style := chroma.MustNewStyle("crush", st.ChromaTheme()) + + // Modify the style to use the provided background + s, err := style.Builder().Transform( + func(t chroma.StyleEntry) chroma.StyleEntry { + r, g, b, _ := bg.RGBA() + t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + return t + }, + ).Build() + if err != nil { + s = chromastyles.Fallback + } + + // Tokenize and format + it, err := l.Tokenise(nil, source) + if err != nil { + return "", err + } + + var buf bytes.Buffer + err = f.Format(&buf, s, it) + return buf.String(), err +} diff --git a/internal/ui/common/interface.go b/internal/ui/common/interface.go new file mode 100644 index 0000000000000000000000000000000000000000..a0cef2e9c2b8b0236ffb7a041a551fb0ad41f560 --- /dev/null +++ b/internal/ui/common/interface.go @@ -0,0 +1,11 @@ +package common + +import ( + tea "charm.land/bubbletea/v2" +) + +// Model represents a common interface for UI components. +type Model[T any] interface { + Update(msg tea.Msg) (T, tea.Cmd) + View() string +} diff --git a/internal/ui/common/markdown.go b/internal/ui/common/markdown.go new file mode 100644 index 0000000000000000000000000000000000000000..f5af8121d1667658725b4424a4ab303804c75b42 --- /dev/null +++ b/internal/ui/common/markdown.go @@ -0,0 +1,26 @@ +package common + +import ( + "charm.land/glamour/v2" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with +// the given styles and width. +func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer { + r, _ := glamour.NewTermRenderer( + glamour.WithStyles(sty.Markdown), + glamour.WithWordWrap(width), + ) + return r +} + +// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors +// (plain text with structure) and the given width. +func PlainMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer { + r, _ := glamour.NewTermRenderer( + glamour.WithStyles(sty.PlainMarkdown), + glamour.WithWordWrap(width), + ) + return r +} diff --git a/internal/ui/common/scrollbar.go b/internal/ui/common/scrollbar.go new file mode 100644 index 0000000000000000000000000000000000000000..7e701659348c90100534c18620f5e9949db3d050 --- /dev/null +++ b/internal/ui/common/scrollbar.go @@ -0,0 +1,46 @@ +package common + +import ( + "strings" + + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// Scrollbar renders a vertical scrollbar based on content and viewport size. +// Returns an empty string if content fits within viewport (no scrolling needed). +func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int) string { + if height <= 0 || contentSize <= viewportSize { + return "" + } + + // Calculate thumb size (minimum 1 character). + thumbSize := max(1, height*viewportSize/contentSize) + + // Calculate thumb position. + maxOffset := contentSize - viewportSize + if maxOffset <= 0 { + return "" + } + + // Calculate where the thumb starts. + trackSpace := height - thumbSize + thumbPos := 0 + if trackSpace > 0 && maxOffset > 0 { + thumbPos = min(trackSpace, offset*trackSpace/maxOffset) + } + + // Build the scrollbar. + var sb strings.Builder + for i := range height { + if i > 0 { + sb.WriteString("\n") + } + if i >= thumbPos && i < thumbPos+thumbSize { + sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb)) + } else { + sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack)) + } + } + + return sb.String() +} diff --git a/internal/ui/completions/completions.go b/internal/ui/completions/completions.go new file mode 100644 index 0000000000000000000000000000000000000000..4a4f9d8133491b8a7b80df6066b9e86c7e852a85 --- /dev/null +++ b/internal/ui/completions/completions.go @@ -0,0 +1,267 @@ +package completions + +import ( + "slices" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/ordered" +) + +const ( + minHeight = 1 + maxHeight = 10 + minWidth = 10 + maxWidth = 100 +) + +// SelectionMsg is sent when a completion is selected. +type SelectionMsg struct { + Value any + Insert bool // If true, insert without closing. +} + +// ClosedMsg is sent when the completions are closed. +type ClosedMsg struct{} + +// FilesLoadedMsg is sent when files have been loaded for completions. +type FilesLoadedMsg struct { + Files []string +} + +// Completions represents the completions popup component. +type Completions struct { + // Popup dimensions + width int + height int + + // State + open bool + query string + + // Key bindings + keyMap KeyMap + + // List component + list *list.FilterableList + + // Styling + normalStyle lipgloss.Style + focusedStyle lipgloss.Style + matchStyle lipgloss.Style +} + +// New creates a new completions component. +func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions { + l := list.NewFilterableList() + l.SetGap(0) + l.SetReverse(true) + + return &Completions{ + keyMap: DefaultKeyMap(), + list: l, + normalStyle: normalStyle, + focusedStyle: focusedStyle, + matchStyle: matchStyle, + } +} + +// IsOpen returns whether the completions popup is open. +func (c *Completions) IsOpen() bool { + return c.open +} + +// Query returns the current filter query. +func (c *Completions) Query() string { + return c.query +} + +// Size returns the visible size of the popup. +func (c *Completions) Size() (width, height int) { + visible := len(c.list.VisibleItems()) + return c.width, min(visible, c.height) +} + +// KeyMap returns the key bindings. +func (c *Completions) KeyMap() KeyMap { + return c.keyMap +} + +// OpenWithFiles opens the completions with file items from the filesystem. +func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd { + return func() tea.Msg { + files, _, _ := fsext.ListDirectory(".", nil, depth, limit) + slices.Sort(files) + return FilesLoadedMsg{Files: files} + } +} + +// SetFiles sets the file items on the completions popup. +func (c *Completions) SetFiles(files []string) { + items := make([]list.FilterableItem, 0, len(files)) + width := 0 + for _, file := range files { + file = strings.TrimPrefix(file, "./") + item := NewCompletionItem( + file, + FileCompletionValue{Path: file}, + c.normalStyle, + c.focusedStyle, + c.matchStyle, + ) + + width = max(width, ansi.StringWidth(file)) + items = append(items, item) + } + + c.open = true + c.query = "" + c.list.SetItems(items...) + c.list.SetFilter("") // Clear any previous filter. + c.list.Focus() + + c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) + c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) + c.list.SetSize(c.width, c.height) + c.list.SelectFirst() + c.list.ScrollToSelected() +} + +// Close closes the completions popup. +func (c *Completions) Close() { + c.open = false +} + +// Filter filters the completions with the given query. +func (c *Completions) Filter(query string) { + if !c.open { + return + } + + if query == c.query { + return + } + + c.query = query + c.list.SetFilter(query) + + items := c.list.VisibleItems() + width := 0 + for _, item := range items { + width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text())) + } + c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth)) + c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight)) + c.list.SetSize(c.width, c.height) + c.list.SelectFirst() + c.list.ScrollToSelected() +} + +// HasItems returns whether there are visible items. +func (c *Completions) HasItems() bool { + return len(c.list.VisibleItems()) > 0 +} + +// Update handles key events for the completions. +func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) { + if !c.open { + return nil, false + } + + switch { + case key.Matches(msg, c.keyMap.Up): + c.selectPrev() + return nil, true + + case key.Matches(msg, c.keyMap.Down): + c.selectNext() + return nil, true + + case key.Matches(msg, c.keyMap.UpInsert): + c.selectPrev() + return c.selectCurrent(true), true + + case key.Matches(msg, c.keyMap.DownInsert): + c.selectNext() + return c.selectCurrent(true), true + + case key.Matches(msg, c.keyMap.Select): + return c.selectCurrent(false), true + + case key.Matches(msg, c.keyMap.Cancel): + c.Close() + return ClosedMsg{}, true + } + + return nil, false +} + +// selectPrev selects the previous item with circular navigation. +func (c *Completions) selectPrev() { + items := c.list.VisibleItems() + if len(items) == 0 { + return + } + if !c.list.SelectPrev() { + c.list.WrapToEnd() + } + c.list.ScrollToSelected() +} + +// selectNext selects the next item with circular navigation. +func (c *Completions) selectNext() { + items := c.list.VisibleItems() + if len(items) == 0 { + return + } + if !c.list.SelectNext() { + c.list.WrapToStart() + } + c.list.ScrollToSelected() +} + +// selectCurrent returns a command with the currently selected item. +func (c *Completions) selectCurrent(insert bool) tea.Msg { + items := c.list.VisibleItems() + if len(items) == 0 { + return nil + } + + selected := c.list.Selected() + if selected < 0 || selected >= len(items) { + return nil + } + + item, ok := items[selected].(*CompletionItem) + if !ok { + return nil + } + + if !insert { + c.open = false + } + + return SelectionMsg{ + Value: item.Value(), + Insert: insert, + } +} + +// Render renders the completions popup. +func (c *Completions) Render() string { + if !c.open { + return "" + } + + items := c.list.VisibleItems() + if len(items) == 0 { + return "" + } + + return c.list.Render() +} diff --git a/internal/ui/completions/item.go b/internal/ui/completions/item.go new file mode 100644 index 0000000000000000000000000000000000000000..1114083fd1a118649921ead3ea2288d6e6085632 --- /dev/null +++ b/internal/ui/completions/item.go @@ -0,0 +1,185 @@ +package completions + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" + "github.com/sahilm/fuzzy" +) + +// FileCompletionValue represents a file path completion value. +type FileCompletionValue struct { + Path string +} + +// CompletionItem represents an item in the completions list. +type CompletionItem struct { + text string + value any + match fuzzy.Match + focused bool + cache map[int]string + + // Styles + normalStyle lipgloss.Style + focusedStyle lipgloss.Style + matchStyle lipgloss.Style +} + +// NewCompletionItem creates a new completion item. +func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem { + return &CompletionItem{ + text: text, + value: value, + normalStyle: normalStyle, + focusedStyle: focusedStyle, + matchStyle: matchStyle, + } +} + +// Text returns the display text of the item. +func (c *CompletionItem) Text() string { + return c.text +} + +// Value returns the value of the item. +func (c *CompletionItem) Value() any { + return c.value +} + +// Filter implements [list.FilterableItem]. +func (c *CompletionItem) Filter() string { + return c.text +} + +// SetMatch implements [list.MatchSettable]. +func (c *CompletionItem) SetMatch(m fuzzy.Match) { + c.cache = nil + c.match = m +} + +// SetFocused implements [list.Focusable]. +func (c *CompletionItem) SetFocused(focused bool) { + if c.focused != focused { + c.cache = nil + } + c.focused = focused +} + +// Render implements [list.Item]. +func (c *CompletionItem) Render(width int) string { + return renderItem( + c.normalStyle, + c.focusedStyle, + c.matchStyle, + c.text, + c.focused, + width, + c.cache, + &c.match, + ) +} + +func renderItem( + normalStyle, focusedStyle, matchStyle lipgloss.Style, + text string, + focused bool, + width int, + cache map[int]string, + match *fuzzy.Match, +) string { + if cache == nil { + cache = make(map[int]string) + } + + cached, ok := cache[width] + if ok { + return cached + } + + innerWidth := width - 2 // Account for padding + // Truncate if needed. + if ansi.StringWidth(text) > innerWidth { + text = ansi.Truncate(text, innerWidth, "…") + } + + // Select base style. + style := normalStyle + matchStyle = matchStyle.Background(style.GetBackground()) + if focused { + style = focusedStyle + matchStyle = matchStyle.Background(style.GetBackground()) + } + + // Render full-width text with background. + content := style.Padding(0, 1).Width(width).Render(text) + + // Apply match highlighting using StyleRanges. + if len(match.MatchedIndexes) > 0 { + var ranges []lipgloss.Range + for _, rng := range matchedRanges(match.MatchedIndexes) { + start, stop := bytePosToVisibleCharPos(text, rng) + // Offset by 1 for the padding space. + ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle)) + } + content = lipgloss.StyleRanges(content, ranges...) + } + + cache[width] = content + return content +} + +// matchedRanges converts a list of match indexes into contiguous ranges. +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} + } + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} + } + var out [][2]int + for i := 1; i < len(in); i++ { + if in[i] == current[1]+1 { + current[1] = in[i] + } else { + out = append(out, current) + current = [2]int{in[i], in[i]} + } + } + out = append(out, current) + return out +} + +// bytePosToVisibleCharPos converts byte positions to visible character positions. +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} + +// Ensure CompletionItem implements the required interfaces. +var ( + _ list.Item = (*CompletionItem)(nil) + _ list.FilterableItem = (*CompletionItem)(nil) + _ list.MatchSettable = (*CompletionItem)(nil) + _ list.Focusable = (*CompletionItem)(nil) +) diff --git a/internal/ui/completions/keys.go b/internal/ui/completions/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..d150f1a96b05018bfeaf6fea0b45d2c5ea65ac06 --- /dev/null +++ b/internal/ui/completions/keys.go @@ -0,0 +1,74 @@ +package completions + +import ( + "charm.land/bubbles/v2/key" +) + +// KeyMap defines the key bindings for the completions component. +type KeyMap struct { + Down, + Up, + Select, + Cancel key.Binding + DownInsert, + UpInsert key.Binding +} + +// DefaultKeyMap returns the default key bindings for completions. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("down", "move down"), + ), + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("up", "move up"), + ), + Select: key.NewBinding( + key.WithKeys("enter", "tab", "ctrl+y"), + key.WithHelp("enter", "select"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel"), + ), + DownInsert: key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "insert next"), + ), + UpInsert: key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "insert previous"), + ), + } +} + +// KeyBindings returns all key bindings as a slice. +func (k KeyMap) KeyBindings() []key.Binding { + return []key.Binding{ + k.Down, + k.Up, + k.Select, + k.Cancel, + } +} + +// FullHelp returns the full help for the key bindings. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := k.KeyBindings() + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp returns the short help for the key bindings. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.Up, + k.Down, + } +} diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go new file mode 100644 index 0000000000000000000000000000000000000000..b5db01692437dbee4b11b77da47b68f258b090e9 --- /dev/null +++ b/internal/ui/dialog/actions.go @@ -0,0 +1,165 @@ +package dialog + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/commands" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" +) + +// ActionClose is a message to close the current dialog. +type ActionClose struct{} + +// ActionQuit is a message to quit the application. +type ActionQuit = tea.QuitMsg + +// ActionOpenDialog is a message to open a dialog. +type ActionOpenDialog struct { + DialogID string +} + +// ActionSelectSession is a message indicating a session has been selected. +type ActionSelectSession struct { + Session session.Session +} + +// ActionSelectModel is a message indicating a model has been selected. +type ActionSelectModel struct { + Provider catwalk.Provider + Model config.SelectedModel + ModelType config.SelectedModelType +} + +// Messages for commands +type ( + ActionNewSession struct{} + ActionToggleHelp struct{} + ActionToggleCompactMode struct{} + ActionToggleThinking struct{} + ActionExternalEditor struct{} + ActionToggleYoloMode struct{} + // ActionInitializeProject is a message to initialize a project. + ActionInitializeProject struct{} + ActionSummarize struct { + SessionID string + } + // ActionSelectReasoningEffort is a message indicating a reasoning effort has been selected. + ActionSelectReasoningEffort struct { + Effort string + } + ActionPermissionResponse struct { + Permission permission.PermissionRequest + Action PermissionAction + } + // ActionRunCustomCommand is a message to run a custom command. + ActionRunCustomCommand struct { + Content string + Arguments []commands.Argument + Args map[string]string // Actual argument values + } + // ActionRunMCPPrompt is a message to run a custom command. + ActionRunMCPPrompt struct { + Title string + Description string + PromptID string + ClientID string + Arguments []commands.Argument + Args map[string]string // Actual argument values + } +) + +// Messages for API key input dialog. +type ( + ActionChangeAPIKeyState struct { + State APIKeyInputState + } +) + +// Messages for OAuth2 device flow dialog. +type ( + // ActionInitiateOAuth is sent when the device auth is initiated + // successfully. + ActionInitiateOAuth struct { + DeviceCode string + UserCode string + ExpiresIn int + VerificationURL string + Interval int + } + + // ActionCompleteOAuth is sent when the device flow completes successfully. + ActionCompleteOAuth struct { + Token *oauth.Token + } + + // ActionOAuthErrored is sent when the device flow encounters an error. + ActionOAuthErrored struct { + Error error + } +) + +// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the +// Bubble Tea program loop. +type ActionCmd struct { + Cmd tea.Cmd +} + +// ActionFilePickerSelected is a message indicating a file has been selected in +// the file picker dialog. +type ActionFilePickerSelected struct { + Path string +} + +// Cmd returns a command that reads the file at path and sends a +// [message.Attachement] to the program. +func (a ActionFilePickerSelected) Cmd() tea.Cmd { + path := a.Path + if path == "" { + return nil + } + return func() tea.Msg { + isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize) + if err != nil { + return uiutil.InfoMsg{ + Type: uiutil.InfoTypeError, + Msg: fmt.Sprintf("unable to read the image: %v", err), + } + } + if isFileLarge { + return uiutil.InfoMsg{ + Type: uiutil.InfoTypeError, + Msg: "file too large, max 5MB", + } + } + + content, err := os.ReadFile(path) + if err != nil { + return uiutil.InfoMsg{ + Type: uiutil.InfoTypeError, + Msg: fmt.Sprintf("unable to read the image: %v", err), + } + } + + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + fileName := filepath.Base(path) + + return message.Attachment{ + FilePath: path, + FileName: fileName, + MimeType: mimeType, + Content: content, + } + } +} diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go new file mode 100644 index 0000000000000000000000000000000000000000..430f7b4629294faa83bad9b5b90ca363ceb6f1b7 --- /dev/null +++ b/internal/ui/dialog/api_key_input.go @@ -0,0 +1,302 @@ +package dialog + +import ( + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/exp/charmtone" +) + +type APIKeyInputState int + +const ( + APIKeyInputStateInitial APIKeyInputState = iota + APIKeyInputStateVerifying + APIKeyInputStateVerified + APIKeyInputStateError +) + +// APIKeyInputID is the identifier for the model selection dialog. +const APIKeyInputID = "api_key_input" + +// APIKeyInput represents a model selection dialog. +type APIKeyInput struct { + com *common.Common + + provider catwalk.Provider + model config.SelectedModel + modelType config.SelectedModelType + + width int + state APIKeyInputState + + keyMap struct { + Submit key.Binding + Close key.Binding + } + input textinput.Model + spinner spinner.Model + help help.Model +} + +var _ Dialog = (*APIKeyInput)(nil) + +// NewAPIKeyInput creates a new Models dialog. +func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, tea.Cmd) { + t := com.Styles + + m := APIKeyInput{} + m.com = com + m.provider = provider + m.model = model + m.modelType = modelType + m.width = 60 + + innerWidth := m.width - t.Dialog.View.GetHorizontalFrameSize() - 2 + + m.input = textinput.New() + m.input.SetVirtualCursor(false) + m.input.Placeholder = "Enter you API key..." + m.input.SetStyles(com.Styles.TextInput) + m.input.Focus() + m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + + m.spinner = spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(t.Base.Foreground(t.Green)), + ) + + m.help = help.New() + m.help.Styles = t.DialogHelpStyles() + + m.keyMap.Submit = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "submit"), + ) + m.keyMap.Close = CloseKey + + return &m, nil +} + +// ID implements Dialog. +func (m *APIKeyInput) ID() string { + return APIKeyInputID +} + +// HandleMsg implements [Dialog]. +func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case ActionChangeAPIKeyState: + m.state = msg.State + switch m.state { + case APIKeyInputStateVerifying: + cmd := tea.Batch(m.spinner.Tick, m.verifyAPIKey) + return ActionCmd{cmd} + } + case spinner.TickMsg: + switch m.state { + case APIKeyInputStateVerifying: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + case tea.KeyPressMsg: + switch { + case m.state == APIKeyInputStateVerifying: + // do nothing + case key.Matches(msg, m.keyMap.Close): + switch m.state { + case APIKeyInputStateVerified: + return m.saveKeyAndContinue() + default: + return ActionClose{} + } + case key.Matches(msg, m.keyMap.Submit): + switch m.state { + case APIKeyInputStateInitial, APIKeyInputStateError: + return ActionChangeAPIKeyState{State: APIKeyInputStateVerifying} + case APIKeyInputStateVerified: + return m.saveKeyAndContinue() + } + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + case tea.PasteMsg: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + return nil +} + +// Draw implements [Dialog]. +func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := m.com.Styles + + textStyle := t.Dialog.SecondaryText + helpStyle := t.Dialog.HelpView + dialogStyle := t.Dialog.View.Width(m.width) + inputStyle := t.Dialog.InputPrompt + helpStyle = helpStyle.Width(m.width - dialogStyle.GetHorizontalFrameSize()) + + m.input.Prompt = m.spinner.View() + + content := strings.Join([]string{ + m.headerView(), + inputStyle.Render(m.inputView()), + textStyle.Render("This will be written in your global configuration:"), + textStyle.Render(config.GlobalConfigData()), + "", + helpStyle.Render(m.help.View(m)), + }, "\n") + + view := dialogStyle.Render(content) + + cur := m.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur +} + +func (m *APIKeyInput) headerView() string { + t := m.com.Styles + titleStyle := t.Dialog.Title + dialogStyle := t.Dialog.View.Width(m.width) + + headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() + return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset) +} + +func (m *APIKeyInput) dialogTitle() string { + t := m.com.Styles + textStyle := t.Dialog.TitleText + errorStyle := t.Dialog.TitleError + accentStyle := t.Dialog.TitleAccent + + switch m.state { + case APIKeyInputStateInitial: + return textStyle.Render("Enter your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(".") + case APIKeyInputStateVerifying: + return textStyle.Render("Verifying your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render("...") + case APIKeyInputStateVerified: + return accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(" validated.") + case APIKeyInputStateError: + return errorStyle.Render("Invalid ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + errorStyle.Render(". Try again?") + } + return "" +} + +func (m *APIKeyInput) inputView() string { + t := m.com.Styles + + switch m.state { + case APIKeyInputStateInitial: + m.input.Prompt = "> " + m.input.SetStyles(t.TextInput) + m.input.Focus() + case APIKeyInputStateVerifying: + ts := t.TextInput + ts.Blurred.Prompt = ts.Focused.Prompt + + m.input.Prompt = m.spinner.View() + m.input.SetStyles(ts) + m.input.Blur() + case APIKeyInputStateVerified: + ts := t.TextInput + ts.Blurred.Prompt = ts.Focused.Prompt + + m.input.Prompt = styles.CheckIcon + " " + m.input.SetStyles(ts) + m.input.Blur() + case APIKeyInputStateError: + ts := t.TextInput + ts.Focused.Prompt = ts.Focused.Prompt.Foreground(charmtone.Cherry) + + m.input.Prompt = styles.ErrorIcon + " " + m.input.SetStyles(ts) + m.input.Focus() + } + return m.input.View() +} + +// Cursor returns the cursor position relative to the dialog. +func (m *APIKeyInput) Cursor() *tea.Cursor { + return InputCursor(m.com.Styles, m.input.Cursor()) +} + +// FullHelp returns the full help view. +func (m *APIKeyInput) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + m.keyMap.Submit, + m.keyMap.Close, + }, + } +} + +// ShortHelp returns the full help view. +func (m *APIKeyInput) ShortHelp() []key.Binding { + return []key.Binding{ + m.keyMap.Submit, + m.keyMap.Close, + } +} + +func (m *APIKeyInput) verifyAPIKey() tea.Msg { + start := time.Now() + + providerConfig := config.ProviderConfig{ + ID: string(m.provider.ID), + Name: m.provider.Name, + APIKey: m.input.Value(), + Type: m.provider.Type, + BaseURL: m.provider.APIEndpoint, + } + err := providerConfig.TestConnection(config.Get().Resolver()) + + // intentionally wait for at least 750ms to make sure the user sees the spinner + elapsed := time.Since(start) + minimum := 750 * time.Millisecond + if elapsed < minimum { + time.Sleep(minimum - elapsed) + } + + if err == nil { + return ActionChangeAPIKeyState{APIKeyInputStateVerified} + } + return ActionChangeAPIKeyState{APIKeyInputStateError} +} + +func (m *APIKeyInput) saveKeyAndContinue() Action { + cfg := m.com.Config() + + err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value()) + if err != nil { + return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))} + } + + return ActionSelectModel{ + Provider: m.provider, + Model: m.model, + ModelType: m.modelType, + } +} diff --git a/internal/ui/dialog/arguments.go b/internal/ui/dialog/arguments.go new file mode 100644 index 0000000000000000000000000000000000000000..c016b7de6ec77e6e333d2b0f18ae5930ba0912fc --- /dev/null +++ b/internal/ui/dialog/arguments.go @@ -0,0 +1,399 @@ +package dialog + +import ( + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/charmbracelet/crush/internal/commands" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" +) + +// ArgumentsID is the identifier for the arguments dialog. +const ArgumentsID = "arguments" + +// Dialog sizing for arguments. +const ( + maxInputWidth = 120 + minInputWidth = 30 + maxViewportHeight = 20 + argumentsFieldHeight = 3 // label + input + spacing per field +) + +// Arguments represents a dialog for collecting command arguments. +type Arguments struct { + com *common.Common + title string + arguments []commands.Argument + inputs []textinput.Model + focused int + spinner spinner.Model + loading bool + + description string + resultAction Action + + help help.Model + keyMap struct { + Confirm, + Next, + Previous, + ScrollUp, + ScrollDown, + Close key.Binding + } + + viewport viewport.Model +} + +var _ Dialog = (*Arguments)(nil) + +// NewArguments creates a new arguments dialog. +func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments { + a := &Arguments{ + com: com, + title: title, + description: description, + arguments: arguments, + resultAction: resultAction, + } + + a.help = help.New() + a.help.Styles = com.Styles.DialogHelpStyles() + + a.keyMap.Confirm = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ) + a.keyMap.Next = key.NewBinding( + key.WithKeys("down", "tab"), + key.WithHelp("↓/tab", "next"), + ) + a.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "shift+tab"), + key.WithHelp("↑/shift+tab", "previous"), + ) + a.keyMap.Close = CloseKey + + // Create input fields for each argument. + a.inputs = make([]textinput.Model, len(arguments)) + for i, arg := range arguments { + input := textinput.New() + input.SetVirtualCursor(false) + input.SetStyles(com.Styles.TextInput) + input.Prompt = "> " + // Use description as placeholder if available, otherwise title + if arg.Description != "" { + input.Placeholder = arg.Description + } else { + input.Placeholder = arg.Title + } + + if i == 0 { + input.Focus() + } else { + input.Blur() + } + + a.inputs[i] = input + } + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = com.Styles.Dialog.Spinner + a.spinner = s + + return a +} + +// ID implements Dialog. +func (a *Arguments) ID() string { + return ArgumentsID +} + +// focusInput changes focus to a new input by index with wrap-around. +func (a *Arguments) focusInput(newIndex int) { + a.inputs[a.focused].Blur() + + // Wrap around: Go's modulo can return negative, so add len first. + n := len(a.inputs) + a.focused = ((newIndex % n) + n) % n + + a.inputs[a.focused].Focus() + + // Ensure the newly focused field is visible in the viewport + a.ensureFieldVisible(a.focused) +} + +// isFieldVisible checks if a field at the given index is visible in the viewport. +func (a *Arguments) isFieldVisible(fieldIndex int) bool { + fieldStart := fieldIndex * argumentsFieldHeight + fieldEnd := fieldStart + argumentsFieldHeight - 1 + viewportTop := a.viewport.YOffset() + viewportBottom := viewportTop + a.viewport.Height() - 1 + + return fieldStart >= viewportTop && fieldEnd <= viewportBottom +} + +// ensureFieldVisible scrolls the viewport to make the field visible. +func (a *Arguments) ensureFieldVisible(fieldIndex int) { + if a.isFieldVisible(fieldIndex) { + return + } + + fieldStart := fieldIndex * argumentsFieldHeight + fieldEnd := fieldStart + argumentsFieldHeight - 1 + viewportTop := a.viewport.YOffset() + viewportHeight := a.viewport.Height() + + // If field is above viewport, scroll up to show it at top + if fieldStart < viewportTop { + a.viewport.SetYOffset(fieldStart) + return + } + + // If field is below viewport, scroll down to show it at bottom + if fieldEnd > viewportTop+viewportHeight-1 { + a.viewport.SetYOffset(fieldEnd - viewportHeight + 1) + } +} + +// findVisibleFieldByOffset returns the field index closest to the given viewport offset. +func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int { + offset := a.viewport.YOffset() + if !fromTop { + offset += a.viewport.Height() - 1 + } + + fieldIndex := offset / argumentsFieldHeight + if fieldIndex >= len(a.inputs) { + return len(a.inputs) - 1 + } + return fieldIndex +} + +// HandleMsg implements Dialog. +func (a *Arguments) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case spinner.TickMsg: + if a.loading { + var cmd tea.Cmd + a.spinner, cmd = a.spinner.Update(msg) + return ActionCmd{Cmd: cmd} + } + case tea.KeyPressMsg: + switch { + case key.Matches(msg, a.keyMap.Close): + return ActionClose{} + case key.Matches(msg, a.keyMap.Confirm): + // If we're on the last input or there's only one input, submit. + if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 { + args := make(map[string]string) + var warning tea.Cmd + for i, arg := range a.arguments { + args[arg.ID] = a.inputs[i].Value() + if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" { + warning = uiutil.ReportWarn("Required argument '" + arg.Title + "' is missing.") + break + } + } + if warning != nil { + return ActionCmd{Cmd: warning} + } + + switch action := a.resultAction.(type) { + case ActionRunCustomCommand: + action.Args = args + return action + case ActionRunMCPPrompt: + action.Args = args + return action + } + } + a.focusInput(a.focused + 1) + case key.Matches(msg, a.keyMap.Next): + a.focusInput(a.focused + 1) + case key.Matches(msg, a.keyMap.Previous): + a.focusInput(a.focused - 1) + default: + var cmd tea.Cmd + a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg) + return ActionCmd{Cmd: cmd} + } + case tea.MouseWheelMsg: + a.viewport, _ = a.viewport.Update(msg) + // If focused field scrolled out of view, focus the visible field + if !a.isFieldVisible(a.focused) { + a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown)) + } + case tea.PasteMsg: + var cmd tea.Cmd + a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg) + return ActionCmd{Cmd: cmd} + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +// we pass the description height to offset the cursor correctly. +func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor { + cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor()) + if cursor == nil { + return nil + } + cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1 + return cursor +} + +// Draw implements Dialog. +func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + s := a.com.Styles + + dialogContentStyle := s.Dialog.Arguments.Content + possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize() + // Build fields with label and input. + caser := cases.Title(language.English) + + var fields []string + for i, arg := range a.arguments { + isFocused := i == a.focused + + // Try to pretty up the title for the label. + title := strings.ReplaceAll(arg.Title, "_", " ") + title = strings.ReplaceAll(title, "-", " ") + titleParts := strings.Fields(title) + for i, part := range titleParts { + titleParts[i] = caser.String(strings.ToLower(part)) + } + labelText := strings.Join(titleParts, " ") + + markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred + + labelStyle := s.Dialog.Arguments.InputLabelBlurred + if isFocused { + labelStyle = s.Dialog.Arguments.InputLabelFocused + markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused + } + if arg.Required { + labelText += markRequiredStyle.String() + } + label := labelStyle.Render(labelText) + + labelWidth := lipgloss.Width(labelText) + placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder) + + inputWidth := max(placeholderWidth, labelWidth, minInputWidth) + inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth)) + a.inputs[i].SetWidth(inputWidth) + + inputLine := a.inputs[i].View() + + field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "") + fields = append(fields, field) + } + + renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...) + + // Anchor width to the longest field, capped at maxInputWidth. + const scrollbarWidth = 1 + width := lipgloss.Width(renderedFields) + height := lipgloss.Height(renderedFields) + + // Use standard header + titleStyle := s.Dialog.Title + + titleText := a.title + if titleText == "" { + titleText = "Arguments" + } + + header := common.DialogTitle(s, titleText, width) + + // Add description if available. + var description string + if a.description != "" { + descStyle := s.Dialog.Arguments.Description.Width(width) + description = descStyle.Render(a.description) + } + + helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a)) + if a.loading { + helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...") + } + + availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing + viewportHeight := min(height, maxViewportHeight, availableHeight) + + a.viewport.SetWidth(width) // -1 for scrollbar + a.viewport.SetHeight(viewportHeight) + a.viewport.SetContent(renderedFields) + + scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset()) + content := a.viewport.View() + if scrollbar != "" { + content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar) + } + contentParts := []string{} + if description != "" { + contentParts = append(contentParts, description) + } + contentParts = append(contentParts, content) + + view := lipgloss.JoinVertical( + lipgloss.Left, + titleStyle.Render(header), + dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)), + helpView, + ) + + dialog := s.Dialog.View.Render(view) + + descriptionHeight := 0 + if a.description != "" { + descriptionHeight = lipgloss.Height(description) + } + cur := a.Cursor(descriptionHeight) + + DrawCenterCursor(scr, area, dialog, cur) + return cur +} + +// StartLoading implements [LoadingDialog]. +func (a *Arguments) StartLoading() tea.Cmd { + if a.loading { + return nil + } + a.loading = true + return a.spinner.Tick +} + +// StopLoading implements [LoadingDialog]. +func (a *Arguments) StopLoading() { + a.loading = false +} + +// ShortHelp implements help.KeyMap. +func (a *Arguments) ShortHelp() []key.Binding { + return []key.Binding{ + a.keyMap.Confirm, + a.keyMap.Next, + a.keyMap.Close, + } +} + +// FullHelp implements help.KeyMap. +func (a *Arguments) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous}, + {a.keyMap.Close}, + } +} diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..9039a2457d3c86ba21886deac4137970f861fd59 --- /dev/null +++ b/internal/ui/dialog/commands.go @@ -0,0 +1,477 @@ +package dialog + +import ( + "os" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/agent/hyper" + "github.com/charmbracelet/crush/internal/commands" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" +) + +// CommandsID is the identifier for the commands dialog. +const CommandsID = "commands" + +// CommandType represents the type of commands being displayed. +type CommandType uint + +// String returns the string representation of the CommandType. +func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] } + +const sidebarCompactModeBreakpoint = 120 + +const ( + SystemCommands CommandType = iota + UserCommands + MCPPrompts +) + +// Commands represents a dialog that shows available commands. +type Commands struct { + com *common.Common + keyMap struct { + Select, + UpDown, + Next, + Previous, + Tab, + ShiftTab, + Close key.Binding + } + + sessionID string // can be empty for non-session-specific commands + selected CommandType + + spinner spinner.Model + loading bool + + help help.Model + input textinput.Model + list *list.FilterableList + + windowWidth int + + customCommands []commands.CustomCommand + mcpPrompts []commands.MCPPrompt +} + +var _ Dialog = (*Commands)(nil) + +// NewCommands creates a new commands dialog. +func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) { + c := &Commands{ + com: com, + selected: SystemCommands, + sessionID: sessionID, + customCommands: customCommands, + mcpPrompts: mcpPrompts, + } + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + c.help = help + + c.list = list.NewFilterableList() + c.list.Focus() + c.list.SetSelected(0) + + c.input = textinput.New() + c.input.SetVirtualCursor(false) + c.input.Placeholder = "Type to filter" + c.input.SetStyles(com.Styles.TextInput) + c.input.Focus() + + c.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ) + c.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) + c.keyMap.Next = key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "next item"), + ) + c.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + c.keyMap.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch selection"), + ) + c.keyMap.ShiftTab = key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "switch selection prev"), + ) + closeKey := CloseKey + closeKey.SetHelp("esc", "cancel") + c.keyMap.Close = closeKey + + // Set initial commands + c.setCommandItems(c.selected) + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = com.Styles.Dialog.Spinner + c.spinner = s + + return c, nil +} + +// ID implements Dialog. +func (c *Commands) ID() string { + return CommandsID +} + +// HandleMsg implements [Dialog]. +func (c *Commands) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case spinner.TickMsg: + if c.loading { + var cmd tea.Cmd + c.spinner, cmd = c.spinner.Update(msg) + return ActionCmd{Cmd: cmd} + } + case tea.KeyPressMsg: + switch { + case key.Matches(msg, c.keyMap.Close): + return ActionClose{} + case key.Matches(msg, c.keyMap.Previous): + c.list.Focus() + if c.list.IsSelectedFirst() { + c.list.SelectLast() + c.list.ScrollToBottom() + break + } + c.list.SelectPrev() + c.list.ScrollToSelected() + case key.Matches(msg, c.keyMap.Next): + c.list.Focus() + if c.list.IsSelectedLast() { + c.list.SelectFirst() + c.list.ScrollToTop() + break + } + c.list.SelectNext() + c.list.ScrollToSelected() + case key.Matches(msg, c.keyMap.Select): + if selectedItem := c.list.SelectedItem(); selectedItem != nil { + if item, ok := selectedItem.(*CommandItem); ok && item != nil { + return item.Action() + } + } + case key.Matches(msg, c.keyMap.Tab): + if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 { + c.selected = c.nextCommandType() + c.setCommandItems(c.selected) + } + case key.Matches(msg, c.keyMap.ShiftTab): + if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 { + c.selected = c.previousCommandType() + c.setCommandItems(c.selected) + } + default: + var cmd tea.Cmd + for _, item := range c.list.VisibleItems() { + if item, ok := item.(*CommandItem); ok && item != nil { + if msg.String() == item.Shortcut() { + return item.Action() + } + } + } + c.input, cmd = c.input.Update(msg) + value := c.input.Value() + c.list.SetFilter(value) + c.list.ScrollToTop() + c.list.SetSelected(0) + return ActionCmd{cmd} + } + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +func (c *Commands) Cursor() *tea.Cursor { + return InputCursor(c.com.Styles, c.input.Cursor()) +} + +// commandsRadioView generates the command type selector radio buttons. +func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string { + if !hasUserCmds && !hasMCPPrompts { + return "" + } + + selectedFn := func(t CommandType) string { + if t == selected { + return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String()) + } + return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String()) + } + + parts := []string{ + selectedFn(SystemCommands), + } + + if hasUserCmds { + parts = append(parts, selectedFn(UserCommands)) + } + if hasMCPPrompts { + parts = append(parts, selectedFn(MCPPrompts)) + } + + return strings.Join(parts, " ") +} + +// Draw implements [Dialog]. +func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := c.com.Styles + width := max(0, min(defaultDialogMaxWidth, area.Dx())) + height := max(0, min(defaultDialogHeight, area.Dy())) + if area.Dx() != c.windowWidth && c.selected == SystemCommands { + c.windowWidth = area.Dx() + // since some items in the list depend on width (e.g. toggle sidebar command), + // we need to reset the command items when width changes + c.setCommandItems(c.selected) + } + + innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + + c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + c.list.SetSize(innerWidth, height-heightOffset) + c.help.SetWidth(innerWidth) + + rc := NewRenderContext(t, width) + rc.Title = "Commands" + rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0) + inputView := t.Dialog.InputPrompt.Render(c.input.View()) + rc.AddPart(inputView) + listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render()) + rc.AddPart(listView) + rc.Help = c.help.View(c) + + if c.loading { + rc.Help = c.spinner.View() + " Generating Prompt..." + } + + view := rc.Render() + + cur := c.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur +} + +// ShortHelp implements [help.KeyMap]. +func (c *Commands) ShortHelp() []key.Binding { + return []key.Binding{ + c.keyMap.Tab, + c.keyMap.UpDown, + c.keyMap.Select, + c.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (c *Commands) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab}, + {c.keyMap.Close}, + } +} + +// nextCommandType returns the next command type in the cycle. +func (c *Commands) nextCommandType() CommandType { + switch c.selected { + case SystemCommands: + if len(c.customCommands) > 0 { + return UserCommands + } + if len(c.mcpPrompts) > 0 { + return MCPPrompts + } + fallthrough + case UserCommands: + if len(c.mcpPrompts) > 0 { + return MCPPrompts + } + fallthrough + case MCPPrompts: + return SystemCommands + default: + return SystemCommands + } +} + +// previousCommandType returns the previous command type in the cycle. +func (c *Commands) previousCommandType() CommandType { + switch c.selected { + case SystemCommands: + if len(c.mcpPrompts) > 0 { + return MCPPrompts + } + if len(c.customCommands) > 0 { + return UserCommands + } + return SystemCommands + case UserCommands: + return SystemCommands + case MCPPrompts: + if len(c.customCommands) > 0 { + return UserCommands + } + return SystemCommands + default: + return SystemCommands + } +} + +// setCommandItems sets the command items based on the specified command type. +func (c *Commands) setCommandItems(commandType CommandType) { + c.selected = commandType + + commandItems := []list.FilterableItem{} + switch c.selected { + case SystemCommands: + for _, cmd := range c.defaultCommands() { + commandItems = append(commandItems, cmd) + } + case UserCommands: + for _, cmd := range c.customCommands { + action := ActionRunCustomCommand{ + Content: cmd.Content, + Arguments: cmd.Arguments, + } + commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)) + } + case MCPPrompts: + for _, cmd := range c.mcpPrompts { + action := ActionRunMCPPrompt{ + Title: cmd.Title, + Description: cmd.Description, + PromptID: cmd.PromptID, + ClientID: cmd.ClientID, + Arguments: cmd.Arguments, + } + commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action)) + } + } + + c.list.SetItems(commandItems...) + c.list.SetFilter("") + c.list.ScrollToTop() + c.list.SetSelected(0) + c.input.SetValue("") +} + +// defaultCommands returns the list of default system commands. +func (c *Commands) defaultCommands() []*CommandItem { + commands := []*CommandItem{ + NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}), + NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}), + NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}), + } + + // Only show compact command if there's an active session + if c.sessionID != "" { + commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID})) + } + + // Add reasoning toggle for models that support it + cfg := c.com.Config() + if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok { + providerCfg := cfg.GetProviderForModel(agentCfg.Model) + model := cfg.GetModelByType(agentCfg.Model) + if providerCfg != nil && model != nil && model.CanReason { + selectedModel := cfg.Models[agentCfg.Model] + + // Anthropic models: thinking toggle + if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) { + status := "Enable" + if selectedModel.Think { + status = "Disable" + } + commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{})) + } + + // OpenAI models: reasoning effort dialog + if len(model.ReasoningLevels) > 0 { + commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{ + DialogID: ReasoningID, + })) + } + } + } + // Only show toggle compact mode command if window width is larger than compact breakpoint (120) + if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" { + commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{})) + } + if c.sessionID != "" { + cfg := c.com.Config() + agentCfg := cfg.Agents[config.AgentCoder] + model := cfg.GetModelByType(agentCfg.Model) + if model != nil && model.SupportsImages { + commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{ + // TODO: Pass in the file picker dialog id + })) + } + } + + // Add external editor command if $EDITOR is available + // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv + if os.Getenv("EDITOR") != "" { + commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{})) + } + + return append(commands, + NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}), + NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}), + NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}), + NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}), + ) +} + +// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed. +func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) { + c.customCommands = customCommands + if c.selected == UserCommands { + c.setCommandItems(c.selected) + } +} + +// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed. +func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) { + c.mcpPrompts = mcpPrompts + if c.selected == MCPPrompts { + c.setCommandItems(c.selected) + } +} + +// StartLoading implements [LoadingDialog]. +func (a *Commands) StartLoading() tea.Cmd { + if a.loading { + return nil + } + a.loading = true + return a.spinner.Tick +} + +// StopLoading implements [LoadingDialog]. +func (a *Commands) StopLoading() { + a.loading = false +} diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go new file mode 100644 index 0000000000000000000000000000000000000000..9a2cf2ceef2be54c6f8d9897d4ddd923fd07b80f --- /dev/null +++ b/internal/ui/dialog/commands_item.go @@ -0,0 +1,70 @@ +package dialog + +import ( + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/sahilm/fuzzy" +) + +// CommandItem wraps a uicmd.Command to implement the ListItem interface. +type CommandItem struct { + id string + title string + shortcut string + action Action + t *styles.Styles + m fuzzy.Match + cache map[int]string + focused bool +} + +var _ ListItem = &CommandItem{} + +// NewCommandItem creates a new CommandItem. +func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem { + return &CommandItem{ + id: id, + t: t, + title: title, + shortcut: shortcut, + action: action, + } +} + +// Filter implements ListItem. +func (c *CommandItem) Filter() string { + return c.title +} + +// ID implements ListItem. +func (c *CommandItem) ID() string { + return c.id +} + +// SetFocused implements ListItem. +func (c *CommandItem) SetFocused(focused bool) { + if c.focused != focused { + c.cache = nil + } + c.focused = focused +} + +// SetMatch implements ListItem. +func (c *CommandItem) SetMatch(m fuzzy.Match) { + c.cache = nil + c.m = m +} + +// Action returns the action associated with the command item. +func (c *CommandItem) Action() Action { + return c.action +} + +// Shortcut returns the shortcut associated with the command item. +func (c *CommandItem) Shortcut() string { + return c.shortcut +} + +// Render implements ListItem. +func (c *CommandItem) Render(width int) string { + return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m) +} diff --git a/internal/ui/dialog/common.go b/internal/ui/dialog/common.go new file mode 100644 index 0000000000000000000000000000000000000000..76b75064670935715f03e0d732b9df5070b9e9da --- /dev/null +++ b/internal/ui/dialog/common.go @@ -0,0 +1,130 @@ +package dialog + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// InputCursor adjusts the cursor position for an input field within a dialog. +func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor { + if cur != nil { + titleStyle := t.Dialog.Title + dialogStyle := t.Dialog.View + inputStyle := t.Dialog.InputPrompt + // Adjust cursor position to account for dialog layout + cur.X += inputStyle.GetBorderLeftSize() + + inputStyle.GetMarginLeft() + + inputStyle.GetPaddingLeft() + + dialogStyle.GetBorderLeftSize() + + dialogStyle.GetPaddingLeft() + + dialogStyle.GetMarginLeft() + cur.Y += titleStyle.GetVerticalFrameSize() + + inputStyle.GetBorderTopSize() + + inputStyle.GetMarginTop() + + inputStyle.GetPaddingTop() + + inputStyle.GetBorderBottomSize() + + inputStyle.GetMarginBottom() + + inputStyle.GetPaddingBottom() + + dialogStyle.GetPaddingTop() + + dialogStyle.GetMarginTop() + + dialogStyle.GetBorderTopSize() + } + return cur +} + +// RenderContext is a dialog rendering context that can be used to render +// common dialog layouts. +type RenderContext struct { + // Styles is the styles to use for rendering. + Styles *styles.Styles + // Width is the total width of the dialog including any margins, borders, + // and paddings. + Width int + // Gap is the gap between content parts. Zero means no gap. + Gap int + // Title is the title of the dialog. This will be styled using the default + // dialog title style and prepended to the content parts slice. + Title string + // TitleInfo is additional information to display next to the title. This + // part is displayed as is, any styling must be applied before setting this + // field. + TitleInfo string + // Parts are the rendered parts of the dialog. + Parts []string + // Help is the help view content. This will be appended to the content parts + // slice using the default dialog help style. + Help string +} + +// NewRenderContext creates a new RenderContext with the provided styles and width. +func NewRenderContext(t *styles.Styles, width int) *RenderContext { + return &RenderContext{ + Styles: t, + Width: width, + Parts: []string{}, + } +} + +// AddPart adds a rendered part to the dialog. +func (rc *RenderContext) AddPart(part string) { + if len(part) > 0 { + rc.Parts = append(rc.Parts, part) + } +} + +// Render renders the dialog using the provided context. +func (rc *RenderContext) Render() string { + titleStyle := rc.Styles.Dialog.Title + dialogStyle := rc.Styles.Dialog.View.Width(rc.Width) + + parts := []string{} + if len(rc.Title) > 0 { + var titleInfoWidth int + if len(rc.TitleInfo) > 0 { + titleInfoWidth = lipgloss.Width(rc.TitleInfo) + } + title := common.DialogTitle(rc.Styles, rc.Title, + max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()- + titleStyle.GetHorizontalFrameSize()- + titleInfoWidth)) + if len(rc.TitleInfo) > 0 { + title += rc.TitleInfo + } + parts = append(parts, titleStyle.Render(title)) + if rc.Gap > 0 { + parts = append(parts, make([]string, rc.Gap)...) + } + } + + if rc.Gap <= 0 { + parts = append(parts, rc.Parts...) + } else { + for i, p := range rc.Parts { + if len(p) > 0 { + parts = append(parts, p) + } + if i < len(rc.Parts)-1 { + parts = append(parts, make([]string, rc.Gap)...) + } + } + } + + if len(rc.Help) > 0 { + if rc.Gap > 0 { + parts = append(parts, make([]string, rc.Gap)...) + } + helpStyle := rc.Styles.Dialog.HelpView + helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize()) + helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "") + parts = append(parts, helpView) + } + + content := strings.Join(parts, "\n") + + return dialogStyle.Render(content) +} diff --git a/internal/ui/dialog/dialog.go b/internal/ui/dialog/dialog.go new file mode 100644 index 0000000000000000000000000000000000000000..7a3db40128fb1e5543a94a93faa4ae9aeec5f947 --- /dev/null +++ b/internal/ui/dialog/dialog.go @@ -0,0 +1,197 @@ +package dialog + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" +) + +// Dialog sizing constants. +const ( + // defaultDialogMaxWidth is the maximum width for standard dialogs. + defaultDialogMaxWidth = 120 + // defaultDialogHeight is the default height for standard dialogs. + defaultDialogHeight = 30 + // titleContentHeight is the height of the title content line. + titleContentHeight = 1 + // inputContentHeight is the height of the input content line. + inputContentHeight = 1 +) + +// CloseKey is the default key binding to close dialogs. +var CloseKey = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "exit"), +) + +// Action represents an action taken in a dialog after handling a message. +type Action any + +// Dialog is a component that can be displayed on top of the UI. +type Dialog interface { + // ID returns the unique identifier of the dialog. + ID() string + // HandleMsg processes a message and returns an action. An [Action] can be + // anything and the caller is responsible for handling it appropriately. + HandleMsg(msg tea.Msg) Action + // Draw draws the dialog onto the provided screen within the specified area + // and returns the desired cursor position on the screen. + Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor +} + +// LoadingDialog is a dialog that can show a loading state. +type LoadingDialog interface { + StartLoading() tea.Cmd + StopLoading() +} + +// Overlay manages multiple dialogs as an overlay. +type Overlay struct { + dialogs []Dialog +} + +// NewOverlay creates a new [Overlay] instance. +func NewOverlay(dialogs ...Dialog) *Overlay { + return &Overlay{ + dialogs: dialogs, + } +} + +// HasDialogs checks if there are any active dialogs. +func (d *Overlay) HasDialogs() bool { + return len(d.dialogs) > 0 +} + +// ContainsDialog checks if a dialog with the specified ID exists. +func (d *Overlay) ContainsDialog(dialogID string) bool { + for _, dialog := range d.dialogs { + if dialog.ID() == dialogID { + return true + } + } + return false +} + +// OpenDialog opens a new dialog to the stack. +func (d *Overlay) OpenDialog(dialog Dialog) { + d.dialogs = append(d.dialogs, dialog) +} + +// CloseDialog closes the dialog with the specified ID from the stack. +func (d *Overlay) CloseDialog(dialogID string) { + for i, dialog := range d.dialogs { + if dialog.ID() == dialogID { + d.removeDialog(i) + return + } + } +} + +// CloseFrontDialog closes the front dialog in the stack. +func (d *Overlay) CloseFrontDialog() { + if len(d.dialogs) == 0 { + return + } + d.removeDialog(len(d.dialogs) - 1) +} + +// Dialog returns the dialog with the specified ID, or nil if not found. +func (d *Overlay) Dialog(dialogID string) Dialog { + for _, dialog := range d.dialogs { + if dialog.ID() == dialogID { + return dialog + } + } + return nil +} + +// DialogLast returns the front dialog, or nil if there are no dialogs. +func (d *Overlay) DialogLast() Dialog { + if len(d.dialogs) == 0 { + return nil + } + return d.dialogs[len(d.dialogs)-1] +} + +// BringToFront brings the dialog with the specified ID to the front. +func (d *Overlay) BringToFront(dialogID string) { + for i, dialog := range d.dialogs { + if dialog.ID() == dialogID { + // Move the dialog to the end of the slice + d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...) + d.dialogs = append(d.dialogs, dialog) + return + } + } +} + +// Update handles dialog updates. +func (d *Overlay) Update(msg tea.Msg) tea.Msg { + if len(d.dialogs) == 0 { + return nil + } + + idx := len(d.dialogs) - 1 // active dialog is the last one + dialog := d.dialogs[idx] + if dialog == nil { + return nil + } + + return dialog.HandleMsg(msg) +} + +// StartLoading starts the loading state for the front dialog if it +// implements [LoadingDialog]. +func (d *Overlay) StartLoading() tea.Cmd { + dialog := d.DialogLast() + if ld, ok := dialog.(LoadingDialog); ok { + return ld.StartLoading() + } + return nil +} + +// StopLoading stops the loading state for the front dialog if it +// implements [LoadingDialog]. +func (d *Overlay) StopLoading() { + dialog := d.DialogLast() + if ld, ok := dialog.(LoadingDialog); ok { + ld.StopLoading() + } +} + +// DrawCenterCursor draws the given string view centered in the screen area and +// adjusts the cursor position accordingly. +func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) { + width, height := lipgloss.Size(view) + center := common.CenterRect(area, width, height) + if cur != nil { + cur.X += center.Min.X + cur.Y += center.Min.Y + } + + uv.NewStyledString(view).Draw(scr, center) +} + +// DrawCenter draws the given string view centered in the screen area. +func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) { + DrawCenterCursor(scr, area, view, nil) +} + +// Draw renders the overlay and its dialogs. +func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + var cur *tea.Cursor + for _, dialog := range d.dialogs { + cur = dialog.Draw(scr, area) + } + return cur +} + +// removeDialog removes a dialog from the stack. +func (d *Overlay) removeDialog(idx int) { + if idx < 0 || idx >= len(d.dialogs) { + return + } + d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...) +} diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go new file mode 100644 index 0000000000000000000000000000000000000000..ce4adcf8b2dc759f5eceff6ad0d7f6d1728fb7de --- /dev/null +++ b/internal/ui/dialog/filepicker.go @@ -0,0 +1,304 @@ +package dialog + +import ( + "fmt" + "image" + _ "image/jpeg" // register JPEG format + _ "image/png" // register PNG format + "os" + "strings" + "sync" + + "charm.land/bubbles/v2/filepicker" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/ui/common" + fimage "github.com/charmbracelet/crush/internal/ui/image" + uv "github.com/charmbracelet/ultraviolet" +) + +// FilePickerID is the identifier for the FilePicker dialog. +const FilePickerID = "filepicker" + +// FilePicker is a dialog that allows users to select files or directories. +type FilePicker struct { + com *common.Common + + imgEnc fimage.Encoding + imgPrevWidth, imgPrevHeight int + cellSize fimage.CellSize + + fp filepicker.Model + help help.Model + previewingImage bool // indicates if an image is being previewed + isTmux bool + + km struct { + Select, + Down, + Up, + Forward, + Backward, + Navigate, + Close key.Binding + } +} + +var _ Dialog = (*FilePicker)(nil) + +// NewFilePicker creates a new [FilePicker] dialog. +func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) { + f := new(FilePicker) + f.com = com + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + f.help = help + + f.km.Select = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "accept"), + ) + f.km.Down = key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("down/j", "move down"), + ) + f.km.Up = key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("up/k", "move up"), + ) + f.km.Forward = key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("right/l", "move forward"), + ) + f.km.Backward = key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("left/h", "move backward"), + ) + f.km.Navigate = key.NewBinding( + key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"), + key.WithHelp("↑↓←→", "navigate"), + ) + f.km.Close = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "close/exit"), + ) + + fp := filepicker.New() + fp.AllowedTypes = common.AllowedImageTypes + fp.ShowPermissions = false + fp.ShowSize = false + fp.AutoHeight = false + fp.Styles = com.Styles.FilePicker + fp.Cursor = "" + fp.CurrentDirectory = f.WorkingDir() + + f.fp = fp + + return f, f.fp.Init() +} + +// SetImageCapabilities sets the image capabilities for the [FilePicker]. +func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) { + if caps != nil { + if caps.SupportsKittyGraphics { + f.imgEnc = fimage.EncodingKitty + } + f.cellSize = caps.CellSize() + _, f.isTmux = caps.Env.LookupEnv("TMUX") + } +} + +// WorkingDir returns the current working directory of the [FilePicker]. +func (f *FilePicker) WorkingDir() string { + wd := f.com.Config().WorkingDir() + if len(wd) > 0 { + return wd + } + + cwd, err := os.Getwd() + if err != nil { + return home.Dir() + } + + return cwd +} + +// ShortHelp returns the short help key bindings for the [FilePicker] dialog. +func (f *FilePicker) ShortHelp() []key.Binding { + return []key.Binding{ + f.km.Navigate, + f.km.Select, + f.km.Close, + } +} + +// FullHelp returns the full help key bindings for the [FilePicker] dialog. +func (f *FilePicker) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + f.km.Select, + f.km.Down, + f.km.Up, + f.km.Forward, + }, + { + f.km.Backward, + f.km.Close, + }, + } +} + +// ID returns the identifier of the [FilePicker] dialog. +func (f *FilePicker) ID() string { + return FilePickerID +} + +// HandleMsg updates the [FilePicker] dialog based on the given message. +func (f *FilePicker) HandleMsg(msg tea.Msg) Action { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, f.km.Close): + return ActionClose{} + } + } + + var cmd tea.Cmd + f.fp, cmd = f.fp.Update(msg) + if selFile := f.fp.HighlightedPath(); selFile != "" { + var allowed bool + for _, allowedExt := range f.fp.AllowedTypes { + if strings.HasSuffix(strings.ToLower(selFile), allowedExt) { + allowed = true + break + } + } + + f.previewingImage = allowed + if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) { + f.previewingImage = false + img, err := loadImage(selFile) + if err == nil { + cmds = append(cmds, tea.Sequence( + f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux), + func() tea.Msg { + f.previewingImage = true + return nil + }, + )) + } + } + } + if cmd != nil { + cmds = append(cmds, cmd) + } + + if didSelect, path := f.fp.DidSelectFile(msg); didSelect { + return ActionFilePickerSelected{Path: path} + } + + return ActionCmd{tea.Batch(cmds...)} +} + +const ( + filePickerMinWidth = 70 + filePickerMinHeight = 10 +) + +// Draw renders the [FilePicker] dialog as a string. +func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + width := max(0, min(filePickerMinWidth, area.Dx())) + height := max(0, min(10, area.Dy())) + innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize() + imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize() + imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize() + f.imgPrevWidth = imgPrevWidth + f.imgPrevHeight = imgPrevHeight + f.fp.SetHeight(height) + + styles := f.com.Styles.FilePicker + styles.File = styles.File.Width(innerWidth) + styles.Directory = styles.Directory.Width(innerWidth) + styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth) + styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth) + f.fp.Styles = styles + + t := f.com.Styles + rc := NewRenderContext(t, width) + rc.Gap = 1 + rc.Title = "Add Image" + rc.Help = f.help.View(f) + + imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight)) + rc.AddPart(imgPreview) + + files := strings.TrimSpace(f.fp.View()) + rc.AddPart(files) + + view := rc.Render() + + DrawCenter(scr, area, view) + return nil +} + +var ( + imagePreviewCache = map[string]string{} + imagePreviewMutex sync.RWMutex +) + +// imagePreview returns the image preview section of the [FilePicker] dialog. +func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string { + if !f.previewingImage { + key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight) + imagePreviewMutex.RLock() + cached, ok := imagePreviewCache[key] + imagePreviewMutex.RUnlock() + if ok { + return cached + } + + var sb strings.Builder + for y := range imgPrevHeight { + for range imgPrevWidth { + sb.WriteRune('█') + } + if y < imgPrevHeight-1 { + sb.WriteRune('\n') + } + } + + imagePreviewMutex.Lock() + imagePreviewCache[key] = sb.String() + imagePreviewMutex.Unlock() + + return sb.String() + } + + if id := f.fp.HighlightedPath(); id != "" { + r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight) + return r + } + + return "" +} + +func loadImage(path string) (img image.Image, err error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + img, _, err = image.Decode(file) + if err != nil { + return nil, err + } + + return img, nil +} diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go new file mode 100644 index 0000000000000000000000000000000000000000..77aeab22380f89455f83760428fac128fd4fc28b --- /dev/null +++ b/internal/ui/dialog/models.go @@ -0,0 +1,478 @@ +package dialog + +import ( + "cmp" + "fmt" + "slices" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" +) + +// ModelType represents the type of model to select. +type ModelType int + +const ( + ModelTypeLarge ModelType = iota + ModelTypeSmall +) + +// String returns the string representation of the [ModelType]. +func (mt ModelType) String() string { + switch mt { + case ModelTypeLarge: + return "Large Task" + case ModelTypeSmall: + return "Small Task" + default: + return "Unknown" + } +} + +// Config returns the corresponding config model type. +func (mt ModelType) Config() config.SelectedModelType { + switch mt { + case ModelTypeLarge: + return config.SelectedModelTypeLarge + case ModelTypeSmall: + return config.SelectedModelTypeSmall + default: + return "" + } +} + +// Placeholder returns the input placeholder for the model type. +func (mt ModelType) Placeholder() string { + switch mt { + case ModelTypeLarge: + return largeModelInputPlaceholder + case ModelTypeSmall: + return smallModelInputPlaceholder + default: + return "" + } +} + +const ( + largeModelInputPlaceholder = "Choose a model for large, complex tasks" + smallModelInputPlaceholder = "Choose a model for small, simple tasks" +) + +// ModelsID is the identifier for the model selection dialog. +const ModelsID = "models" + +// Models represents a model selection dialog. +type Models struct { + com *common.Common + + modelType ModelType + providers []catwalk.Provider + + keyMap struct { + Tab key.Binding + UpDown key.Binding + Select key.Binding + Next key.Binding + Previous key.Binding + Close key.Binding + } + list *ModelsList + input textinput.Model + help help.Model +} + +var _ Dialog = (*Models)(nil) + +// NewModels creates a new Models dialog. +func NewModels(com *common.Common) (*Models, error) { + t := com.Styles + m := &Models{} + m.com = com + help := help.New() + help.Styles = t.DialogHelpStyles() + + m.help = help + m.list = NewModelsList(t) + m.list.Focus() + m.list.SetSelected(0) + + m.input = textinput.New() + m.input.SetVirtualCursor(false) + m.input.Placeholder = largeModelInputPlaceholder + m.input.SetStyles(com.Styles.TextInput) + m.input.Focus() + + m.keyMap.Tab = key.NewBinding( + key.WithKeys("tab", "shift+tab"), + key.WithHelp("tab", "toggle type"), + ) + m.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ) + m.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) + m.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + m.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + m.keyMap.Close = CloseKey + + providers, err := getFilteredProviders(com.Config()) + if err != nil { + return nil, fmt.Errorf("failed to get providers: %w", err) + } + + m.providers = providers + if err := m.setProviderItems(); err != nil { + return nil, fmt.Errorf("failed to set provider items: %w", err) + } + + return m, nil +} + +// ID implements Dialog. +func (m *Models) ID() string { + return ModelsID +} + +// HandleMsg implements Dialog. +func (m *Models) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.Close): + return ActionClose{} + case key.Matches(msg, m.keyMap.Previous): + m.list.Focus() + if m.list.IsSelectedFirst() { + m.list.SelectLast() + m.list.ScrollToBottom() + break + } + m.list.SelectPrev() + m.list.ScrollToSelected() + case key.Matches(msg, m.keyMap.Next): + m.list.Focus() + if m.list.IsSelectedLast() { + m.list.SelectFirst() + m.list.ScrollToTop() + break + } + m.list.SelectNext() + m.list.ScrollToSelected() + case key.Matches(msg, m.keyMap.Select): + selectedItem := m.list.SelectedItem() + if selectedItem == nil { + break + } + + modelItem, ok := selectedItem.(*ModelItem) + if !ok { + break + } + + return ActionSelectModel{ + Provider: modelItem.prov, + Model: modelItem.SelectedModel(), + ModelType: modelItem.SelectedModelType(), + } + case key.Matches(msg, m.keyMap.Tab): + if m.modelType == ModelTypeLarge { + m.modelType = ModelTypeSmall + } else { + m.modelType = ModelTypeLarge + } + if err := m.setProviderItems(); err != nil { + return uiutil.ReportError(err) + } + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + value := m.input.Value() + m.list.SetFilter(value) + m.list.ScrollToSelected() + return ActionCmd{cmd} + } + } + return nil +} + +// Cursor returns the cursor for the dialog. +func (m *Models) Cursor() *tea.Cursor { + return InputCursor(m.com.Styles, m.input.Cursor()) +} + +// modelTypeRadioView returns the radio view for model type selection. +func (m *Models) modelTypeRadioView() string { + t := m.com.Styles + textStyle := t.HalfMuted + largeRadioStyle := t.RadioOff + smallRadioStyle := t.RadioOff + if m.modelType == ModelTypeLarge { + largeRadioStyle = t.RadioOn + } else { + smallRadioStyle = t.RadioOn + } + + largeRadio := largeRadioStyle.Padding(0, 1).Render() + smallRadio := smallRadioStyle.Padding(0, 1).Render() + + return fmt.Sprintf("%s%s %s%s", + largeRadio, textStyle.Render(ModelTypeLarge.String()), + smallRadio, textStyle.Render(ModelTypeSmall.String())) +} + +// Draw implements [Dialog]. +func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := m.com.Styles + width := max(0, min(defaultDialogMaxWidth, area.Dx())) + height := max(0, min(defaultDialogHeight, area.Dy())) + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + m.list.SetSize(innerWidth, height-heightOffset) + m.help.SetWidth(innerWidth) + + rc := NewRenderContext(t, width) + rc.Title = "Switch Model" + rc.TitleInfo = m.modelTypeRadioView() + inputView := t.Dialog.InputPrompt.Render(m.input.View()) + rc.AddPart(inputView) + listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render()) + rc.AddPart(listView) + rc.Help = m.help.View(m) + + view := rc.Render() + + cur := m.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur +} + +// ShortHelp returns the short help view. +func (m *Models) ShortHelp() []key.Binding { + return []key.Binding{ + m.keyMap.UpDown, + m.keyMap.Tab, + m.keyMap.Select, + m.keyMap.Close, + } +} + +// FullHelp returns the full help view. +func (m *Models) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + m.keyMap.Select, + m.keyMap.Next, + m.keyMap.Previous, + m.keyMap.Tab, + }, + { + m.keyMap.Close, + }, + } +} + +// setProviderItems sets the provider items in the list. +func (m *Models) setProviderItems() error { + t := m.com.Styles + cfg := m.com.Config() + + var selectedItemID string + selectedType := m.modelType.Config() + currentModel := cfg.Models[selectedType] + recentItems := cfg.RecentModels[selectedType] + + // Track providers already added to avoid duplicates + addedProviders := make(map[string]bool) + + // Get a list of known providers to compare against + knownProviders, err := config.Providers(cfg) + if err != nil { + return fmt.Errorf("failed to get providers: %w", err) + } + + containsProviderFunc := func(id string) func(p catwalk.Provider) bool { + return func(p catwalk.Provider) bool { + return p.ID == catwalk.InferenceProvider(id) + } + } + + // itemsMap contains the keys of added model items. + itemsMap := make(map[string]*ModelItem) + groups := []ModelGroup{} + for id, p := range cfg.Providers.Seq2() { + if p.Disable { + continue + } + + // Check if this provider is not in the known providers list + if !slices.ContainsFunc(knownProviders, containsProviderFunc(id)) || + !slices.ContainsFunc(m.providers, containsProviderFunc(id)) { + provider := p.ToProvider() + + // Add this unknown provider to the list + name := cmp.Or(p.Name, id) + + addedProviders[id] = true + + group := NewModelGroup(t, name, true) + for _, model := range p.Models { + item := NewModelItem(t, provider, model, m.modelType, false) + group.AppendItems(item) + itemsMap[item.ID()] = item + if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { + selectedItemID = item.ID() + } + } + if len(group.Items) > 0 { + groups = append(groups, group) + } + } + } + + // Now add known providers from the predefined list + for _, provider := range m.providers { + providerID := string(provider.ID) + if addedProviders[providerID] { + continue + } + + providerConfig, providerConfigured := cfg.Providers.Get(providerID) + if providerConfigured && providerConfig.Disable { + continue + } + + displayProvider := provider + if providerConfigured { + displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name) + modelIndex := make(map[string]int, len(displayProvider.Models)) + for i, model := range displayProvider.Models { + modelIndex[model.ID] = i + } + for _, model := range providerConfig.Models { + if model.ID == "" { + continue + } + if idx, ok := modelIndex[model.ID]; ok { + if model.Name != "" { + displayProvider.Models[idx].Name = model.Name + } + continue + } + if model.Name == "" { + model.Name = model.ID + } + displayProvider.Models = append(displayProvider.Models, model) + modelIndex[model.ID] = len(displayProvider.Models) - 1 + } + } + + name := displayProvider.Name + if name == "" { + name = providerID + } + + group := NewModelGroup(t, name, providerConfigured) + for _, model := range displayProvider.Models { + item := NewModelItem(t, provider, model, m.modelType, false) + group.AppendItems(item) + itemsMap[item.ID()] = item + if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { + selectedItemID = item.ID() + } + } + + groups = append(groups, group) + } + + if len(recentItems) > 0 { + recentGroup := NewModelGroup(t, "Recently used", false) + + var validRecentItems []config.SelectedModel + for _, recent := range recentItems { + key := modelKey(recent.Provider, recent.Model) + item, ok := itemsMap[key] + if !ok { + continue + } + + // Show provider for recent items + item = NewModelItem(t, item.prov, item.model, m.modelType, true) + item.showProvider = true + + validRecentItems = append(validRecentItems, recent) + recentGroup.AppendItems(item) + if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider { + selectedItemID = item.ID() + } + } + + if len(validRecentItems) != len(recentItems) { + // FIXME: Does this need to be here? Is it mutating the config during a read? + if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil { + return fmt.Errorf("failed to update recent models: %w", err) + } + } + + if len(recentGroup.Items) > 0 { + groups = append([]ModelGroup{recentGroup}, groups...) + } + } + + // Set model groups in the list. + m.list.SetGroups(groups...) + m.list.SetSelectedItem(selectedItemID) + + // Update placeholder based on model type + m.input.Placeholder = m.modelType.Placeholder() + + return nil +} + +func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) { + providers, err := config.Providers(cfg) + if err != nil { + return nil, fmt.Errorf("failed to get providers: %w", err) + } + var filteredProviders []catwalk.Provider + for _, p := range providers { + var ( + isAzure = p.ID == catwalk.InferenceProviderAzure + isCopilot = p.ID == catwalk.InferenceProviderCopilot + isHyper = string(p.ID) == "hyper" + hasAPIKeyEnv = strings.HasPrefix(p.APIKey, "$") + _, isConfigured = cfg.Providers.Get(string(p.ID)) + ) + if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured { + filteredProviders = append(filteredProviders, p) + } + } + return filteredProviders, nil +} + +func modelKey(providerID, modelID string) string { + if providerID == "" || modelID == "" { + return "" + } + return providerID + ":" + modelID +} diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go new file mode 100644 index 0000000000000000000000000000000000000000..40a8a25c57cd7cf0ce6252ef3113ce2af2f8d2f4 --- /dev/null +++ b/internal/ui/dialog/models_item.go @@ -0,0 +1,124 @@ +package dialog + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" + "github.com/sahilm/fuzzy" +) + +// ModelGroup represents a group of model items. +type ModelGroup struct { + Title string + Items []*ModelItem + configured bool + t *styles.Styles +} + +// NewModelGroup creates a new ModelGroup. +func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup { + return ModelGroup{ + Title: title, + Items: items, + configured: configured, + t: t, + } +} + +// AppendItems appends [ModelItem]s to the group. +func (m *ModelGroup) AppendItems(items ...*ModelItem) { + m.Items = append(m.Items, items...) +} + +// Render implements [list.Item]. +func (m *ModelGroup) Render(width int) string { + var configured string + if m.configured { + configuredIcon := m.t.ToolCallSuccess.Render() + configuredText := m.t.Subtle.Render("Configured") + configured = configuredIcon + " " + configuredText + } + + title := " " + m.Title + " " + title = ansi.Truncate(title, max(0, width-lipgloss.Width(configured)-1), "…") + + return common.Section(m.t, title, width, configured) +} + +// ModelItem represents a list item for a model type. +type ModelItem struct { + prov catwalk.Provider + model catwalk.Model + modelType ModelType + + cache map[int]string + t *styles.Styles + m fuzzy.Match + focused bool + showProvider bool +} + +// SelectedModel returns this model item as a [config.SelectedModel] instance. +func (m *ModelItem) SelectedModel() config.SelectedModel { + return config.SelectedModel{ + Model: m.model.ID, + Provider: string(m.prov.ID), + ReasoningEffort: m.model.DefaultReasoningEffort, + MaxTokens: m.model.DefaultMaxTokens, + } +} + +// SelectedModelType returns the type of model represented by this item. +func (m *ModelItem) SelectedModelType() config.SelectedModelType { + return m.modelType.Config() +} + +var _ ListItem = &ModelItem{} + +// NewModelItem creates a new ModelItem. +func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, typ ModelType, showProvider bool) *ModelItem { + return &ModelItem{ + prov: prov, + model: model, + modelType: typ, + t: t, + cache: make(map[int]string), + showProvider: showProvider, + } +} + +// Filter implements ListItem. +func (m *ModelItem) Filter() string { + return m.model.Name +} + +// ID implements ListItem. +func (m *ModelItem) ID() string { + return modelKey(string(m.prov.ID), m.model.ID) +} + +// Render implements ListItem. +func (m *ModelItem) Render(width int) string { + var providerInfo string + if m.showProvider { + providerInfo = string(m.prov.Name) + } + return renderItem(m.t, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m) +} + +// SetFocused implements ListItem. +func (m *ModelItem) SetFocused(focused bool) { + if m.focused != focused { + m.cache = nil + } + m.focused = focused +} + +// SetMatch implements ListItem. +func (m *ModelItem) SetMatch(fm fuzzy.Match) { + m.cache = nil + m.m = fm +} diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go new file mode 100644 index 0000000000000000000000000000000000000000..c0eaba437154a78df3865ab9cd0e96c5c9c57321 --- /dev/null +++ b/internal/ui/dialog/models_list.go @@ -0,0 +1,273 @@ +package dialog + +import ( + "fmt" + "slices" + "sort" + "strings" + + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/sahilm/fuzzy" +) + +// ModelsList is a list specifically for model items and groups. +type ModelsList struct { + *list.List + groups []ModelGroup + query string + t *styles.Styles +} + +// NewModelsList creates a new list suitable for model items and groups. +func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList { + f := &ModelsList{ + List: list.NewList(), + groups: groups, + t: sty, + } + f.RegisterRenderCallback(list.FocusedRenderCallback(f.List)) + return f +} + +// Len returns the number of model items across all groups. +func (f *ModelsList) Len() int { + n := 0 + for _, g := range f.groups { + n += len(g.Items) + } + return n +} + +// SetGroups sets the model groups and updates the list items. +func (f *ModelsList) SetGroups(groups ...ModelGroup) { + f.groups = groups + items := []list.Item{} + for _, g := range f.groups { + items = append(items, &g) + for _, item := range g.Items { + items = append(items, item) + } + // Add a space separator after each provider section + items = append(items, list.NewSpacerItem(1)) + } + f.SetItems(items...) +} + +// SetFilter sets the filter query and updates the list items. +func (f *ModelsList) SetFilter(q string) { + f.query = q +} + +// SetSelected sets the selected item index. It overrides the base method to +// skip non-model items. +func (f *ModelsList) SetSelected(index int) { + if index < 0 || index >= f.Len() { + f.List.SetSelected(index) + return + } + + f.List.SetSelected(index) + for { + selectedItem := f.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return + } + f.List.SetSelected(index + 1) + index++ + if index >= f.Len() { + return + } + } +} + +// SetSelectedItem sets the selected item in the list by item ID. +func (f *ModelsList) SetSelectedItem(itemID string) { + if itemID == "" { + f.SetSelected(0) + return + } + + count := 0 + for _, g := range f.groups { + for _, item := range g.Items { + if item.ID() == itemID { + f.SetSelected(count) + return + } + count++ + } + } +} + +// SelectNext selects the next model item, skipping any non-focusable items +// like group headers and spacers. +func (f *ModelsList) SelectNext() (v bool) { + for { + v = f.List.SelectNext() + selectedItem := f.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + } +} + +// SelectPrev selects the previous model item, skipping any non-focusable items +// like group headers and spacers. +func (f *ModelsList) SelectPrev() (v bool) { + for { + v = f.List.SelectPrev() + selectedItem := f.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + } +} + +// SelectFirst selects the first model item in the list. +func (f *ModelsList) SelectFirst() (v bool) { + v = f.List.SelectFirst() + for { + selectedItem := f.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + v = f.List.SelectNext() + } +} + +// SelectLast selects the last model item in the list. +func (f *ModelsList) SelectLast() (v bool) { + v = f.List.SelectLast() + for { + selectedItem := f.SelectedItem() + if _, ok := selectedItem.(*ModelItem); ok { + return v + } + v = f.List.SelectPrev() + } +} + +// IsSelectedFirst checks if the selected item is the first model item. +func (f *ModelsList) IsSelectedFirst() bool { + originalIndex := f.Selected() + f.SelectFirst() + isFirst := f.Selected() == originalIndex + f.List.SetSelected(originalIndex) + return isFirst +} + +// IsSelectedLast checks if the selected item is the last model item. +func (f *ModelsList) IsSelectedLast() bool { + originalIndex := f.Selected() + f.SelectLast() + isLast := f.Selected() == originalIndex + f.List.SetSelected(originalIndex) + return isLast +} + +// VisibleItems returns the visible items after filtering. +func (f *ModelsList) VisibleItems() []list.Item { + query := strings.ToLower(strings.ReplaceAll(f.query, " ", "")) + + if query == "" { + // No filter, return all items with group headers + items := []list.Item{} + for _, g := range f.groups { + items = append(items, &g) + for _, item := range g.Items { + item.SetMatch(fuzzy.Match{}) + items = append(items, item) + } + // Add a space separator after each provider section + items = append(items, list.NewSpacerItem(1)) + } + return items + } + + filterableItems := make([]list.FilterableItem, 0, f.Len()) + for _, g := range f.groups { + for _, item := range g.Items { + filterableItems = append(filterableItems, item) + } + } + + items := []list.Item{} + visitedGroups := map[int]bool{} + + // Reconstruct groups with matched items + // Find which group this item belongs to + for gi, g := range f.groups { + addedCount := 0 + name := strings.ToLower(g.Title) + " " + + names := make([]string, len(filterableItems)) + for i, item := range filterableItems { + ms := item.(*ModelItem) + names[i] = fmt.Sprintf("%s%s", name, ms.Filter()) + } + + matches := fuzzy.Find(query, names) + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Score > matches[j].Score + }) + + for _, match := range matches { + item := filterableItems[match.Index].(*ModelItem) + idxs := []int{} + for _, idx := range match.MatchedIndexes { + // Adjusts removing provider name highlights + if idx < len(name) { + continue + } + idxs = append(idxs, idx-len(name)) + } + + match.MatchedIndexes = idxs + if slices.Contains(g.Items, item) { + if !visitedGroups[gi] { + // Add section header + items = append(items, &g) + visitedGroups[gi] = true + } + // Add the matched item + item.SetMatch(match) + items = append(items, item) + addedCount++ + } + } + if addedCount > 0 { + // Add a space separator after each provider section + items = append(items, list.NewSpacerItem(1)) + } + } + + return items +} + +// Render renders the filterable list. +func (f *ModelsList) Render() string { + f.SetItems(f.VisibleItems()...) + return f.List.Render() +} + +type modelGroups []ModelGroup + +func (m modelGroups) Len() int { + n := 0 + for _, g := range m { + n += len(g.Items) + } + return n +} + +func (m modelGroups) String(i int) string { + count := 0 + for _, g := range m { + if i < count+len(g.Items) { + return g.Items[i-count].Filter() + } + count += len(g.Items) + } + return "" +} diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go new file mode 100644 index 0000000000000000000000000000000000000000..ae5a2ab25a1ec596ba50ea6b3a0d03f560f1b10d --- /dev/null +++ b/internal/ui/dialog/oauth.go @@ -0,0 +1,369 @@ +package dialog + +import ( + "context" + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/pkg/browser" +) + +type OAuthProvider interface { + name() string + initiateAuth() tea.Msg + startPolling(deviceCode string, expiresIn int) tea.Cmd + stopPolling() tea.Msg +} + +// OAuthState represents the current state of the device flow. +type OAuthState int + +const ( + OAuthStateInitializing OAuthState = iota + OAuthStateDisplay + OAuthStateSuccess + OAuthStateError +) + +// OAuthID is the identifier for the model selection dialog. +const OAuthID = "oauth" + +// OAuth handles the OAuth flow authentication. +type OAuth struct { + com *common.Common + + provider catwalk.Provider + model config.SelectedModel + modelType config.SelectedModelType + oAuthProvider OAuthProvider + + State OAuthState + + spinner spinner.Model + help help.Model + keyMap struct { + Copy key.Binding + Submit key.Binding + Close key.Binding + } + + width int + deviceCode string + userCode string + verificationURL string + expiresIn int + interval int + token *oauth.Token + cancelFunc context.CancelFunc +} + +var _ Dialog = (*OAuth)(nil) + +// newOAuth creates a new device flow component. +func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) { + t := com.Styles + + m := OAuth{} + m.com = com + m.provider = provider + m.model = model + m.modelType = modelType + m.oAuthProvider = oAuthProvider + m.width = 60 + m.State = OAuthStateInitializing + + m.spinner = spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(t.Base.Foreground(t.GreenLight)), + ) + + m.help = help.New() + m.help.Styles = t.DialogHelpStyles() + + m.keyMap.Copy = key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "copy code"), + ) + m.keyMap.Submit = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "copy & open"), + ) + m.keyMap.Close = CloseKey + + return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth) +} + +// ID implements Dialog. +func (m *OAuth) ID() string { + return OAuthID +} + +// HandleMsg handles messages and state transitions. +func (m *OAuth) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case spinner.TickMsg: + switch m.State { + case OAuthStateInitializing, OAuthStateDisplay: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.Copy): + cmd := m.copyCode() + return ActionCmd{cmd} + + case key.Matches(msg, m.keyMap.Submit): + switch m.State { + case OAuthStateSuccess: + return m.saveKeyAndContinue() + + default: + cmd := m.copyCodeAndOpenURL() + return ActionCmd{cmd} + } + + case key.Matches(msg, m.keyMap.Close): + switch m.State { + case OAuthStateSuccess: + return m.saveKeyAndContinue() + + default: + return ActionClose{} + } + } + + case ActionInitiateOAuth: + m.deviceCode = msg.DeviceCode + m.userCode = msg.UserCode + m.expiresIn = msg.ExpiresIn + m.verificationURL = msg.VerificationURL + m.interval = msg.Interval + m.State = OAuthStateDisplay + return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)} + + case ActionCompleteOAuth: + m.State = OAuthStateSuccess + m.token = msg.Token + return ActionCmd{m.oAuthProvider.stopPolling} + + case ActionOAuthErrored: + m.State = OAuthStateError + cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error)) + return ActionCmd{cmd} + } + return nil +} + +// View renders the device flow dialog. +func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + var ( + t = m.com.Styles + dialogStyle = t.Dialog.View.Width(m.width) + view = dialogStyle.Render(m.dialogContent()) + ) + DrawCenterCursor(scr, area, view, nil) + return nil +} + +func (m *OAuth) dialogContent() string { + var ( + t = m.com.Styles + helpStyle = t.Dialog.HelpView + ) + + switch m.State { + case OAuthStateInitializing: + return m.innerDialogContent() + + default: + elements := []string{ + m.headerContent(), + m.innerDialogContent(), + helpStyle.Render(m.help.View(m)), + } + return strings.Join(elements, "\n") + } +} + +func (m *OAuth) headerContent() string { + var ( + t = m.com.Styles + titleStyle = t.Dialog.Title + dialogStyle = t.Dialog.View.Width(m.width) + headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize() + ) + return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset) +} + +func (m *OAuth) innerDialogContent() string { + var ( + t = m.com.Styles + whiteStyle = lipgloss.NewStyle().Foreground(t.White) + primaryStyle = lipgloss.NewStyle().Foreground(t.Primary) + greenStyle = lipgloss.NewStyle().Foreground(t.GreenLight) + linkStyle = lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true) + errorStyle = lipgloss.NewStyle().Foreground(t.Error) + mutedStyle = lipgloss.NewStyle().Foreground(t.FgMuted) + ) + + switch m.State { + case OAuthStateInitializing: + return lipgloss.NewStyle(). + Margin(1, 1). + Width(m.width - 2). + Align(lipgloss.Center). + Render( + greenStyle.Render(m.spinner.View()) + + mutedStyle.Render("Initializing..."), + ) + + case OAuthStateDisplay: + instructions := lipgloss.NewStyle(). + Margin(0, 1). + Width(m.width - 2). + Render( + whiteStyle.Render("Press ") + + primaryStyle.Render("enter") + + whiteStyle.Render(" to copy the code below and open the browser."), + ) + + codeBox := lipgloss.NewStyle(). + Width(m.width-2). + Height(7). + Align(lipgloss.Center, lipgloss.Center). + Background(t.BgBaseLighter). + Margin(0, 1). + Render( + lipgloss.NewStyle(). + Bold(true). + Foreground(t.White). + Render(m.userCode), + ) + + link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL) + url := mutedStyle. + Margin(0, 1). + Width(m.width - 2). + Render("Browser not opening? Refer to\n" + link) + + waiting := lipgloss.NewStyle(). + Margin(0, 1). + Width(m.width - 2). + Render( + greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."), + ) + + return lipgloss.JoinVertical( + lipgloss.Left, + "", + instructions, + "", + codeBox, + "", + url, + "", + waiting, + "", + ) + + case OAuthStateSuccess: + return greenStyle. + Margin(1). + Width(m.width - 2). + Render("Authentication successful!") + + case OAuthStateError: + return lipgloss.NewStyle(). + Margin(1). + Width(m.width - 2). + Render(errorStyle.Render("Authentication failed.")) + + default: + return "" + } +} + +// FullHelp returns the full help view. +func (m *OAuth) FullHelp() [][]key.Binding { + return [][]key.Binding{m.ShortHelp()} +} + +// ShortHelp returns the full help view. +func (m *OAuth) ShortHelp() []key.Binding { + switch m.State { + case OAuthStateError: + return []key.Binding{m.keyMap.Close} + + case OAuthStateSuccess: + return []key.Binding{ + key.NewBinding( + key.WithKeys("finish", "ctrl+y", "esc"), + key.WithHelp("enter", "finish"), + ), + } + + default: + return []key.Binding{ + m.keyMap.Copy, + m.keyMap.Submit, + m.keyMap.Close, + } + } +} + +func (d *OAuth) copyCode() tea.Cmd { + if d.State != OAuthStateDisplay { + return nil + } + return tea.Sequence( + tea.SetClipboard(d.userCode), + uiutil.ReportInfo("Code copied to clipboard"), + ) +} + +func (d *OAuth) copyCodeAndOpenURL() tea.Cmd { + if d.State != OAuthStateDisplay { + return nil + } + return tea.Sequence( + tea.SetClipboard(d.userCode), + func() tea.Msg { + if err := browser.OpenURL(d.verificationURL); err != nil { + return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)} + } + return nil + }, + uiutil.ReportInfo("Code copied and URL opened"), + ) +} + +func (m *OAuth) saveKeyAndContinue() Action { + cfg := m.com.Config() + + err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token) + if err != nil { + return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))} + } + + return ActionSelectModel{ + Provider: m.provider, + Model: m.model, + ModelType: m.modelType, + } +} diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go new file mode 100644 index 0000000000000000000000000000000000000000..19e389b38a965c4c22ba1b2080b029975aaedc19 --- /dev/null +++ b/internal/ui/dialog/oauth_copilot.go @@ -0,0 +1,72 @@ +package dialog + +import ( + "context" + "fmt" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth/copilot" + "github.com/charmbracelet/crush/internal/ui/common" +) + +func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) { + return newOAuth(com, provider, model, modelType, &OAuthCopilot{}) +} + +type OAuthCopilot struct { + deviceCode *copilot.DeviceCode + cancelFunc func() +} + +var _ OAuthProvider = (*OAuthCopilot)(nil) + +func (m *OAuthCopilot) name() string { + return "GitHub Copilot" +} + +func (m *OAuthCopilot) initiateAuth() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + deviceCode, err := copilot.RequestDeviceCode(ctx) + if err != nil { + return ActionOAuthErrored{Error: fmt.Errorf("failed to initiate device auth: %w", err)} + } + + m.deviceCode = deviceCode + + return ActionInitiateOAuth{ + DeviceCode: deviceCode.DeviceCode, + UserCode: deviceCode.UserCode, + VerificationURL: deviceCode.VerificationURI, + ExpiresIn: deviceCode.ExpiresIn, + Interval: deviceCode.Interval, + } +} + +func (m *OAuthCopilot) startPolling(deviceCode string, expiresIn int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFunc = cancel + + token, err := copilot.PollForToken(ctx, m.deviceCode) + if err != nil { + if ctx.Err() != nil { + return nil // cancelled, don't report error. + } + return ActionOAuthErrored{Error: err} + } + + return ActionCompleteOAuth{Token: token} + } +} + +func (m *OAuthCopilot) stopPolling() tea.Msg { + if m.cancelFunc != nil { + m.cancelFunc() + } + return nil +} diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go new file mode 100644 index 0000000000000000000000000000000000000000..478960b0df10f62d88b65450de360f4db6d6cd0c --- /dev/null +++ b/internal/ui/dialog/oauth_hyper.go @@ -0,0 +1,90 @@ +package dialog + +import ( + "context" + "fmt" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth/hyper" + "github.com/charmbracelet/crush/internal/ui/common" +) + +func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) { + return newOAuth(com, provider, model, modelType, &OAuthHyper{}) +} + +type OAuthHyper struct { + cancelFunc func() +} + +var _ OAuthProvider = (*OAuthHyper)(nil) + +func (m *OAuthHyper) name() string { + return "Hyper" +} + +func (m *OAuthHyper) initiateAuth() tea.Msg { + minimumWait := 750 * time.Millisecond + startTime := time.Now() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + authResp, err := hyper.InitiateDeviceAuth(ctx) + + ellapsed := time.Since(startTime) + if ellapsed < minimumWait { + time.Sleep(minimumWait - ellapsed) + } + + if err != nil { + return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)} + } + + return ActionInitiateOAuth{ + DeviceCode: authResp.DeviceCode, + UserCode: authResp.UserCode, + ExpiresIn: authResp.ExpiresIn, + VerificationURL: authResp.VerificationURL, + } +} + +func (m *OAuthHyper) startPolling(deviceCode string, expiresIn int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFunc = cancel + + refreshToken, err := hyper.PollForToken(ctx, deviceCode, expiresIn) + if err != nil { + if ctx.Err() != nil { + return nil + } + return ActionOAuthErrored{err} + } + + token, err := hyper.ExchangeToken(ctx, refreshToken) + if err != nil { + return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)} + } + + introspect, err := hyper.IntrospectToken(ctx, token.AccessToken) + if err != nil { + return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)} + } + if !introspect.Active { + return ActionOAuthErrored{fmt.Errorf("access token is not active")} + } + + return ActionCompleteOAuth{token} + } +} + +func (m *OAuthHyper) stopPolling() tea.Msg { + if m.cancelFunc != nil { + m.cancelFunc() + } + return nil +} diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go new file mode 100644 index 0000000000000000000000000000000000000000..8f2ca1ed27e7eff5096bcb33c8f516a07fe2dd88 --- /dev/null +++ b/internal/ui/dialog/permissions.go @@ -0,0 +1,760 @@ +package dialog + +import ( + "encoding/json" + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" +) + +// PermissionsID is the identifier for the permissions dialog. +const PermissionsID = "permissions" + +// PermissionAction represents the user's response to a permission request. +type PermissionAction string + +const ( + PermissionAllow PermissionAction = "allow" + PermissionAllowForSession PermissionAction = "allow_session" + PermissionDeny PermissionAction = "deny" +) + +// Permissions dialog sizing constants. +const ( + // diffMaxWidth is the maximum width for diff views. + diffMaxWidth = 180 + // diffSizeRatio is the size ratio for diff views relative to window. + diffSizeRatio = 0.8 + // simpleMaxWidth is the maximum width for simple content dialogs. + simpleMaxWidth = 100 + // simpleSizeRatio is the size ratio for simple content dialogs. + simpleSizeRatio = 0.6 + // simpleHeightRatio is the height ratio for simple content dialogs. + simpleHeightRatio = 0.5 + // splitModeMinWidth is the minimum width to enable split diff mode. + splitModeMinWidth = 140 + // layoutSpacingLines is the number of empty lines used for layout spacing. + layoutSpacingLines = 4 + // minWindowWidth is the minimum window width before forcing fullscreen. + minWindowWidth = 60 + // minWindowHeight is the minimum window height before forcing fullscreen. + minWindowHeight = 20 +) + +// Permissions represents a dialog for permission requests. +type Permissions struct { + com *common.Common + windowWidth int // Terminal window dimensions. + windowHeight int + fullscreen bool // true when dialog is fullscreen + + permission permission.PermissionRequest + selectedOption int // 0: Allow, 1: Allow for session, 2: Deny + + viewport viewport.Model + viewportDirty bool // true when viewport content needs to be re-rendered + viewportWidth int + + // Diff view state. + diffSplitMode *bool // nil means use default based on width + defaultDiffSplitMode bool // default split mode based on width + unifiedDiffContent string + splitDiffContent string + + help help.Model + keyMap permissionsKeyMap +} + +type permissionsKeyMap struct { + Left key.Binding + Right key.Binding + Tab key.Binding + Select key.Binding + Allow key.Binding + AllowSession key.Binding + Deny key.Binding + Close key.Binding + ToggleDiffMode key.Binding + ToggleFullscreen key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + ScrollLeft key.Binding + ScrollRight key.Binding + Choose key.Binding + Scroll key.Binding +} + +func defaultPermissionsKeyMap() permissionsKeyMap { + return permissionsKeyMap{ + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←", "previous"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→", "next"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next option"), + ), + Select: key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + Allow: key.NewBinding( + key.WithKeys("a", "A", "ctrl+a"), + key.WithHelp("a", "allow"), + ), + AllowSession: key.NewBinding( + key.WithKeys("s", "S", "ctrl+s"), + key.WithHelp("s", "allow session"), + ), + Deny: key.NewBinding( + key.WithKeys("d", "D"), + key.WithHelp("d", "deny"), + ), + Close: CloseKey, + ToggleDiffMode: key.NewBinding( + key.WithKeys("t"), + key.WithHelp("t", "toggle diff view"), + ), + ToggleFullscreen: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "toggle fullscreen"), + ), + ScrollUp: key.NewBinding( + key.WithKeys("shift+up", "K"), + key.WithHelp("shift+↑", "scroll up"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("shift+down", "J"), + key.WithHelp("shift+↓", "scroll down"), + ), + ScrollLeft: key.NewBinding( + key.WithKeys("shift+left", "H"), + key.WithHelp("shift+←", "scroll left"), + ), + ScrollRight: key.NewBinding( + key.WithKeys("shift+right", "L"), + key.WithHelp("shift+→", "scroll right"), + ), + Choose: key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "choose"), + ), + Scroll: key.NewBinding( + key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"), + key.WithHelp("shift+←↓↑→", "scroll"), + ), + } +} + +var _ Dialog = (*Permissions)(nil) + +// PermissionsOption configures the permissions dialog. +type PermissionsOption func(*Permissions) + +// WithDiffMode sets the initial diff mode (split or unified). +func WithDiffMode(split bool) PermissionsOption { + return func(p *Permissions) { + p.diffSplitMode = &split + } +} + +// NewPermissions creates a new permissions dialog. +func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions { + h := help.New() + h.Styles = com.Styles.DialogHelpStyles() + + km := defaultPermissionsKeyMap() + + // Configure viewport with matching keybindings. + vp := viewport.New() + vp.KeyMap = viewport.KeyMap{ + Up: km.ScrollUp, + Down: km.ScrollDown, + Left: km.ScrollLeft, + Right: km.ScrollRight, + // Disable other viewport keys to avoid conflicts with dialog shortcuts. + PageUp: key.NewBinding(key.WithDisabled()), + PageDown: key.NewBinding(key.WithDisabled()), + HalfPageUp: key.NewBinding(key.WithDisabled()), + HalfPageDown: key.NewBinding(key.WithDisabled()), + } + + p := &Permissions{ + com: com, + permission: perm, + selectedOption: 0, + viewport: vp, + help: h, + keyMap: km, + } + + for _, opt := range opts { + opt(p) + } + + return p +} + +// Calculate usable content width (dialog border + horizontal padding). +func (p *Permissions) calculateContentWidth(width int) int { + t := p.com.Styles + const dialogHorizontalPadding = 2 + return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding +} + +// ID implements [Dialog]. +func (*Permissions) ID() string { + return PermissionsID +} + +// HandleMsg implements [Dialog]. +func (p *Permissions) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, p.keyMap.Close): + // Escape denies the permission request. + return p.respond(PermissionDeny) + case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab): + p.selectedOption = (p.selectedOption + 1) % 3 + case key.Matches(msg, p.keyMap.Left): + // Add 2 instead of subtracting 1 to avoid negative modulo. + p.selectedOption = (p.selectedOption + 2) % 3 + case key.Matches(msg, p.keyMap.Select): + return p.selectCurrentOption() + case key.Matches(msg, p.keyMap.Allow): + return p.respond(PermissionAllow) + case key.Matches(msg, p.keyMap.AllowSession): + return p.respond(PermissionAllowForSession) + case key.Matches(msg, p.keyMap.Deny): + return p.respond(PermissionDeny) + case key.Matches(msg, p.keyMap.ToggleDiffMode): + if p.hasDiffView() { + newMode := !p.isSplitMode() + p.diffSplitMode = &newMode + p.viewportDirty = true + } + case key.Matches(msg, p.keyMap.ToggleFullscreen): + if p.hasDiffView() { + p.fullscreen = !p.fullscreen + } + case key.Matches(msg, p.keyMap.ScrollDown): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollUp): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollLeft): + p.viewport, _ = p.viewport.Update(msg) + case key.Matches(msg, p.keyMap.ScrollRight): + p.viewport, _ = p.viewport.Update(msg) + } + case tea.MouseWheelMsg: + p.viewport, _ = p.viewport.Update(msg) + default: + // Pass unhandled keys to viewport for non-diff content scrolling. + if !p.hasDiffView() { + p.viewport, _ = p.viewport.Update(msg) + p.viewportDirty = true + } + } + + return nil +} + +func (p *Permissions) selectCurrentOption() tea.Msg { + switch p.selectedOption { + case 0: + return p.respond(PermissionAllow) + case 1: + return p.respond(PermissionAllowForSession) + default: + return p.respond(PermissionDeny) + } +} + +func (p *Permissions) respond(action PermissionAction) tea.Msg { + return ActionPermissionResponse{ + Permission: p.permission, + Action: action, + } +} + +func (p *Permissions) hasDiffView() bool { + switch p.permission.ToolName { + case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName: + return true + } + return false +} + +func (p *Permissions) isSplitMode() bool { + if p.diffSplitMode != nil { + return *p.diffSplitMode + } + return p.defaultDiffSplitMode +} + +// Draw implements [Dialog]. +func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := p.com.Styles + // Force fullscreen when window is too small. + forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight + + // Calculate dialog dimensions based on fullscreen state and content type. + var width, maxHeight int + if forceFullscreen || (p.fullscreen && p.hasDiffView()) { + // Use nearly full window for fullscreen. + width = area.Dx() + maxHeight = area.Dy() + } else if p.hasDiffView() { + // Wide for side-by-side diffs, capped for readability. + width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth) + maxHeight = int(float64(area.Dy()) * diffSizeRatio) + } else { + // Narrower for simple content like commands/URLs. + width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth) + maxHeight = int(float64(area.Dy()) * simpleHeightRatio) + } + + dialogStyle := t.Dialog.View.Width(width).Padding(0, 1) + + contentWidth := p.calculateContentWidth(width) + header := p.renderHeader(contentWidth) + buttons := p.renderButtons(contentWidth) + helpView := p.help.View(p) + + // Calculate available height for content. + headerHeight := lipgloss.Height(header) + buttonsHeight := lipgloss.Height(buttons) + helpHeight := lipgloss.Height(helpView) + frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines + + p.defaultDiffSplitMode = width >= splitModeMinWidth + + // Pre-render content to measure its actual height. + renderedContent := p.renderContent(contentWidth) + contentHeight := lipgloss.Height(renderedContent) + + // For non-diff views, shrink dialog to fit content if it's smaller than max. + var availableHeight int + if !p.hasDiffView() && !forceFullscreen { + fixedHeight := headerHeight + buttonsHeight + helpHeight + frameHeight + neededHeight := fixedHeight + contentHeight + if neededHeight < maxHeight { + availableHeight = contentHeight + } else { + availableHeight = maxHeight - fixedHeight + } + } else { + availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight + } + + // Determine if scrollbar is needed. + needsScrollbar := p.hasDiffView() || contentHeight > availableHeight + viewportWidth := contentWidth + if needsScrollbar { + viewportWidth = contentWidth - 1 // Reserve space for scrollbar. + } + + if p.viewport.Width() != viewportWidth { + // Mark content as dirty if width has changed. + p.viewportDirty = true + renderedContent = p.renderContent(viewportWidth) + } + + var content string + var scrollbar string + p.viewport.SetWidth(viewportWidth) + p.viewport.SetHeight(availableHeight) + if p.viewportDirty { + p.viewport.SetContent(renderedContent) + p.viewportWidth = p.viewport.Width() + p.viewportDirty = false + } + content = p.viewport.View() + if needsScrollbar { + scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset()) + } + + // Join content with scrollbar if present. + if scrollbar != "" { + content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar) + } + + parts := []string{header} + if content != "" { + parts = append(parts, "", content) + } + parts = append(parts, "", buttons, "", helpView) + + innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...) + DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil) + return nil +} + +func (p *Permissions) renderHeader(contentWidth int) string { + t := p.com.Styles + + title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize()) + title = t.Dialog.Title.Render(title) + + // Tool info. + toolLine := p.renderToolName(contentWidth) + pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth) + + lines := []string{title, "", toolLine, pathLine} + + // Add tool-specific header info. + switch p.permission.ToolName { + case tools.BashToolName: + if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth)) + } + case tools.DownloadToolName: + if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth)) + lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth)) + } + case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName: + var filePath string + switch params := p.permission.Params.(type) { + case tools.EditPermissionsParams: + filePath = params.FilePath + case tools.WritePermissionsParams: + filePath = params.FilePath + case tools.MultiEditPermissionsParams: + filePath = params.FilePath + case tools.ViewPermissionsParams: + filePath = params.FilePath + } + if filePath != "" { + lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth)) + } + case tools.LSToolName: + if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok { + lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth)) + } + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (p *Permissions) renderKeyValue(key, value string, width int) string { + t := p.com.Styles + keyStyle := t.Muted + valueStyle := t.Base + + keyStr := keyStyle.Render(key) + valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value) + + return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr) +} + +func (p *Permissions) renderToolName(width int) string { + toolName := p.permission.ToolName + + // Check if this is an MCP tool (format: mcp__). + if strings.HasPrefix(toolName, "mcp_") { + parts := strings.SplitN(toolName, "_", 3) + if len(parts) == 3 { + mcpName := prettyName(parts[1]) + toolPart := prettyName(parts[2]) + toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart) + } + } + + return p.renderKeyValue("Tool", toolName, width) +} + +// prettyName converts snake_case or kebab-case to Title Case. +func prettyName(name string) string { + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return stringext.Capitalize(name) +} + +func (p *Permissions) renderContent(width int) string { + switch p.permission.ToolName { + case tools.BashToolName: + return p.renderBashContent(width) + case tools.EditToolName: + return p.renderEditContent(width) + case tools.WriteToolName: + return p.renderWriteContent(width) + case tools.MultiEditToolName: + return p.renderMultiEditContent(width) + case tools.DownloadToolName: + return p.renderDownloadContent(width) + case tools.FetchToolName: + return p.renderFetchContent(width) + case tools.AgenticFetchToolName: + return p.renderAgenticFetchContent(width) + case tools.ViewToolName: + return p.renderViewContent(width) + case tools.LSToolName: + return p.renderLSContent(width) + default: + return p.renderDefaultContent(width) + } +} + +func (p *Permissions) renderBashContent(width int) string { + params, ok := p.permission.Params.(tools.BashPermissionsParams) + if !ok { + return "" + } + + return p.renderContentPanel(params.Command, width) +} + +func (p *Permissions) renderEditContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.EditPermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderWriteContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.WritePermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderMultiEditContent(contentWidth int) string { + params, ok := p.permission.Params.(tools.MultiEditPermissionsParams) + if !ok { + return "" + } + return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth) +} + +func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string { + if !p.viewportDirty { + if p.isSplitMode() { + return p.splitDiffContent + } + return p.unifiedDiffContent + } + + isSplitMode := p.isSplitMode() + formatter := common.DiffFormatter(p.com.Styles). + Before(fsext.PrettyPath(filePath), oldContent). + After(fsext.PrettyPath(filePath), newContent). + // TODO: Allow horizontal scrolling instead of cropping. However, the + // diffview currently would only background color the width of the + // content. If the viewport is wider than the content, the rest of the + // line would not be colored properly. + Width(contentWidth) + + var result string + if isSplitMode { + formatter = formatter.Split() + p.splitDiffContent = formatter.String() + result = p.splitDiffContent + } else { + formatter = formatter.Unified() + p.unifiedDiffContent = formatter.String() + result = p.unifiedDiffContent + } + + return result +} + +func (p *Permissions) renderDownloadContent(width int) string { + params, ok := p.permission.Params.(tools.DownloadPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath)) + if params.Timeout > 0 { + content += fmt.Sprintf("\nTimeout: %ds", params.Timeout) + } + + return p.renderContentPanel(content, width) +} + +func (p *Permissions) renderFetchContent(width int) string { + params, ok := p.permission.Params.(tools.FetchPermissionsParams) + if !ok { + return "" + } + + return p.renderContentPanel(params.URL, width) +} + +func (p *Permissions) renderAgenticFetchContent(width int) string { + params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams) + if !ok { + return "" + } + + var content string + if params.URL != "" { + content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt) + } else { + content = fmt.Sprintf("Prompt: %s", params.Prompt) + } + + return p.renderContentPanel(content, width) +} + +func (p *Permissions) renderViewContent(width int) string { + params, ok := p.permission.Params.(tools.ViewPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath)) + if params.Offset > 0 { + content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1) + } + if params.Limit > 0 && params.Limit != 2000 { + content += fmt.Sprintf("\nLines to read: %d", params.Limit) + } + + return p.renderContentPanel(content, width) +} + +func (p *Permissions) renderLSContent(width int) string { + params, ok := p.permission.Params.(tools.LSPermissionsParams) + if !ok { + return "" + } + + content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path)) + if len(params.Ignore) > 0 { + content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", ")) + } + + return p.renderContentPanel(content, width) +} + +func (p *Permissions) renderDefaultContent(width int) string { + t := p.com.Styles + var content string + // do not add the description for mcp tools + if !strings.HasPrefix(p.permission.ToolName, "mcp_") { + content = p.permission.Description + } + + // Pretty-print JSON params if available. + if p.permission.Params != nil { + var paramStr string + if str, ok := p.permission.Params.(string); ok { + paramStr = str + } else { + paramStr = fmt.Sprintf("%v", p.permission.Params) + } + + var parsed any + if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil { + if b, err := json.MarshalIndent(parsed, "", " "); err == nil { + jsonContent := string(b) + highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.BgSubtle) + if err == nil { + jsonContent = highlighted + } + if content != "" { + content += "\n\n" + } + content += jsonContent + } + } else if paramStr != "" { + if content != "" { + content += "\n\n" + } + content += paramStr + } + } + + if content == "" { + return "" + } + + return p.renderContentPanel(strings.TrimSpace(content), width) +} + +// renderContentPanel renders content in a panel with the full width. +func (p *Permissions) renderContentPanel(content string, width int) string { + panelStyle := p.com.Styles.Dialog.ContentPanel + return panelStyle.Width(width).Render(content) +} + +func (p *Permissions) renderButtons(contentWidth int) string { + buttons := []common.ButtonOpts{ + {Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0}, + {Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1}, + {Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2}, + } + + content := common.ButtonGroup(p.com.Styles, buttons, " ") + + // If buttons are too wide, stack them vertically. + if lipgloss.Width(content) > contentWidth { + content = common.ButtonGroup(p.com.Styles, buttons, "\n") + return lipgloss.NewStyle(). + Width(contentWidth). + Align(lipgloss.Center). + Render(content) + } + + return lipgloss.NewStyle(). + Width(contentWidth). + Align(lipgloss.Right). + Render(content) +} + +func (p *Permissions) canScroll() bool { + if p.hasDiffView() { + // Diff views can always scroll. + return true + } + // For non-diff content, check if viewport has scrollable content. + return !p.viewport.AtTop() || !p.viewport.AtBottom() +} + +// ShortHelp implements [help.KeyMap]. +func (p *Permissions) ShortHelp() []key.Binding { + bindings := []key.Binding{ + p.keyMap.Choose, + p.keyMap.Select, + p.keyMap.Close, + } + + if p.canScroll() { + bindings = append(bindings, p.keyMap.Scroll) + } + + if p.hasDiffView() { + bindings = append(bindings, + p.keyMap.ToggleDiffMode, + p.keyMap.ToggleFullscreen, + ) + } + + return bindings +} + +// FullHelp implements [help.KeyMap]. +func (p *Permissions) FullHelp() [][]key.Binding { + return [][]key.Binding{p.ShortHelp()} +} diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go new file mode 100644 index 0000000000000000000000000000000000000000..11173f0eaddb35a0b96aad6b1bf957ec86a37044 --- /dev/null +++ b/internal/ui/dialog/quit.go @@ -0,0 +1,133 @@ +package dialog + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" +) + +// QuitID is the identifier for the quit dialog. +const QuitID = "quit" + +// Quit represents a confirmation dialog for quitting the application. +type Quit struct { + com *common.Common + selectedNo bool // true if "No" button is selected + keyMap struct { + LeftRight, + EnterSpace, + Yes, + No, + Tab, + Close, + Quit key.Binding + } +} + +var _ Dialog = (*Quit)(nil) + +// NewQuit creates a new quit confirmation dialog. +func NewQuit(com *common.Common) *Quit { + q := &Quit{ + com: com, + selectedNo: true, + } + q.keyMap.LeftRight = key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "switch options"), + ) + q.keyMap.EnterSpace = key.NewBinding( + key.WithKeys("enter", " "), + key.WithHelp("enter/space", "confirm"), + ) + q.keyMap.Yes = key.NewBinding( + key.WithKeys("y", "Y", "ctrl+c"), + key.WithHelp("y/Y/ctrl+c", "yes"), + ) + q.keyMap.No = key.NewBinding( + key.WithKeys("n", "N"), + key.WithHelp("n/N", "no"), + ) + q.keyMap.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch options"), + ) + q.keyMap.Close = CloseKey + q.keyMap.Quit = key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ) + return q +} + +// ID implements [Model]. +func (*Quit) ID() string { + return QuitID +} + +// HandleMsg implements [Model]. +func (q *Quit) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, q.keyMap.Quit): + return ActionQuit{} + case key.Matches(msg, q.keyMap.Close): + return ActionClose{} + case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab): + q.selectedNo = !q.selectedNo + case key.Matches(msg, q.keyMap.EnterSpace): + if !q.selectedNo { + return ActionQuit{} + } + return ActionClose{} + case key.Matches(msg, q.keyMap.Yes): + return ActionQuit{} + case key.Matches(msg, q.keyMap.No, q.keyMap.Close): + return ActionClose{} + } + } + + return nil +} + +// Draw implements [Dialog]. +func (q *Quit) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + const question = "Are you sure you want to quit?" + baseStyle := q.com.Styles.Base + buttonOpts := []common.ButtonOpts{ + {Text: "Yep!", Selected: !q.selectedNo, Padding: 3}, + {Text: "Nope", Selected: q.selectedNo, Padding: 3}, + } + buttons := common.ButtonGroup(q.com.Styles, buttonOpts, " ") + content := baseStyle.Render( + lipgloss.JoinVertical( + lipgloss.Center, + question, + "", + buttons, + ), + ) + + view := q.com.Styles.BorderFocus.Render(content) + DrawCenter(scr, area, view) + return nil +} + +// ShortHelp implements [help.KeyMap]. +func (q *Quit) ShortHelp() []key.Binding { + return []key.Binding{ + q.keyMap.LeftRight, + q.keyMap.EnterSpace, + } +} + +// FullHelp implements [help.KeyMap]. +func (q *Quit) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No}, + {q.keyMap.Tab, q.keyMap.Close}, + } +} diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go new file mode 100644 index 0000000000000000000000000000000000000000..258c5c77470380478a2ffab9af89db195c849d32 --- /dev/null +++ b/internal/ui/dialog/reasoning.go @@ -0,0 +1,297 @@ +package dialog + +import ( + "errors" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" + "github.com/sahilm/fuzzy" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +const ( + // ReasoningID is the identifier for the reasoning effort dialog. + ReasoningID = "reasoning" + reasoningDialogMaxWidth = 80 + reasoningDialogMaxHeight = 12 +) + +// Reasoning represents a dialog for selecting reasoning effort. +type Reasoning struct { + com *common.Common + help help.Model + list *list.FilterableList + input textinput.Model + + keyMap struct { + Select key.Binding + Next key.Binding + Previous key.Binding + UpDown key.Binding + Close key.Binding + } +} + +// ReasoningItem represents a reasoning effort list item. +type ReasoningItem struct { + effort string + title string + isCurrent bool + t *styles.Styles + m fuzzy.Match + cache map[int]string + focused bool +} + +var ( + _ Dialog = (*Reasoning)(nil) + _ ListItem = (*ReasoningItem)(nil) +) + +// NewReasoning creates a new reasoning effort dialog. +func NewReasoning(com *common.Common) (*Reasoning, error) { + r := &Reasoning{com: com} + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + r.help = help + + r.list = list.NewFilterableList() + r.list.Focus() + + r.input = textinput.New() + r.input.SetVirtualCursor(false) + r.input.Placeholder = "Type to filter" + r.input.SetStyles(com.Styles.TextInput) + r.input.Focus() + + r.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ) + r.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + r.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + r.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑/↓", "choose"), + ) + r.keyMap.Close = CloseKey + + if err := r.setReasoningItems(); err != nil { + return nil, err + } + + return r, nil +} + +// ID implements Dialog. +func (r *Reasoning) ID() string { + return ReasoningID +} + +// HandleMsg implements [Dialog]. +func (r *Reasoning) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, r.keyMap.Close): + return ActionClose{} + case key.Matches(msg, r.keyMap.Previous): + r.list.Focus() + if r.list.IsSelectedFirst() { + r.list.SelectLast() + r.list.ScrollToBottom() + break + } + r.list.SelectPrev() + r.list.ScrollToSelected() + case key.Matches(msg, r.keyMap.Next): + r.list.Focus() + if r.list.IsSelectedLast() { + r.list.SelectFirst() + r.list.ScrollToTop() + break + } + r.list.SelectNext() + r.list.ScrollToSelected() + case key.Matches(msg, r.keyMap.Select): + selectedItem := r.list.SelectedItem() + if selectedItem == nil { + break + } + reasoningItem, ok := selectedItem.(*ReasoningItem) + if !ok { + break + } + return ActionSelectReasoningEffort{Effort: reasoningItem.effort} + default: + var cmd tea.Cmd + r.input, cmd = r.input.Update(msg) + value := r.input.Value() + r.list.SetFilter(value) + r.list.ScrollToTop() + r.list.SetSelected(0) + return ActionCmd{cmd} + } + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +func (r *Reasoning) Cursor() *tea.Cursor { + return InputCursor(r.com.Styles, r.input.Cursor()) +} + +// Draw implements [Dialog]. +func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := r.com.Styles + width := max(0, min(reasoningDialogMaxWidth, area.Dx())) + height := max(0, min(reasoningDialogMaxHeight, area.Dy())) + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + + r.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) + r.list.SetSize(innerWidth, height-heightOffset) + r.help.SetWidth(innerWidth) + + rc := NewRenderContext(t, width) + rc.Title = "Select Reasoning Effort" + inputView := t.Dialog.InputPrompt.Render(r.input.View()) + rc.AddPart(inputView) + + visibleCount := len(r.list.VisibleItems()) + if r.list.Height() >= visibleCount { + r.list.ScrollToTop() + } else { + r.list.ScrollToSelected() + } + + listView := t.Dialog.List.Height(r.list.Height()).Render(r.list.Render()) + rc.AddPart(listView) + rc.Help = r.help.View(r) + + view := rc.Render() + + cur := r.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur +} + +// ShortHelp implements [help.KeyMap]. +func (r *Reasoning) ShortHelp() []key.Binding { + return []key.Binding{ + r.keyMap.UpDown, + r.keyMap.Select, + r.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (r *Reasoning) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := []key.Binding{ + r.keyMap.Select, + r.keyMap.Next, + r.keyMap.Previous, + r.keyMap.Close, + } + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +func (r *Reasoning) setReasoningItems() error { + cfg := r.com.Config() + agentCfg, ok := cfg.Agents[config.AgentCoder] + if !ok { + return errors.New("agent configuration not found") + } + + selectedModel := cfg.Models[agentCfg.Model] + model := cfg.GetModelByType(agentCfg.Model) + if model == nil { + return errors.New("model configuration not found") + } + + if len(model.ReasoningLevels) == 0 { + return errors.New("no reasoning levels available") + } + + currentEffort := selectedModel.ReasoningEffort + if currentEffort == "" { + currentEffort = model.DefaultReasoningEffort + } + + caser := cases.Title(language.English) + items := make([]list.FilterableItem, 0, len(model.ReasoningLevels)) + selectedIndex := 0 + for i, effort := range model.ReasoningLevels { + item := &ReasoningItem{ + effort: effort, + title: caser.String(effort), + isCurrent: effort == currentEffort, + t: r.com.Styles, + } + items = append(items, item) + if effort == currentEffort { + selectedIndex = i + } + } + + r.list.SetItems(items...) + r.list.SetSelected(selectedIndex) + r.list.ScrollToSelected() + return nil +} + +// Filter returns the filter value for the reasoning item. +func (r *ReasoningItem) Filter() string { + return r.title +} + +// ID returns the unique identifier for the reasoning effort. +func (r *ReasoningItem) ID() string { + return r.effort +} + +// SetFocused sets the focus state of the reasoning item. +func (r *ReasoningItem) SetFocused(focused bool) { + if r.focused != focused { + r.cache = nil + } + r.focused = focused +} + +// SetMatch sets the fuzzy match for the reasoning item. +func (r *ReasoningItem) SetMatch(m fuzzy.Match) { + r.cache = nil + r.m = m +} + +// Render returns the string representation of the reasoning item. +func (r *ReasoningItem) Render(width int) string { + info := "" + if r.isCurrent { + info = "current" + } + return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m) +} diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go new file mode 100644 index 0000000000000000000000000000000000000000..a70d13ce58fed2ddf1b292d30e405362cf093569 --- /dev/null +++ b/internal/ui/dialog/sessions.go @@ -0,0 +1,194 @@ +package dialog + +import ( + "context" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + uv "github.com/charmbracelet/ultraviolet" +) + +// SessionsID is the identifier for the session selector dialog. +const SessionsID = "session" + +// Session is a session selector dialog. +type Session struct { + com *common.Common + help help.Model + list *list.FilterableList + input textinput.Model + selectedSessionInx int + + keyMap struct { + Select key.Binding + Next key.Binding + Previous key.Binding + UpDown key.Binding + Close key.Binding + } +} + +var _ Dialog = (*Session)(nil) + +// NewSessions creates a new Session dialog. +func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) { + s := new(Session) + s.com = com + sessions, err := com.App.Sessions.List(context.TODO()) + if err != nil { + return nil, err + } + + for i, sess := range sessions { + if sess.ID == selectedSessionID { + s.selectedSessionInx = i + break + } + } + + help := help.New() + help.Styles = com.Styles.DialogHelpStyles() + + s.help = help + s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...) + s.list.Focus() + s.list.SetSelected(s.selectedSessionInx) + s.list.ScrollToSelected() + + s.input = textinput.New() + s.input.SetVirtualCursor(false) + s.input.Placeholder = "Enter session name" + s.input.SetStyles(com.Styles.TextInput) + s.input.Focus() + + s.keyMap.Select = key.NewBinding( + key.WithKeys("enter", "tab", "ctrl+y"), + key.WithHelp("enter", "choose"), + ) + s.keyMap.Next = key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ) + s.keyMap.Previous = key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ) + s.keyMap.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "choose"), + ) + s.keyMap.Close = CloseKey + + return s, nil +} + +// ID implements Dialog. +func (s *Session) ID() string { + return SessionsID +} + +// HandleMsg implements Dialog. +func (s *Session) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, s.keyMap.Close): + return ActionClose{} + case key.Matches(msg, s.keyMap.Previous): + s.list.Focus() + if s.list.IsSelectedFirst() { + s.list.SelectLast() + s.list.ScrollToBottom() + break + } + s.list.SelectPrev() + s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Next): + s.list.Focus() + if s.list.IsSelectedLast() { + s.list.SelectFirst() + s.list.ScrollToTop() + break + } + s.list.SelectNext() + s.list.ScrollToSelected() + case key.Matches(msg, s.keyMap.Select): + if item := s.list.SelectedItem(); item != nil { + sessionItem := item.(*SessionItem) + return ActionSelectSession{sessionItem.Session} + } + default: + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + value := s.input.Value() + s.list.SetFilter(value) + s.list.ScrollToTop() + s.list.SetSelected(0) + return ActionCmd{cmd} + } + } + return nil +} + +// Cursor returns the cursor position relative to the dialog. +func (s *Session) Cursor() *tea.Cursor { + return InputCursor(s.com.Styles, s.input.Cursor()) +} + +// Draw implements [Dialog]. +func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + t := s.com.Styles + width := max(0, min(defaultDialogMaxWidth, area.Dx())) + height := max(0, min(defaultDialogHeight, area.Dy())) + innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2 + heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + + t.Dialog.HelpView.GetVerticalFrameSize() + + t.Dialog.View.GetVerticalFrameSize() + s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding + s.list.SetSize(innerWidth, height-heightOffset) + s.help.SetWidth(innerWidth) + + rc := NewRenderContext(t, width) + rc.Title = "Switch Session" + inputView := t.Dialog.InputPrompt.Render(s.input.View()) + rc.AddPart(inputView) + listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render()) + rc.AddPart(listView) + rc.Help = s.help.View(s) + + view := rc.Render() + + cur := s.Cursor() + DrawCenterCursor(scr, area, view, cur) + return cur +} + +// ShortHelp implements [help.KeyMap]. +func (s *Session) ShortHelp() []key.Binding { + return []key.Binding{ + s.keyMap.UpDown, + s.keyMap.Select, + s.keyMap.Close, + } +} + +// FullHelp implements [help.KeyMap]. +func (s *Session) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := []key.Binding{ + s.keyMap.Select, + s.keyMap.Next, + s.keyMap.Previous, + s.keyMap.Close, + } + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go new file mode 100644 index 0000000000000000000000000000000000000000..6d6852b7359d5f19d85349f34eff3b21c0510a05 --- /dev/null +++ b/internal/ui/dialog/sessions_item.go @@ -0,0 +1,187 @@ +package dialog + +import ( + "fmt" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" + "github.com/dustin/go-humanize" + "github.com/rivo/uniseg" + "github.com/sahilm/fuzzy" +) + +// ListItem represents a selectable and searchable item in a dialog list. +type ListItem interface { + list.FilterableItem + list.Focusable + list.MatchSettable + + // ID returns the unique identifier of the item. + ID() string +} + +// SessionItem wraps a [session.Session] to implement the [ListItem] interface. +type SessionItem struct { + session.Session + t *styles.Styles + m fuzzy.Match + cache map[int]string + focused bool +} + +var _ ListItem = &SessionItem{} + +// Filter returns the filterable value of the session. +func (s *SessionItem) Filter() string { + return s.Title +} + +// ID returns the unique identifier of the session. +func (s *SessionItem) ID() string { + return s.Session.ID +} + +// SetMatch sets the fuzzy match for the session item. +func (s *SessionItem) SetMatch(m fuzzy.Match) { + s.cache = nil + s.m = m +} + +// Render returns the string representation of the session item. +func (s *SessionItem) Render(width int) string { + info := humanize.Time(time.Unix(s.UpdatedAt, 0)) + return renderItem(s.t, s.Title, info, s.focused, width, s.cache, &s.m) +} + +func renderItem(t *styles.Styles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { + if cache == nil { + cache = make(map[int]string) + } + + cached, ok := cache[width] + if ok { + return cached + } + + style := t.Dialog.NormalItem + if focused { + style = t.Dialog.SelectedItem + } + + var infoText string + var infoWidth int + lineWidth := width + if len(info) > 0 { + infoText = fmt.Sprintf(" %s ", info) + if focused { + infoText = t.Base.Render(infoText) + } else { + infoText = t.Subtle.Render(infoText) + } + + infoWidth = lipgloss.Width(infoText) + } + + title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "") + titleWidth := lipgloss.Width(title) + gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth)) + content := title + if matches := len(m.MatchedIndexes); matches > 0 { + var lastPos int + parts := make([]string, 0) + ranges := matchedRanges(m.MatchedIndexes) + for _, rng := range ranges { + start, stop := bytePosToVisibleCharPos(title, rng) + if start > lastPos { + parts = append(parts, title[lastPos:start]) + } + // NOTE: We're using [ansi.Style] here instead of [lipglosStyle] + // because we can control the underline start and stop more + // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline] + // which only affect the underline attribute without interfering + // with other style + parts = append(parts, + ansi.NewStyle().Underline(true).String(), + title[start:stop+1], + ansi.NewStyle().Underline(false).String(), + ) + lastPos = stop + 1 + } + if lastPos < len(title) { + parts = append(parts, title[lastPos:]) + } + + content = strings.Join(parts, "") + } + + content = style.Render(content + gap + infoText) + cache[width] = content + return content +} + +// SetFocused sets the focus state of the session item. +func (s *SessionItem) SetFocused(focused bool) { + if s.focused != focused { + s.cache = nil + } + s.focused = focused +} + +// sessionItems takes a slice of [session.Session]s and convert them to a slice +// of [ListItem]s. +func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem { + items := make([]list.FilterableItem, len(sessions)) + for i, s := range sessions { + items[i] = &SessionItem{Session: s, t: t} + } + return items +} + +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} + } + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} + } + var out [][2]int + for i := 1; i < len(in); i++ { + if in[i] == current[1]+1 { + current[1] = in[i] + } else { + out = append(out, current) + current = [2]int{in[i], in[i]} + } + } + out = append(out, current) + return out +} + +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go new file mode 100644 index 0000000000000000000000000000000000000000..06183ae8142b6d7f2e4ff932cdfa07273f1a16c8 --- /dev/null +++ b/internal/ui/image/image.go @@ -0,0 +1,299 @@ +package image + +import ( + "bytes" + "fmt" + "hash/fnv" + "image" + "image/color" + "io" + "log/slog" + "strings" + "sync" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/kitty" + "github.com/charmbracelet/x/mosaic" + "github.com/disintegration/imaging" +) + +// Capabilities represents the capabilities of displaying images on the +// terminal. +type Capabilities struct { + // Columns is the number of character columns in the terminal. + Columns int + // Rows is the number of character rows in the terminal. + Rows int + // PixelWidth is the width of the terminal in pixels. + PixelWidth int + // PixelHeight is the height of the terminal in pixels. + PixelHeight int + // SupportsKittyGraphics indicates whether the terminal supports the Kitty + // graphics protocol. + SupportsKittyGraphics bool + // Env is the terminal environment variables. + Env uv.Environ +} + +// CellSize returns the size of a single terminal cell in pixels. +func (c Capabilities) CellSize() CellSize { + return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows) +} + +// CalculateCellSize calculates the size of a single terminal cell in pixels +// based on the terminal's pixel dimensions and character dimensions. +func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize { + if charWidth == 0 || charHeight == 0 { + return CellSize{} + } + + return CellSize{ + Width: pixelWidth / charWidth, + Height: pixelHeight / charHeight, + } +} + +// RequestCapabilities is a [tea.Cmd] that requests the terminal to report +// its image related capabilities to the program. +func RequestCapabilities(env uv.Environ) tea.Cmd { + winOpReq := ansi.WindowOp(14) // Window size in pixels + // ID 31 is just a random ID used to detect Kitty graphics support. + kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24") + if _, isTmux := env.LookupEnv("TMUX"); isTmux { + kittyReq = ansi.TmuxPassthrough(kittyReq) + } + + return tea.Raw(winOpReq + kittyReq) +} + +// TransmittedMsg is a message indicating that an image has been transmitted to +// the terminal. +type TransmittedMsg struct { + ID string +} + +// Encoding represents the encoding format of the image. +type Encoding byte + +// Image encodings. +const ( + EncodingBlocks Encoding = iota + EncodingKitty +) + +type imageKey struct { + id string + cols int + rows int +} + +// Hash returns a hash value for the image key. +// This uses FNV-32a for simplicity and speed. +func (k imageKey) Hash() uint32 { + h := fnv.New32a() + _, _ = io.WriteString(h, k.ID()) + return h.Sum32() +} + +// ID returns a unique string representation of the image key. +func (k imageKey) ID() string { + return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows) +} + +// CellSize represents the size of a single terminal cell in pixels. +type CellSize struct { + Width, Height int +} + +type cachedImage struct { + img image.Image + cols, rows int +} + +var ( + cachedImages = map[imageKey]cachedImage{} + cachedMutex sync.RWMutex +) + +// fitImage resizes the image to fit within the specified dimensions in +// terminal cells, maintaining the aspect ratio. +func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image { + if img == nil { + return nil + } + + key := imageKey{id: id, cols: cols, rows: rows} + + cachedMutex.RLock() + cached, ok := cachedImages[key] + cachedMutex.RUnlock() + if ok { + return cached.img + } + + if cs.Width == 0 || cs.Height == 0 { + return img + } + + maxWidth := cols * cs.Width + maxHeight := rows * cs.Height + + img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos) + + cachedMutex.Lock() + cachedImages[key] = cachedImage{ + img: img, + cols: cols, + rows: rows, + } + cachedMutex.Unlock() + + return img +} + +// HasTransmitted checks if the image with the given ID has already been +// transmitted to the terminal. +func HasTransmitted(id string, cols, rows int) bool { + key := imageKey{id: id, cols: cols, rows: rows} + + cachedMutex.RLock() + _, ok := cachedImages[key] + cachedMutex.RUnlock() + return ok +} + +// Transmit transmits the image data to the terminal if needed. This is used to +// cache the image on the terminal for later rendering. +func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int, tmux bool) tea.Cmd { + if img == nil { + return nil + } + + key := imageKey{id: id, cols: cols, rows: rows} + + cachedMutex.RLock() + _, ok := cachedImages[key] + cachedMutex.RUnlock() + if ok { + return nil + } + + cmd := func() tea.Msg { + if e != EncodingKitty { + cachedMutex.Lock() + cachedImages[key] = cachedImage{ + img: img, + cols: cols, + rows: rows, + } + cachedMutex.Unlock() + return TransmittedMsg{ID: key.ID()} + } + + var buf bytes.Buffer + img := fitImage(id, img, cs, cols, rows) + bounds := img.Bounds() + imgWidth := bounds.Dx() + imgHeight := bounds.Dy() + imgID := int(key.Hash()) + if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{ + ID: imgID, + Action: kitty.TransmitAndPut, + Transmission: kitty.Direct, + Format: kitty.RGBA, + ImageWidth: imgWidth, + ImageHeight: imgHeight, + Columns: cols, + Rows: rows, + VirtualPlacement: true, + Quite: 1, + Chunk: true, + ChunkFormatter: func(chunk string) string { + if tmux { + return ansi.TmuxPassthrough(chunk) + } + return chunk + }, + }); err != nil { + slog.Error("failed to encode image for kitty graphics", "err", err) + return uiutil.InfoMsg{ + Type: uiutil.InfoTypeError, + Msg: "failed to encode image", + } + } + + return tea.RawMsg{Msg: buf.String()} + } + + return cmd +} + +// Render renders the given image within the specified dimensions using the +// specified encoding. +func (e Encoding) Render(id string, cols, rows int) string { + key := imageKey{id: id, cols: cols, rows: rows} + cachedMutex.RLock() + cached, ok := cachedImages[key] + cachedMutex.RUnlock() + if !ok { + return "" + } + + img := cached.img + + switch e { + case EncodingBlocks: + m := mosaic.New().Width(cols).Height(rows).Scale(1) + return strings.TrimSpace(m.Render(img)) + case EncodingKitty: + // Build Kitty graphics unicode place holders + var fg color.Color + var extra int + var r, g, b int + hashedID := key.Hash() + id := int(hashedID) + extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff + + if id <= 255 { + fg = ansi.IndexedColor(b) + } else { + fg = color.RGBA{ + R: uint8(r), //nolint:gosec + G: uint8(g), //nolint:gosec + B: uint8(b), //nolint:gosec + A: 0xff, + } + } + + fgStyle := ansi.NewStyle().ForegroundColor(fg).String() + + var buf bytes.Buffer + for y := range rows { + // As an optimization, we only write the fg color sequence id, and + // column-row data once on the first cell. The terminal will handle + // the rest. + buf.WriteString(fgStyle) + buf.WriteRune(kitty.Placeholder) + buf.WriteRune(kitty.Diacritic(y)) + buf.WriteRune(kitty.Diacritic(0)) + if extra > 0 { + buf.WriteRune(kitty.Diacritic(extra)) + } + for x := 1; x < cols; x++ { + buf.WriteString(fgStyle) + buf.WriteRune(kitty.Placeholder) + } + if y < rows-1 { + buf.WriteByte('\n') + } + } + + return buf.String() + + default: + return "" + } +} diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go new file mode 100644 index 0000000000000000000000000000000000000000..ed018e2e1e26e7d5a5bbc091b17c572331811dc5 --- /dev/null +++ b/internal/ui/list/filterable.go @@ -0,0 +1,125 @@ +package list + +import ( + "github.com/sahilm/fuzzy" +) + +// FilterableItem is an item that can be filtered via a query. +type FilterableItem interface { + Item + // Filter returns the value to be used for filtering. + Filter() string +} + +// MatchSettable is an interface for items that can have their match indexes +// and match score set. +type MatchSettable interface { + SetMatch(fuzzy.Match) +} + +// FilterableList is a list that takes filterable items that can be filtered +// via a settable query. +type FilterableList struct { + *List + items []FilterableItem + query string +} + +// NewFilterableList creates a new filterable list. +func NewFilterableList(items ...FilterableItem) *FilterableList { + f := &FilterableList{ + List: NewList(), + items: items, + } + f.RegisterRenderCallback(FocusedRenderCallback(f.List)) + f.SetItems(items...) + return f +} + +// SetItems sets the list items and updates the filtered items. +func (f *FilterableList) SetItems(items ...FilterableItem) { + f.items = items + fitems := make([]Item, len(items)) + for i, item := range items { + fitems[i] = item + } + f.List.SetItems(fitems...) +} + +// AppendItems appends items to the list and updates the filtered items. +func (f *FilterableList) AppendItems(items ...FilterableItem) { + f.items = append(f.items, items...) + itms := make([]Item, len(f.items)) + for i, item := range f.items { + itms[i] = item + } + f.List.SetItems(itms...) +} + +// PrependItems prepends items to the list and updates the filtered items. +func (f *FilterableList) PrependItems(items ...FilterableItem) { + f.items = append(items, f.items...) + itms := make([]Item, len(f.items)) + for i, item := range f.items { + itms[i] = item + } + f.List.SetItems(itms...) +} + +// SetFilter sets the filter query and updates the list items. +func (f *FilterableList) SetFilter(q string) { + f.query = q + f.List.SetItems(f.VisibleItems()...) + f.ScrollToTop() +} + +// FilterableItemsSource is a type that implements [fuzzy.Source] for filtering +// [FilterableItem]s. +type FilterableItemsSource []FilterableItem + +// Len returns the length of the source. +func (f FilterableItemsSource) Len() int { + return len(f) +} + +// String returns the string representation of the item at index i. +func (f FilterableItemsSource) String(i int) string { + return f[i].Filter() +} + +// VisibleItems returns the visible items after filtering. +func (f *FilterableList) VisibleItems() []Item { + if f.query == "" { + items := make([]Item, len(f.items)) + for i, item := range f.items { + if ms, ok := item.(MatchSettable); ok { + ms.SetMatch(fuzzy.Match{}) + item = ms.(FilterableItem) + } + items[i] = item + } + return items + } + + items := FilterableItemsSource(f.items) + matches := fuzzy.FindFrom(f.query, items) + matchedItems := []Item{} + resultSize := len(matches) + for i := range resultSize { + match := matches[i] + item := items[match.Index] + if ms, ok := item.(MatchSettable); ok { + ms.SetMatch(match) + item = ms.(FilterableItem) + } + matchedItems = append(matchedItems, item) + } + + return matchedItems +} + +// Render renders the filterable list. +func (f *FilterableList) Render() string { + f.List.SetItems(f.VisibleItems()...) + return f.List.Render() +} diff --git a/internal/ui/list/focus.go b/internal/ui/list/focus.go new file mode 100644 index 0000000000000000000000000000000000000000..6bdee37afa39a69d6d321b1894c6a5f221fc307d --- /dev/null +++ b/internal/ui/list/focus.go @@ -0,0 +1,13 @@ +package list + +// FocusedRenderCallback is a helper function that returns a render callback +// that marks items as focused during rendering. +func FocusedRenderCallback(list *List) RenderCallback { + return func(idx, selectedIdx int, item Item) Item { + if focusable, ok := item.(Focusable); ok { + focusable.SetFocused(list.Focused() && idx == selectedIdx) + return focusable.(Item) + } + return item + } +} diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go new file mode 100644 index 0000000000000000000000000000000000000000..fefe836d110b52496028d21071fffc5262189d92 --- /dev/null +++ b/internal/ui/list/highlight.go @@ -0,0 +1,208 @@ +package list + +import ( + "image" + "strings" + + "charm.land/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" +) + +// DefaultHighlighter is the default highlighter function that applies inverse style. +var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell { + if c == nil { + return c + } + c.Style.Attrs |= uv.AttrReverse + return c +} + +// Highlighter represents a function that defines how to highlight text. +type Highlighter func(x, y int, c *uv.Cell) *uv.Cell + +// HighlightContent returns the content with highlighted regions based on the specified parameters. +func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string { + var sb strings.Builder + pos := image.Pt(-1, -1) + HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell { + pos.X = x + if pos.Y == -1 { + pos.Y = y + } else if y > pos.Y { + sb.WriteString(strings.Repeat("\n", y-pos.Y)) + pos.Y = y + } + sb.WriteString(c.Content) + return c + }) + if sb.Len() > 0 { + sb.WriteString("\n") + } + return sb.String() +} + +// Highlight highlights a region of text within the given content and region. +func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string { + buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter) + if buf == nil { + return content + } + return buf.Render() +} + +// HighlightBuffer highlights a region of text within the given content and +// region, returning a [uv.ScreenBuffer]. +func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer { + if startLine < 0 || startCol < 0 { + return nil + } + + if highlighter == nil { + highlighter = DefaultHighlighter + } + + width, height := area.Dx(), area.Dy() + buf := uv.NewScreenBuffer(width, height) + styled := uv.NewStyledString(content) + styled.Draw(&buf, area) + + // Treat -1 as "end of content" + if endLine < 0 { + endLine = height - 1 + } + if endCol < 0 { + endCol = width + } + + for y := startLine; y <= endLine && y < height; y++ { + if y >= buf.Height() { + break + } + + line := buf.Line(y) + + // Determine column range for this line + colStart := 0 + if y == startLine { + colStart = min(startCol, len(line)) + } + + colEnd := len(line) + if y == endLine { + colEnd = min(endCol, len(line)) + } + + // Track last non-empty position as we go + lastContentX := -1 + + // Single pass: check content and track last non-empty position + for x := colStart; x < colEnd; x++ { + cell := line.At(x) + if cell == nil { + continue + } + + // Update last content position if non-empty + if cell.Content != "" && cell.Content != " " { + lastContentX = x + } + } + + // Only apply highlight up to last content position + highlightEnd := colEnd + if lastContentX >= 0 { + highlightEnd = lastContentX + 1 + } else if lastContentX == -1 { + highlightEnd = colStart // No content on this line + } + + // Apply highlight style only to cells with content + for x := colStart; x < highlightEnd; x++ { + if !image.Pt(x, y).In(area) { + continue + } + cell := line.At(x) + if cell != nil { + line.Set(x, highlighter(x, y, cell)) + } + } + } + + return &buf +} + +// ToHighlighter converts a [lipgloss.Style] to a [Highlighter]. +func ToHighlighter(lgStyle lipgloss.Style) Highlighter { + return func(_ int, _ int, c *uv.Cell) *uv.Cell { + if c != nil { + c.Style = ToStyle(lgStyle) + } + return c + } +} + +// ToStyle converts an inline [lipgloss.Style] to a [uv.Style]. +func ToStyle(lgStyle lipgloss.Style) uv.Style { + var uvStyle uv.Style + + // Colors are already color.Color + uvStyle.Fg = lgStyle.GetForeground() + uvStyle.Bg = lgStyle.GetBackground() + + // Build attributes using bitwise OR + var attrs uint8 + + if lgStyle.GetBold() { + attrs |= uv.AttrBold + } + + if lgStyle.GetItalic() { + attrs |= uv.AttrItalic + } + + if lgStyle.GetUnderline() { + uvStyle.Underline = uv.UnderlineSingle + } + + if lgStyle.GetStrikethrough() { + attrs |= uv.AttrStrikethrough + } + + if lgStyle.GetFaint() { + attrs |= uv.AttrFaint + } + + if lgStyle.GetBlink() { + attrs |= uv.AttrBlink + } + + if lgStyle.GetReverse() { + attrs |= uv.AttrReverse + } + + uvStyle.Attrs = attrs + + return uvStyle +} + +// AdjustArea adjusts the given area rectangle by subtracting margins, borders, +// and padding from the style. +func AdjustArea(area image.Rectangle, style lipgloss.Style) image.Rectangle { + topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin() + topBorder, rightBorder, bottomBorder, leftBorder := style.GetBorderTopSize(), + style.GetBorderRightSize(), + style.GetBorderBottomSize(), + style.GetBorderLeftSize() + topPadding, rightPadding, bottomPadding, leftPadding := style.GetPadding() + + return image.Rectangle{ + Min: image.Point{ + X: area.Min.X + leftMargin + leftBorder + leftPadding, + Y: area.Min.Y + topMargin + topBorder + topPadding, + }, + Max: image.Point{ + X: area.Max.X - (rightMargin + rightBorder + rightPadding), + Y: area.Max.Y - (bottomMargin + bottomBorder + bottomPadding), + }, + } +} diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go new file mode 100644 index 0000000000000000000000000000000000000000..7ac87212889dbc58773b409b5a4a96ec47d1fede --- /dev/null +++ b/internal/ui/list/item.go @@ -0,0 +1,61 @@ +package list + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// Item represents a single item in the lazy-loaded list. +type Item interface { + // Render returns the string representation of the item for the given + // width. + Render(width int) string +} + +// RawRenderable represents an item that can provide a raw rendering +// without additional styling. +type RawRenderable interface { + // RawRender returns the raw rendered string without any additional + // styling. + RawRender(width int) string +} + +// Focusable represents an item that can be aware of focus state changes. +type Focusable interface { + // SetFocused sets the focus state of the item. + SetFocused(focused bool) +} + +// Highlightable represents an item that can highlight a portion of its content. +type Highlightable interface { + // SetHighlight highlights the content from the given start to end + // positions. Use -1 for no highlight. + SetHighlight(startLine, startCol, endLine, endCol int) + // Highlight returns the current highlight positions within the item. + Highlight() (startLine, startCol, endLine, endCol int) +} + +// MouseClickable represents an item that can handle mouse click events. +type MouseClickable interface { + // HandleMouseClick processes a mouse click event at the given coordinates. + // It returns true if the event was handled, false otherwise. + HandleMouseClick(btn ansi.MouseButton, x, y int) bool +} + +// SpacerItem is a spacer item that adds vertical space in the list. +type SpacerItem struct { + Height int +} + +// NewSpacerItem creates a new [SpacerItem] with the specified height. +func NewSpacerItem(height int) *SpacerItem { + return &SpacerItem{ + Height: max(0, height-1), + } +} + +// Render implements the Item interface for [SpacerItem]. +func (s *SpacerItem) Render(width int) string { + return strings.Repeat("\n", s.Height) +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go new file mode 100644 index 0000000000000000000000000000000000000000..78cb437d361f8ec05d81acfa98f2e87a23755d58 --- /dev/null +++ b/internal/ui/list/list.go @@ -0,0 +1,634 @@ +package list + +import ( + "strings" +) + +// List represents a list of items that can be lazily rendered. A list is +// always rendered like a chat conversation where items are stacked vertically +// from top to bottom. +type List struct { + // Viewport size + width, height int + + // Items in the list + items []Item + + // Gap between items (0 or less means no gap) + gap int + + // show list in reverse order + reverse bool + + // Focus and selection state + focused bool + selectedIdx int // The current selected index -1 means no selection + + // offsetIdx is the index of the first visible item in the viewport. + offsetIdx int + // offsetLine is the number of lines of the item at offsetIdx that are + // scrolled out of view (above the viewport). + // It must always be >= 0. + offsetLine int + + // renderCallbacks is a list of callbacks to apply when rendering items. + renderCallbacks []func(idx, selectedIdx int, item Item) Item +} + +// renderedItem holds the rendered content and height of an item. +type renderedItem struct { + content string + height int +} + +// NewList creates a new lazy-loaded list. +func NewList(items ...Item) *List { + l := new(List) + l.items = items + l.selectedIdx = -1 + return l +} + +// RenderCallback defines a function that can modify an item before it is +// rendered. +type RenderCallback func(idx, selectedIdx int, item Item) Item + +// RegisterRenderCallback registers a callback to be called when rendering +// items. This can be used to modify items before they are rendered. +func (l *List) RegisterRenderCallback(cb RenderCallback) { + l.renderCallbacks = append(l.renderCallbacks, cb) +} + +// SetSize sets the size of the list viewport. +func (l *List) SetSize(width, height int) { + l.width = width + l.height = height +} + +// SetGap sets the gap between items. +func (l *List) SetGap(gap int) { + l.gap = gap +} + +// Gap returns the gap between items. +func (l *List) Gap() int { + return l.gap +} + +// SetReverse shows the list in reverse order. +func (l *List) SetReverse(reverse bool) { + l.reverse = reverse +} + +// Width returns the width of the list viewport. +func (l *List) Width() int { + return l.width +} + +// Height returns the height of the list viewport. +func (l *List) Height() int { + return l.height +} + +// Len returns the number of items in the list. +func (l *List) Len() int { + return len(l.items) +} + +// getItem renders (if needed) and returns the item at the given index. +func (l *List) getItem(idx int) renderedItem { + if idx < 0 || idx >= len(l.items) { + return renderedItem{} + } + + item := l.items[idx] + if len(l.renderCallbacks) > 0 { + for _, cb := range l.renderCallbacks { + if it := cb(idx, l.selectedIdx, item); it != nil { + item = it + } + } + } + + rendered := item.Render(l.width) + rendered = strings.TrimRight(rendered, "\n") + height := countLines(rendered) + ri := renderedItem{ + content: rendered, + height: height, + } + + return ri +} + +// ScrollToIndex scrolls the list to the given item index. +func (l *List) ScrollToIndex(index int) { + if index < 0 { + index = 0 + } + if index >= len(l.items) { + index = len(l.items) - 1 + } + l.offsetIdx = index + l.offsetLine = 0 +} + +// ScrollBy scrolls the list by the given number of lines. +func (l *List) ScrollBy(lines int) { + if len(l.items) == 0 || lines == 0 { + return + } + + if l.reverse { + lines = -lines + } + + if lines > 0 { + // Scroll down + // Calculate from the bottom how many lines needed to anchor the last + // item to the bottom + var totalLines int + var lastItemIdx int // the last item that can be partially visible + for i := len(l.items) - 1; i >= 0; i-- { + item := l.getItem(i) + totalLines += item.height + if l.gap > 0 && i < len(l.items)-1 { + totalLines += l.gap + } + if totalLines > l.height-1 { + lastItemIdx = i + break + } + } + + // Now scroll down by lines + var item renderedItem + l.offsetLine += lines + for { + item = l.getItem(l.offsetIdx) + totalHeight := item.height + if l.gap > 0 { + totalHeight += l.gap + } + + if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight { + // Valid offset + break + } + + // Move to next item + l.offsetLine -= totalHeight + l.offsetIdx++ + } + + if l.offsetLine >= item.height { + l.offsetLine = item.height + } + } else if lines < 0 { + // Scroll up + l.offsetLine += lines // lines is negative + for l.offsetLine < 0 { + if l.offsetIdx <= 0 { + // Reached top + l.ScrollToTop() + break + } + + // Move to previous item + l.offsetIdx-- + prevItem := l.getItem(l.offsetIdx) + totalHeight := prevItem.height + if l.gap > 0 { + totalHeight += l.gap + } + l.offsetLine += totalHeight + } + } +} + +// VisibleItemIndices finds the range of items that are visible in the viewport. +// This is used for checking if selected item is in view. +func (l *List) VisibleItemIndices() (startIdx, endIdx int) { + if len(l.items) == 0 { + return 0, 0 + } + + startIdx = l.offsetIdx + currentIdx := startIdx + visibleHeight := -l.offsetLine + + for currentIdx < len(l.items) { + item := l.getItem(currentIdx) + visibleHeight += item.height + if l.gap > 0 { + visibleHeight += l.gap + } + + if visibleHeight >= l.height { + break + } + currentIdx++ + } + + endIdx = currentIdx + if endIdx >= len(l.items) { + endIdx = len(l.items) - 1 + } + + return startIdx, endIdx +} + +// Render renders the list and returns the visible lines. +func (l *List) Render() string { + if len(l.items) == 0 { + return "" + } + + var lines []string + currentIdx := l.offsetIdx + currentOffset := l.offsetLine + + linesNeeded := l.height + + for linesNeeded > 0 && currentIdx < len(l.items) { + item := l.getItem(currentIdx) + itemLines := strings.Split(item.content, "\n") + itemHeight := len(itemLines) + + if currentOffset >= 0 && currentOffset < itemHeight { + // Add visible content lines + lines = append(lines, itemLines[currentOffset:]...) + + // Add gap if this is not the absolute last visual element (conceptually gaps are between items) + // But in the loop we can just add it and trim later + if l.gap > 0 { + for i := 0; i < l.gap; i++ { + lines = append(lines, "") + } + } + } else { + // offsetLine starts in the gap + gapOffset := currentOffset - itemHeight + gapRemaining := l.gap - gapOffset + if gapRemaining > 0 { + for range gapRemaining { + lines = append(lines, "") + } + } + } + + linesNeeded = l.height - len(lines) + currentIdx++ + currentOffset = 0 // Reset offset for subsequent items + } + + if len(lines) > l.height { + lines = lines[:l.height] + } + + if l.reverse { + // Reverse the lines so the list renders bottom-to-top. + for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { + lines[i], lines[j] = lines[j], lines[i] + } + } + + return strings.Join(lines, "\n") +} + +// PrependItems prepends items to the list. +func (l *List) PrependItems(items ...Item) { + l.items = append(items, l.items...) + + // Keep view position relative to the content that was visible + l.offsetIdx += len(items) + + // Update selection index if valid + if l.selectedIdx != -1 { + l.selectedIdx += len(items) + } +} + +// SetItems sets the items in the list. +func (l *List) SetItems(items ...Item) { + l.setItems(true, items...) +} + +// setItems sets the items in the list. If evict is true, it clears the +// rendered item cache. +func (l *List) setItems(evict bool, items ...Item) { + l.items = items + l.selectedIdx = min(l.selectedIdx, len(l.items)-1) + l.offsetIdx = min(l.offsetIdx, len(l.items)-1) + l.offsetLine = 0 +} + +// AppendItems appends items to the list. +func (l *List) AppendItems(items ...Item) { + l.items = append(l.items, items...) +} + +// RemoveItem removes the item at the given index from the list. +func (l *List) RemoveItem(idx int) { + if idx < 0 || idx >= len(l.items) { + return + } + + // Remove the item + l.items = append(l.items[:idx], l.items[idx+1:]...) + + // Adjust selection if needed + if l.selectedIdx == idx { + l.selectedIdx = -1 + } else if l.selectedIdx > idx { + l.selectedIdx-- + } + + // Adjust offset if needed + if l.offsetIdx > idx { + l.offsetIdx-- + } else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) { + l.offsetIdx = max(0, len(l.items)-1) + l.offsetLine = 0 + } +} + +// Focused returns whether the list is focused. +func (l *List) Focused() bool { + return l.focused +} + +// Focus sets the focus state of the list. +func (l *List) Focus() { + l.focused = true +} + +// Blur removes the focus state from the list. +func (l *List) Blur() { + l.focused = false +} + +// ScrollToTop scrolls the list to the top. +func (l *List) ScrollToTop() { + l.offsetIdx = 0 + l.offsetLine = 0 +} + +// ScrollToBottom scrolls the list to the bottom. +func (l *List) ScrollToBottom() { + if len(l.items) == 0 { + return + } + + // Scroll to the last item + var totalHeight int + for i := len(l.items) - 1; i >= 0; i-- { + item := l.getItem(i) + totalHeight += item.height + if l.gap > 0 && i < len(l.items)-1 { + totalHeight += l.gap + } + if totalHeight >= l.height { + l.offsetIdx = i + l.offsetLine = totalHeight - l.height + break + } + } + if totalHeight < l.height { + // All items fit in the viewport + l.ScrollToTop() + } +} + +// ScrollToSelected scrolls the list to the selected item. +func (l *List) ScrollToSelected() { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return + } + + startIdx, endIdx := l.VisibleItemIndices() + if l.selectedIdx < startIdx { + // Selected item is above the visible range + l.offsetIdx = l.selectedIdx + l.offsetLine = 0 + } else if l.selectedIdx > endIdx { + // Selected item is below the visible range + // Scroll so that the selected item is at the bottom + var totalHeight int + for i := l.selectedIdx; i >= 0; i-- { + item := l.getItem(i) + totalHeight += item.height + if l.gap > 0 && i < l.selectedIdx { + totalHeight += l.gap + } + if totalHeight >= l.height { + l.offsetIdx = i + l.offsetLine = totalHeight - l.height + break + } + } + if totalHeight < l.height { + // All items fit in the viewport + l.ScrollToTop() + } + } +} + +// SelectedItemInView returns whether the selected item is currently in view. +func (l *List) SelectedItemInView() bool { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return false + } + startIdx, endIdx := l.VisibleItemIndices() + return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx +} + +// SetSelected sets the selected item index in the list. +// It returns -1 if the index is out of bounds. +func (l *List) SetSelected(index int) { + if index < 0 || index >= len(l.items) { + l.selectedIdx = -1 + } else { + l.selectedIdx = index + } +} + +// Selected returns the index of the currently selected item. It returns -1 if +// no item is selected. +func (l *List) Selected() int { + return l.selectedIdx +} + +// IsSelectedFirst returns whether the first item is selected. +func (l *List) IsSelectedFirst() bool { + return l.selectedIdx == 0 +} + +// IsSelectedLast returns whether the last item is selected. +func (l *List) IsSelectedLast() bool { + return l.selectedIdx == len(l.items)-1 +} + +// SelectPrev selects the visually previous item (moves toward visual top). +// It returns whether the selection changed. +func (l *List) SelectPrev() bool { + if l.reverse { + // In reverse, visual up = higher index + if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + return true + } + } else { + // Normal: visual up = lower index + if l.selectedIdx > 0 { + l.selectedIdx-- + return true + } + } + return false +} + +// SelectNext selects the next item in the list. +// It returns whether the selection changed. +func (l *List) SelectNext() bool { + if l.reverse { + // In reverse, visual down = lower index + if l.selectedIdx > 0 { + l.selectedIdx-- + return true + } + } else { + // Normal: visual down = higher index + if l.selectedIdx < len(l.items)-1 { + l.selectedIdx++ + return true + } + } + return false +} + +// SelectFirst selects the first item in the list. +// It returns whether the selection changed. +func (l *List) SelectFirst() bool { + if len(l.items) == 0 { + return false + } + l.selectedIdx = 0 + return true +} + +// SelectLast selects the last item in the list (highest index). +// It returns whether the selection changed. +func (l *List) SelectLast() bool { + if len(l.items) == 0 { + return false + } + l.selectedIdx = len(l.items) - 1 + return true +} + +// WrapToStart wraps selection to the visual start (for circular navigation). +// In normal mode, this is index 0. In reverse mode, this is the highest index. +func (l *List) WrapToStart() bool { + if len(l.items) == 0 { + return false + } + if l.reverse { + l.selectedIdx = len(l.items) - 1 + } else { + l.selectedIdx = 0 + } + return true +} + +// WrapToEnd wraps selection to the visual end (for circular navigation). +// In normal mode, this is the highest index. In reverse mode, this is index 0. +func (l *List) WrapToEnd() bool { + if len(l.items) == 0 { + return false + } + if l.reverse { + l.selectedIdx = 0 + } else { + l.selectedIdx = len(l.items) - 1 + } + return true +} + +// SelectedItem returns the currently selected item. It may be nil if no item +// is selected. +func (l *List) SelectedItem() Item { + if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { + return nil + } + return l.items[l.selectedIdx] +} + +// SelectFirstInView selects the first item currently in view. +func (l *List) SelectFirstInView() { + startIdx, _ := l.VisibleItemIndices() + l.selectedIdx = startIdx +} + +// SelectLastInView selects the last item currently in view. +func (l *List) SelectLastInView() { + _, endIdx := l.VisibleItemIndices() + l.selectedIdx = endIdx +} + +// ItemAt returns the item at the given index. +func (l *List) ItemAt(index int) Item { + if index < 0 || index >= len(l.items) { + return nil + } + return l.items[index] +} + +// ItemIndexAtPosition returns the item at the given viewport-relative y +// coordinate. Returns the item index and the y offset within that item. It +// returns -1, -1 if no item is found. +func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) { + return l.findItemAtY(x, y) +} + +// findItemAtY finds the item at the given viewport y coordinate. +// Returns the item index and the y offset within that item. It returns -1, -1 +// if no item is found. +func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) { + if y < 0 || y >= l.height { + return -1, -1 + } + + // Walk through visible items to find which one contains this y + currentIdx := l.offsetIdx + currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden + + for currentIdx < len(l.items) && currentLine < l.height { + item := l.getItem(currentIdx) + itemEndLine := currentLine + item.height + + // Check if y is within this item's visible range + if y >= currentLine && y < itemEndLine { + // Found the item, calculate itemY (offset within the item) + itemY = y - currentLine + return currentIdx, itemY + } + + // Move to next item + currentLine = itemEndLine + if l.gap > 0 { + currentLine += l.gap + } + currentIdx++ + } + + return -1, -1 +} + +// countLines counts the number of lines in a string. +func countLines(s string) int { + if s == "" { + return 1 + } + return strings.Count(s, "\n") + 1 +} diff --git a/internal/ui/logo/logo.go b/internal/ui/logo/logo.go new file mode 100644 index 0000000000000000000000000000000000000000..9f4cdfef36723cc69dd13f4a60dcd76f0c8f9904 --- /dev/null +++ b/internal/ui/logo/logo.go @@ -0,0 +1,346 @@ +// Package logo renders a Crush wordmark in a stylized way. +package logo + +import ( + "fmt" + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/MakeNowJust/heredoc" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/slice" +) + +// letterform represents a letterform. It can be stretched horizontally by +// a given amount via the boolean argument. +type letterform func(bool) string + +const diag = `╱` + +// Opts are the options for rendering the Crush title art. +type Opts struct { + FieldColor color.Color // diagonal lines + TitleColorA color.Color // left gradient ramp point + TitleColorB color.Color // right gradient ramp point + CharmColor color.Color // Charm™ text color + VersionColor color.Color // Version text color + Width int // width of the rendered logo, used for truncation +} + +// Render renders the Crush logo. Set the argument to true to render the narrow +// version, intended for use in a sidebar. +// +// The compact argument determines whether it renders compact for the sidebar +// or wider for the main pane. +func Render(version string, compact bool, o Opts) string { + const charm = " Charm™" + + fg := func(c color.Color, s string) string { + return lipgloss.NewStyle().Foreground(c).Render(s) + } + + // Title. + const spacing = 1 + letterforms := []letterform{ + letterC, + letterR, + letterU, + letterSStylized, + letterH, + } + stretchIndex := -1 // -1 means no stretching. + if !compact { + stretchIndex = cachedRandN(len(letterforms)) + } + + crush := renderWord(spacing, stretchIndex, letterforms...) + crushWidth := lipgloss.Width(crush) + b := new(strings.Builder) + for r := range strings.SplitSeq(crush, "\n") { + fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB)) + } + crush = b.String() + + // Charm and version. + metaRowGap := 1 + maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap + version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long. + gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version)) + metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version) + + // Join the meta row and big Crush title. + crush = strings.TrimSpace(metaRow + "\n" + crush) + + // Narrow version. + if compact { + field := fg(o.FieldColor, strings.Repeat(diag, crushWidth)) + return strings.Join([]string{field, field, crush, field, ""}, "\n") + } + + fieldHeight := lipgloss.Height(crush) + + // Left field. + const leftWidth = 6 + leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth)) + leftField := new(strings.Builder) + for range fieldHeight { + fmt.Fprintln(leftField, leftFieldRow) + } + + // Right field. + rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap. + const stepDownAt = 0 + rightField := new(strings.Builder) + for i := range fieldHeight { + width := rightWidth + if i >= stepDownAt { + width = rightWidth - (i - stepDownAt) + } + fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n") + } + + // Return the wide version. + const hGap = " " + logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String()) + if o.Width > 0 { + // Truncate the logo to the specified width. + lines := strings.Split(logo, "\n") + for i, line := range lines { + lines[i] = ansi.Truncate(line, o.Width, "") + } + logo = strings.Join(lines, "\n") + } + return logo +} + +// SmallRender renders a smaller version of the Crush logo, suitable for +// smaller windows or sidebar usage. +func SmallRender(width int) string { + t := styles.CurrentTheme() + title := t.S().Base.Foreground(t.Secondary).Render("Charm™") + title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary)) + remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush" + if remainingWidth > 0 { + lines := strings.Repeat("╱", remainingWidth) + title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines)) + } + return title +} + +// renderWord renders letterforms to fork a word. stretchIndex is the index of +// the letter to stretch, or -1 if no letter should be stretched. +func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string { + if spacing < 0 { + spacing = 0 + } + + renderedLetterforms := make([]string, len(letterforms)) + + // pick one letter randomly to stretch + for i, letter := range letterforms { + renderedLetterforms[i] = letter(i == stretchIndex) + } + + if spacing > 0 { + // Add spaces between the letters and render. + renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing)) + } + return strings.TrimSpace( + lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...), + ) +} + +// letterC renders the letter C in a stylized way. It takes an integer that +// determines how many cells to stretch the letter. If the stretch is less than +// 1, it defaults to no stretching. +func letterC(stretch bool) string { + // Here's what we're making: + // + // ▄▀▀▀▀ + // █ + // ▀▀▀▀ + + left := heredoc.Doc(` + ▄ + █ + `) + right := heredoc.Doc(` + ▀ + + ▀ + `) + return joinLetterform( + left, + stretchLetterformPart(right, letterformProps{ + stretch: stretch, + width: 4, + minStretch: 7, + maxStretch: 12, + }), + ) +} + +// letterH renders the letter H in a stylized way. It takes an integer that +// determines how many cells to stretch the letter. If the stretch is less than +// 1, it defaults to no stretching. +func letterH(stretch bool) string { + // Here's what we're making: + // + // █ █ + // █▀▀▀█ + // ▀ ▀ + + side := heredoc.Doc(` + █ + █ + ▀`) + middle := heredoc.Doc(` + + ▀ + `) + return joinLetterform( + side, + stretchLetterformPart(middle, letterformProps{ + stretch: stretch, + width: 3, + minStretch: 8, + maxStretch: 12, + }), + side, + ) +} + +// letterR renders the letter R in a stylized way. It takes an integer that +// determines how many cells to stretch the letter. If the stretch is less than +// 1, it defaults to no stretching. +func letterR(stretch bool) string { + // Here's what we're making: + // + // █▀▀▀▄ + // █▀▀▀▄ + // ▀ ▀ + + left := heredoc.Doc(` + █ + █ + ▀ + `) + center := heredoc.Doc(` + ▀ + ▀ + `) + right := heredoc.Doc(` + ▄ + ▄ + ▀ + `) + return joinLetterform( + left, + stretchLetterformPart(center, letterformProps{ + stretch: stretch, + width: 3, + minStretch: 7, + maxStretch: 12, + }), + right, + ) +} + +// letterSStylized renders the letter S in a stylized way, more so than +// [letterS]. It takes an integer that determines how many cells to stretch the +// letter. If the stretch is less than 1, it defaults to no stretching. +func letterSStylized(stretch bool) string { + // Here's what we're making: + // + // ▄▀▀▀▀▀ + // ▀▀▀▀▀█ + // ▀▀▀▀▀ + + left := heredoc.Doc(` + ▄ + ▀ + ▀ + `) + center := heredoc.Doc(` + ▀ + ▀ + ▀ + `) + right := heredoc.Doc(` + ▀ + █ + `) + return joinLetterform( + left, + stretchLetterformPart(center, letterformProps{ + stretch: stretch, + width: 3, + minStretch: 7, + maxStretch: 12, + }), + right, + ) +} + +// letterU renders the letter U in a stylized way. It takes an integer that +// determines how many cells to stretch the letter. If the stretch is less than +// 1, it defaults to no stretching. +func letterU(stretch bool) string { + // Here's what we're making: + // + // █ █ + // █ █ + // ▀▀▀ + + side := heredoc.Doc(` + █ + █ + `) + middle := heredoc.Doc(` + + + ▀ + `) + return joinLetterform( + side, + stretchLetterformPart(middle, letterformProps{ + stretch: stretch, + width: 3, + minStretch: 7, + maxStretch: 12, + }), + side, + ) +} + +func joinLetterform(letters ...string) string { + return lipgloss.JoinHorizontal(lipgloss.Top, letters...) +} + +// letterformProps defines letterform stretching properties. +// for readability. +type letterformProps struct { + width int + minStretch int + maxStretch int + stretch bool +} + +// stretchLetterformPart is a helper function for letter stretching. If randomize +// is false the minimum number will be used. +func stretchLetterformPart(s string, p letterformProps) string { + if p.maxStretch < p.minStretch { + p.minStretch, p.maxStretch = p.maxStretch, p.minStretch + } + n := p.width + if p.stretch { + n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec + } + parts := make([]string, n) + for i := range parts { + parts[i] = s + } + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) +} diff --git a/internal/ui/logo/rand.go b/internal/ui/logo/rand.go new file mode 100644 index 0000000000000000000000000000000000000000..cf79487e23825b468c98a0f27bbc8dbfbb1a7081 --- /dev/null +++ b/internal/ui/logo/rand.go @@ -0,0 +1,24 @@ +package logo + +import ( + "math/rand/v2" + "sync" +) + +var ( + randCaches = make(map[int]int) + randCachesMu sync.Mutex +) + +func cachedRandN(n int) int { + randCachesMu.Lock() + defer randCachesMu.Unlock() + + if n, ok := randCaches[n]; ok { + return n + } + + r := rand.IntN(n) + randCaches[n] = r + return r +} diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go new file mode 100644 index 0000000000000000000000000000000000000000..9ab77c60f6b1a48228c5d08ae4d9827584b62e6d --- /dev/null +++ b/internal/ui/model/chat.go @@ -0,0 +1,600 @@ +package model + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/chat" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +// Chat represents the chat UI model that handles chat interactions and +// messages. +type Chat struct { + com *common.Common + list *list.List + idInxMap map[string]int // Map of message IDs to their indices in the list + + // Animation visibility optimization: track animations paused due to items + // being scrolled out of view. When items become visible again, their + // animations are restarted. + pausedAnimations map[string]struct{} + + // Mouse state + mouseDown bool + mouseDownItem int // Item index where mouse was pressed + mouseDownX int // X position in item content (character offset) + mouseDownY int // Y position in item (line offset) + mouseDragItem int // Current item index being dragged over + mouseDragX int // Current X in item content + mouseDragY int // Current Y in item +} + +// NewChat creates a new instance of [Chat] that handles chat interactions and +// messages. +func NewChat(com *common.Common) *Chat { + c := &Chat{ + com: com, + idInxMap: make(map[string]int), + pausedAnimations: make(map[string]struct{}), + } + l := list.NewList() + l.SetGap(1) + l.RegisterRenderCallback(c.applyHighlightRange) + l.RegisterRenderCallback(list.FocusedRenderCallback(l)) + c.list = l + c.mouseDownItem = -1 + c.mouseDragItem = -1 + return c +} + +// Height returns the height of the chat view port. +func (m *Chat) Height() int { + return m.list.Height() +} + +// Draw renders the chat UI component to the screen and the given area. +func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) { + uv.NewStyledString(m.list.Render()).Draw(scr, area) +} + +// SetSize sets the size of the chat view port. +func (m *Chat) SetSize(width, height int) { + m.list.SetSize(width, height) +} + +// Len returns the number of items in the chat list. +func (m *Chat) Len() int { + return m.list.Len() +} + +// SetMessages sets the chat messages to the provided list of message items. +func (m *Chat) SetMessages(msgs ...chat.MessageItem) { + m.idInxMap = make(map[string]int) + m.pausedAnimations = make(map[string]struct{}) + + items := make([]list.Item, len(msgs)) + for i, msg := range msgs { + m.idInxMap[msg.ID()] = i + // Register nested tool IDs for tools that contain nested tools. + if container, ok := msg.(chat.NestedToolContainer); ok { + for _, nested := range container.NestedTools() { + m.idInxMap[nested.ID()] = i + } + } + items[i] = msg + } + m.list.SetItems(items...) + m.list.ScrollToBottom() +} + +// AppendMessages appends a new message item to the chat list. +func (m *Chat) AppendMessages(msgs ...chat.MessageItem) { + items := make([]list.Item, len(msgs)) + indexOffset := m.list.Len() + for i, msg := range msgs { + m.idInxMap[msg.ID()] = indexOffset + i + // Register nested tool IDs for tools that contain nested tools. + if container, ok := msg.(chat.NestedToolContainer); ok { + for _, nested := range container.NestedTools() { + m.idInxMap[nested.ID()] = indexOffset + i + } + } + items[i] = msg + } + m.list.AppendItems(items...) +} + +// UpdateNestedToolIDs updates the ID map for nested tools within a container. +// Call this after modifying nested tools to ensure animations work correctly. +func (m *Chat) UpdateNestedToolIDs(containerID string) { + idx, ok := m.idInxMap[containerID] + if !ok { + return + } + + item, ok := m.list.ItemAt(idx).(chat.MessageItem) + if !ok { + return + } + + container, ok := item.(chat.NestedToolContainer) + if !ok { + return + } + + // Register all nested tool IDs to point to the container's index. + for _, nested := range container.NestedTools() { + m.idInxMap[nested.ID()] = idx + } +} + +// Animate animates items in the chat list. Only propagates animation messages +// to visible items to save CPU. When items are not visible, their animation ID +// is tracked so it can be restarted when they become visible again. +func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd { + idx, ok := m.idInxMap[msg.ID] + if !ok { + return nil + } + + animatable, ok := m.list.ItemAt(idx).(chat.Animatable) + if !ok { + return nil + } + + // Check if item is currently visible. + startIdx, endIdx := m.list.VisibleItemIndices() + isVisible := idx >= startIdx && idx <= endIdx + + if !isVisible { + // Item not visible - pause animation by not propagating. + // Track it so we can restart when it becomes visible. + m.pausedAnimations[msg.ID] = struct{}{} + return nil + } + + // Item is visible - remove from paused set and animate. + delete(m.pausedAnimations, msg.ID) + return animatable.Animate(msg) +} + +// RestartPausedVisibleAnimations restarts animations for items that were paused +// due to being scrolled out of view but are now visible again. +func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd { + if len(m.pausedAnimations) == 0 { + return nil + } + + startIdx, endIdx := m.list.VisibleItemIndices() + var cmds []tea.Cmd + + for id := range m.pausedAnimations { + idx, ok := m.idInxMap[id] + if !ok { + // Item no longer exists. + delete(m.pausedAnimations, id) + continue + } + + if idx >= startIdx && idx <= endIdx { + // Item is now visible - restart its animation. + if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + delete(m.pausedAnimations, id) + } + } + + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +// Focus sets the focus state of the chat component. +func (m *Chat) Focus() { + m.list.Focus() +} + +// Blur removes the focus state from the chat component. +func (m *Chat) Blur() { + m.list.Blur() +} + +// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart +// any paused animations that are now visible. +func (m *Chat) ScrollToTopAndAnimate() tea.Cmd { + m.list.ScrollToTop() + return m.RestartPausedVisibleAnimations() +} + +// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to +// restart any paused animations that are now visible. +func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd { + m.list.ScrollToBottom() + return m.RestartPausedVisibleAnimations() +} + +// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns +// a command to restart any paused animations that are now visible. +func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd { + m.list.ScrollBy(lines) + return m.RestartPausedVisibleAnimations() +} + +// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a +// command to restart any paused animations that are now visible. +func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd { + m.list.ScrollToSelected() + return m.RestartPausedVisibleAnimations() +} + +// SelectedItemInView returns whether the selected item is currently in view. +func (m *Chat) SelectedItemInView() bool { + return m.list.SelectedItemInView() +} + +func (m *Chat) isSelectable(index int) bool { + item := m.list.ItemAt(index) + if item == nil { + return false + } + _, ok := item.(list.Focusable) + return ok +} + +// SetSelected sets the selected message index in the chat list. +func (m *Chat) SetSelected(index int) { + m.list.SetSelected(index) + if index < 0 || index >= m.list.Len() { + return + } + for { + if m.isSelectable(m.list.Selected()) { + return + } + if m.list.SelectNext() { + continue + } + // If we're at the end and the last item isn't selectable, walk backwards + // to find the nearest selectable item. + for { + if !m.list.SelectPrev() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } + } +} + +// SelectPrev selects the previous message in the chat list. +func (m *Chat) SelectPrev() { + for { + if !m.list.SelectPrev() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } +} + +// SelectNext selects the next message in the chat list. +func (m *Chat) SelectNext() { + for { + if !m.list.SelectNext() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } +} + +// SelectFirst selects the first message in the chat list. +func (m *Chat) SelectFirst() { + if !m.list.SelectFirst() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + for { + if !m.list.SelectNext() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } +} + +// SelectLast selects the last message in the chat list. +func (m *Chat) SelectLast() { + if !m.list.SelectLast() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + for { + if !m.list.SelectPrev() { + return + } + if m.isSelectable(m.list.Selected()) { + return + } + } +} + +// SelectFirstInView selects the first message currently in view. +func (m *Chat) SelectFirstInView() { + startIdx, endIdx := m.list.VisibleItemIndices() + for i := startIdx; i <= endIdx; i++ { + if m.isSelectable(i) { + m.list.SetSelected(i) + return + } + } +} + +// SelectLastInView selects the last message currently in view. +func (m *Chat) SelectLastInView() { + startIdx, endIdx := m.list.VisibleItemIndices() + for i := endIdx; i >= startIdx; i-- { + if m.isSelectable(i) { + m.list.SetSelected(i) + return + } + } +} + +// ClearMessages removes all messages from the chat list. +func (m *Chat) ClearMessages() { + m.idInxMap = make(map[string]int) + m.pausedAnimations = make(map[string]struct{}) + m.list.SetItems() + m.ClearMouse() +} + +// RemoveMessage removes a message from the chat list by its ID. +func (m *Chat) RemoveMessage(id string) { + idx, ok := m.idInxMap[id] + if !ok { + return + } + + // Remove from list + m.list.RemoveItem(idx) + + // Remove from index map + delete(m.idInxMap, id) + + // Rebuild index map for all items after the removed one + for i := idx; i < m.list.Len(); i++ { + if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok { + m.idInxMap[item.ID()] = i + } + } + + // Clean up any paused animations for this message + delete(m.pausedAnimations, id) +} + +// MessageItem returns the message item with the given ID, or nil if not found. +func (m *Chat) MessageItem(id string) chat.MessageItem { + idx, ok := m.idInxMap[id] + if !ok { + return nil + } + item, ok := m.list.ItemAt(idx).(chat.MessageItem) + if !ok { + return nil + } + return item +} + +// ToggleExpandedSelectedItem expands the selected message item if it is expandable. +func (m *Chat) ToggleExpandedSelectedItem() { + if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok { + expandable.ToggleExpanded() + } +} + +// HandleMouseDown handles mouse down events for the chat component. +func (m *Chat) HandleMouseDown(x, y int) bool { + if m.list.Len() == 0 { + return false + } + + itemIdx, itemY := m.list.ItemIndexAtPosition(x, y) + if itemIdx < 0 { + return false + } + if !m.isSelectable(itemIdx) { + return false + } + + m.mouseDown = true + m.mouseDownItem = itemIdx + m.mouseDownX = x + m.mouseDownY = itemY + m.mouseDragItem = itemIdx + m.mouseDragX = x + m.mouseDragY = itemY + + // Select the item that was clicked + m.list.SetSelected(itemIdx) + + if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok { + return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY) + } + + return true +} + +// HandleMouseUp handles mouse up events for the chat component. +func (m *Chat) HandleMouseUp(x, y int) bool { + if !m.mouseDown { + return false + } + + m.mouseDown = false + return true +} + +// HandleMouseDrag handles mouse drag events for the chat component. +func (m *Chat) HandleMouseDrag(x, y int) bool { + if !m.mouseDown { + return false + } + + if m.list.Len() == 0 { + return false + } + + itemIdx, itemY := m.list.ItemIndexAtPosition(x, y) + if itemIdx < 0 { + return false + } + + m.mouseDragItem = itemIdx + m.mouseDragX = x + m.mouseDragY = itemY + + return true +} + +// HasHighlight returns whether there is currently highlighted content. +func (m *Chat) HasHighlight() bool { + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange() + return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol) +} + +// HighlighContent returns the currently highlighted content based on the mouse +// selection. It returns an empty string if no content is highlighted. +func (m *Chat) HighlighContent() string { + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange() + if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol { + return "" + } + + var sb strings.Builder + for i := startItemIdx; i <= endItemIdx; i++ { + item := m.list.ItemAt(i) + if hi, ok := item.(list.Highlightable); ok { + startLine, startCol, endLine, endCol := hi.Highlight() + listWidth := m.list.Width() + var rendered string + if rr, ok := item.(list.RawRenderable); ok { + rendered = rr.RawRender(listWidth) + } else { + rendered = item.Render(listWidth) + } + sb.WriteString(list.HighlightContent( + rendered, + uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)), + startLine, + startCol, + endLine, + endCol, + )) + sb.WriteString(strings.Repeat("\n", m.list.Gap())) + } + } + + return strings.TrimSpace(sb.String()) +} + +// ClearMouse clears the current mouse interaction state. +func (m *Chat) ClearMouse() { + m.mouseDown = false + m.mouseDownItem = -1 + m.mouseDragItem = -1 +} + +// applyHighlightRange applies the current highlight range to the chat items. +func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item { + if hi, ok := item.(list.Highlightable); ok { + // Apply highlight + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange() + sLine, sCol, eLine, eCol := -1, -1, -1, -1 + if idx >= startItemIdx && idx <= endItemIdx { + if idx == startItemIdx && idx == endItemIdx { + // Single item selection + sLine = startLine + sCol = startCol + eLine = endLine + eCol = endCol + } else if idx == startItemIdx { + // First item - from start position to end of item + sLine = startLine + sCol = startCol + eLine = -1 + eCol = -1 + } else if idx == endItemIdx { + // Last item - from start of item to end position + sLine = 0 + sCol = 0 + eLine = endLine + eCol = endCol + } else { + // Middle item - fully highlighted + sLine = 0 + sCol = 0 + eLine = -1 + eCol = -1 + } + } + + hi.SetHighlight(sLine, sCol, eLine, eCol) + return hi.(list.Item) + } + + return item +} + +// getHighlightRange returns the current highlight range. +func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) { + if m.mouseDownItem < 0 { + return -1, -1, -1, -1, -1, -1 + } + + downItemIdx := m.mouseDownItem + dragItemIdx := m.mouseDragItem + + // Determine selection direction + draggingDown := dragItemIdx > downItemIdx || + (dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) || + (dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX) + + if draggingDown { + // Normal forward selection + startItemIdx = downItemIdx + startLine = m.mouseDownY + startCol = m.mouseDownX + endItemIdx = dragItemIdx + endLine = m.mouseDragY + endCol = m.mouseDragX + } else { + // Backward selection (dragging up) + startItemIdx = dragItemIdx + startLine = m.mouseDragY + startCol = m.mouseDragX + endItemIdx = downItemIdx + endLine = m.mouseDownY + endCol = m.mouseDownX + } + + return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol +} diff --git a/internal/ui/model/header.go b/internal/ui/model/header.go new file mode 100644 index 0000000000000000000000000000000000000000..e01a19143c20e0d3e2c6753b719c28092077ac91 --- /dev/null +++ b/internal/ui/model/header.go @@ -0,0 +1,112 @@ +package model + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +const ( + headerDiag = "╱" + minHeaderDiags = 3 + leftPadding = 1 + rightPadding = 1 +) + +// renderCompactHeader renders the compact header for the given session. +func renderCompactHeader( + com *common.Common, + session *session.Session, + lspClients *csync.Map[string, *lsp.Client], + detailsOpen bool, + width int, +) string { + if session == nil || session.ID == "" { + return "" + } + + t := com.Styles + + var b strings.Builder + + b.WriteString(t.Header.Charm.Render("Charm™")) + b.WriteString(" ") + b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary)) + b.WriteString(" ") + + availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags + details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth) + + remainingWidth := width - + lipgloss.Width(b.String()) - + lipgloss.Width(details) - + leftPadding - + rightPadding + + if remainingWidth > 0 { + b.WriteString(t.Header.Diagonals.Render( + strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)), + )) + b.WriteString(" ") + } + + b.WriteString(details) + + return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String()) +} + +// renderHeaderDetails renders the details section of the header. +func renderHeaderDetails( + com *common.Common, + session *session.Session, + lspClients *csync.Map[string, *lsp.Client], + detailsOpen bool, + availWidth int, +) string { + t := com.Styles + + var parts []string + + errorCount := 0 + for l := range lspClients.Seq() { + errorCount += l.GetDiagnosticCounts().Error + } + + if errorCount > 0 { + parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount))) + } + + agentCfg := config.Get().Agents[config.AgentCoder] + model := config.Get().GetModelByType(agentCfg.Model) + percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100 + formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage))) + parts = append(parts, formattedPercentage) + + const keystroke = "ctrl+d" + if detailsOpen { + parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close")) + } else { + parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open ")) + } + + dot := t.Header.Separator.Render(" • ") + metadata := strings.Join(parts, dot) + metadata = dot + metadata + + const dirTrimLimit = 4 + cfg := com.Config() + cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit) + cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…") + cwd = t.Header.WorkingDir.Render(cwd) + + return cwd + metadata +} diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..053c30aaa1b51b1fd04bc8a3e754460519336359 --- /dev/null +++ b/internal/ui/model/keys.go @@ -0,0 +1,246 @@ +package model + +import "charm.land/bubbles/v2/key" + +type KeyMap struct { + Editor struct { + AddFile key.Binding + SendMessage key.Binding + OpenEditor key.Binding + Newline key.Binding + AddImage key.Binding + MentionFile key.Binding + + // Attachments key maps + AttachmentDeleteMode key.Binding + Escape key.Binding + DeleteAllAttachments key.Binding + } + + Chat struct { + NewSession key.Binding + AddAttachment key.Binding + Cancel key.Binding + Tab key.Binding + Details key.Binding + TogglePills key.Binding + PillLeft key.Binding + PillRight key.Binding + Down key.Binding + Up key.Binding + UpDown key.Binding + DownOneItem key.Binding + UpOneItem key.Binding + UpDownOneItem key.Binding + PageDown key.Binding + PageUp key.Binding + HalfPageDown key.Binding + HalfPageUp key.Binding + Home key.Binding + End key.Binding + Copy key.Binding + ClearHighlight key.Binding + Expand key.Binding + } + + Initialize struct { + Yes, + No, + Enter, + Switch key.Binding + } + + // Global key maps + Quit key.Binding + Help key.Binding + Commands key.Binding + Models key.Binding + Suspend key.Binding + Sessions key.Binding + Tab key.Binding +} + +func DefaultKeyMap() KeyMap { + km := KeyMap{ + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + Help: key.NewBinding( + key.WithKeys("ctrl+g"), + key.WithHelp("ctrl+g", "more"), + ), + Commands: key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "commands"), + ), + Models: key.NewBinding( + key.WithKeys("ctrl+m", "ctrl+l"), + key.WithHelp("ctrl+l", "models"), + ), + Suspend: key.NewBinding( + key.WithKeys("ctrl+z"), + key.WithHelp("ctrl+z", "suspend"), + ), + Sessions: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sessions"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "change focus"), + ), + } + + km.Editor.AddFile = key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "add file"), + ) + km.Editor.SendMessage = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "send"), + ) + km.Editor.OpenEditor = key.NewBinding( + key.WithKeys("ctrl+o"), + key.WithHelp("ctrl+o", "open editor"), + ) + km.Editor.Newline = key.NewBinding( + key.WithKeys("shift+enter", "ctrl+j"), + // "ctrl+j" is a common keybinding for newline in many editors. If + // the terminal supports "shift+enter", we substitute the help tex + // to reflect that. + key.WithHelp("ctrl+j", "newline"), + ) + km.Editor.AddImage = key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add image"), + ) + km.Editor.MentionFile = key.NewBinding( + key.WithKeys("@"), + key.WithHelp("@", "mention file"), + ) + km.Editor.AttachmentDeleteMode = key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), + ) + km.Editor.Escape = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel delete mode"), + ) + km.Editor.DeleteAllAttachments = key.NewBinding( + key.WithKeys("r"), + key.WithHelp("ctrl+r+r", "delete all attachments"), + ) + + km.Chat.NewSession = key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new session"), + ) + km.Chat.AddAttachment = key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add attachment"), + ) + km.Chat.Cancel = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel"), + ) + km.Chat.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "change focus"), + ) + km.Chat.Details = key.NewBinding( + key.WithKeys("ctrl+d"), + key.WithHelp("ctrl+d", "toggle details"), + ) + km.Chat.TogglePills = key.NewBinding( + key.WithKeys("ctrl+space"), + key.WithHelp("ctrl+space", "toggle tasks"), + ) + km.Chat.PillLeft = key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←/→", "switch section"), + ) + km.Chat.PillRight = key.NewBinding( + key.WithKeys("right"), + key.WithHelp("←/→", "switch section"), + ) + + km.Chat.Down = key.NewBinding( + key.WithKeys("down", "ctrl+j", "ctrl+n", "j"), + key.WithHelp("↓", "down"), + ) + km.Chat.Up = key.NewBinding( + key.WithKeys("up", "ctrl+k", "ctrl+p", "k"), + key.WithHelp("↑", "up"), + ) + km.Chat.UpDown = key.NewBinding( + key.WithKeys("up", "down"), + key.WithHelp("↑↓", "scroll"), + ) + km.Chat.UpOneItem = key.NewBinding( + key.WithKeys("shift+up", "K"), + key.WithHelp("shift+↑", "up one item"), + ) + km.Chat.DownOneItem = key.NewBinding( + key.WithKeys("shift+down", "J"), + key.WithHelp("shift+↓", "down one item"), + ) + km.Chat.UpDownOneItem = key.NewBinding( + key.WithKeys("shift+up", "shift+down"), + key.WithHelp("shift+↑↓", "scroll one item"), + ) + km.Chat.HalfPageDown = key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "half page down"), + ) + km.Chat.PageDown = key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ) + km.Chat.PageUp = key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ) + km.Chat.HalfPageUp = key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "half page up"), + ) + km.Chat.Home = key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "home"), + ) + km.Chat.End = key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "end"), + ) + km.Chat.Copy = key.NewBinding( + key.WithKeys("c", "y", "C", "Y"), + key.WithHelp("c/y", "copy"), + ) + km.Chat.ClearHighlight = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "clear selection"), + ) + km.Chat.Expand = key.NewBinding( + key.WithKeys("space"), + key.WithHelp("space", "expand/collapse"), + ) + km.Initialize.Yes = key.NewBinding( + key.WithKeys("y", "Y"), + key.WithHelp("y", "yes"), + ) + km.Initialize.No = key.NewBinding( + key.WithKeys("n", "N", "esc", "alt+esc"), + key.WithHelp("n", "no"), + ) + km.Initialize.Switch = key.NewBinding( + key.WithKeys("left", "right", "tab"), + key.WithHelp("tab", "switch"), + ) + km.Initialize.Enter = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ) + + return km +} diff --git a/internal/ui/model/landing.go b/internal/ui/model/landing.go new file mode 100644 index 0000000000000000000000000000000000000000..a90ef76fdaf779e61477f5a05fd92a68d2e8a257 --- /dev/null +++ b/internal/ui/model/landing.go @@ -0,0 +1,50 @@ +package model + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" +) + +// selectedLargeModel returns the currently selected large language model from +// the agent coordinator, if one exists. +func (m *UI) selectedLargeModel() *agent.Model { + if m.com.App.AgentCoordinator != nil { + model := m.com.App.AgentCoordinator.Model() + return &model + } + return nil +} + +// landingView renders the landing page view showing the current working +// directory, model information, and LSP/MCP status in a two-column layout. +func (m *UI) landingView() string { + t := m.com.Styles + width := m.layout.main.Dx() + cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) + + parts := []string{ + cwd, + } + + parts = append(parts, "", m.modelInfo(width)) + infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...) + + _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1)) + + mcpLspSectionWidth := min(30, (width-1)/2) + + lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false) + mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false) + + content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection) + + return lipgloss.NewStyle(). + Width(width). + Height(m.layout.main.Dy() - 1). + PaddingTop(1). + Render( + lipgloss.JoinVertical(lipgloss.Left, infoSection, "", content), + ) +} diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go new file mode 100644 index 0000000000000000000000000000000000000000..61e9f75d478ef51daee465ca7eeca109acd6c64b --- /dev/null +++ b/internal/ui/model/lsp.go @@ -0,0 +1,118 @@ +package model + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" +) + +// LSPInfo wraps LSP client information with diagnostic counts by severity. +type LSPInfo struct { + app.LSPClientInfo + Diagnostics map[protocol.DiagnosticSeverity]int +} + +// lspInfo renders the LSP status section showing active LSP clients and their +// diagnostic counts. +func (m *UI) lspInfo(width, maxItems int, isSection bool) string { + var lsps []LSPInfo + t := m.com.Styles + + for _, state := range m.lspStates { + client, ok := m.com.App.LSPClients.Get(state.Name) + if !ok { + continue + } + counts := client.GetDiagnosticCounts() + lspErrs := map[protocol.DiagnosticSeverity]int{ + protocol.SeverityError: counts.Error, + protocol.SeverityWarning: counts.Warning, + protocol.SeverityHint: counts.Hint, + protocol.SeverityInformation: counts.Information, + } + + lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs}) + } + title := t.Subtle.Render("LSPs") + if isSection { + title = common.Section(t, title, width) + } + list := t.Subtle.Render("None") + if len(lsps) > 0 { + list = lspList(t, lsps, width, maxItems) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +// lspDiagnostics formats diagnostic counts with appropriate icons and colors. +func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string { + errs := []string{} + if diagnostics[protocol.SeverityError] > 0 { + errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s %d", styles.ErrorIcon, diagnostics[protocol.SeverityError]))) + } + if diagnostics[protocol.SeverityWarning] > 0 { + errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s %d", styles.WarningIcon, diagnostics[protocol.SeverityWarning]))) + } + if diagnostics[protocol.SeverityHint] > 0 { + errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s %d", styles.HintIcon, diagnostics[protocol.SeverityHint]))) + } + if diagnostics[protocol.SeverityInformation] > 0 { + errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s %d", styles.InfoIcon, diagnostics[protocol.SeverityInformation]))) + } + return strings.Join(errs, " ") +} + +// lspList renders a list of LSP clients with their status and diagnostics, +// truncating to maxItems if needed. +func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string { + if maxItems <= 0 { + return "" + } + var renderedLsps []string + for _, l := range lsps { + var icon string + title := l.Name + var description string + var diagnostics string + switch l.State { + case lsp.StateStarting: + icon = t.ItemBusyIcon.String() + description = t.Subtle.Render("starting...") + case lsp.StateReady: + icon = t.ItemOnlineIcon.String() + diagnostics = lspDiagnostics(t, l.Diagnostics) + case lsp.StateError: + icon = t.ItemErrorIcon.String() + description = t.Subtle.Render("error") + if l.Error != nil { + description = t.Subtle.Render(fmt.Sprintf("error: %s", l.Error.Error())) + } + case lsp.StateDisabled: + icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.Subtle.Render("inactive") + default: + icon = t.ItemOfflineIcon.String() + } + renderedLsps = append(renderedLsps, common.Status(t, common.StatusOpts{ + Icon: icon, + Title: title, + Description: description, + ExtraContent: diagnostics, + }, width)) + } + + if len(renderedLsps) > maxItems { + visibleItems := renderedLsps[:maxItems-1] + remaining := len(renderedLsps) - maxItems + visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) + } + return lipgloss.JoinVertical(lipgloss.Left, renderedLsps...) +} diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..40be8619133268edbc53cf2bee863ed89a2af00f --- /dev/null +++ b/internal/ui/model/mcp.go @@ -0,0 +1,98 @@ +package model + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// mcpInfo renders the MCP status section showing active MCP clients and their +// tool/prompt counts. +func (m *UI) mcpInfo(width, maxItems int, isSection bool) string { + var mcps []mcp.ClientInfo + t := m.com.Styles + + for _, mcp := range m.com.Config().MCP.Sorted() { + if state, ok := m.mcpStates[mcp.Name]; ok { + mcps = append(mcps, state) + } + } + + title := t.Subtle.Render("MCPs") + if isSection { + title = common.Section(t, title, width) + } + list := t.Subtle.Render("None") + if len(mcps) > 0 { + list = mcpList(t, mcps, width, maxItems) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +// mcpCounts formats tool and prompt counts for display. +func mcpCounts(t *styles.Styles, counts mcp.Counts) string { + parts := []string{} + if counts.Tools > 0 { + parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d tools", counts.Tools))) + } + if counts.Prompts > 0 { + parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts))) + } + return strings.Join(parts, " ") +} + +// mcpList renders a list of MCP clients with their status and counts, +// truncating to maxItems if needed. +func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string { + if maxItems <= 0 { + return "" + } + var renderedMcps []string + + for _, m := range mcps { + var icon string + title := m.Name + var description string + var extraContent string + + switch m.State { + case mcp.StateStarting: + icon = t.ItemBusyIcon.String() + description = t.Subtle.Render("starting...") + case mcp.StateConnected: + icon = t.ItemOnlineIcon.String() + extraContent = mcpCounts(t, m.Counts) + case mcp.StateError: + icon = t.ItemErrorIcon.String() + description = t.Subtle.Render("error") + if m.Error != nil { + description = t.Subtle.Render(fmt.Sprintf("error: %s", m.Error.Error())) + } + case mcp.StateDisabled: + icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.Subtle.Render("disabled") + default: + icon = t.ItemOfflineIcon.String() + } + + renderedMcps = append(renderedMcps, common.Status(t, common.StatusOpts{ + Icon: icon, + Title: title, + Description: description, + ExtraContent: extraContent, + }, width)) + } + + if len(renderedMcps) > maxItems { + visibleItems := renderedMcps[:maxItems-1] + remaining := len(renderedMcps) - maxItems + visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) + } + return lipgloss.JoinVertical(lipgloss.Left, renderedMcps...) +} diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go new file mode 100644 index 0000000000000000000000000000000000000000..1b922282ae78bf0a89004abfff6098ec3240ff94 --- /dev/null +++ b/internal/ui/model/onboarding.go @@ -0,0 +1,101 @@ +package model + +import ( + "fmt" + "log/slog" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/ui/common" +) + +// markProjectInitialized marks the current project as initialized in the config. +func (m *UI) markProjectInitialized() tea.Msg { + // TODO: handle error so we show it in the tui footer + err := config.MarkProjectInitialized() + if err != nil { + slog.Error(err.Error()) + } + return nil +} + +// updateInitializeView handles keyboard input for the project initialization prompt. +func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { + switch { + case key.Matches(msg, m.keyMap.Initialize.Enter): + if m.onboarding.yesInitializeSelected { + cmds = append(cmds, m.initializeProject()) + } else { + cmds = append(cmds, m.skipInitializeProject()) + } + case key.Matches(msg, m.keyMap.Initialize.Switch): + m.onboarding.yesInitializeSelected = !m.onboarding.yesInitializeSelected + case key.Matches(msg, m.keyMap.Initialize.Yes): + cmds = append(cmds, m.initializeProject()) + case key.Matches(msg, m.keyMap.Initialize.No): + cmds = append(cmds, m.skipInitializeProject()) + } + return cmds +} + +// initializeProject starts project initialization and transitions to the landing view. +func (m *UI) initializeProject() tea.Cmd { + // TODO: initialize the project + // for now we just go to the landing page + m.state = uiLanding + m.focus = uiFocusEditor + // TODO: actually send a message to the agent + return m.markProjectInitialized +} + +// skipInitializeProject skips project initialization and transitions to the landing view. +func (m *UI) skipInitializeProject() tea.Cmd { + // TODO: initialize the project + m.state = uiLanding + m.focus = uiFocusEditor + // mark the project as initialized + return m.markProjectInitialized +} + +// initializeView renders the project initialization prompt with Yes/No buttons. +func (m *UI) initializeView() string { + cfg := m.com.Config() + s := m.com.Styles.Initialize + cwd := home.Short(cfg.WorkingDir()) + initFile := cfg.Options.InitializeAs + + header := s.Header.Render("Would you like to initialize this project?") + path := s.Accent.PaddingLeft(2).Render(cwd) + desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile)) + hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".") + prompt := s.Content.Render("Would you like to initialize now?") + + buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{ + {Text: "Yep!", Selected: m.onboarding.yesInitializeSelected}, + {Text: "Nope", Selected: !m.onboarding.yesInitializeSelected}, + }, " ") + + // max width 60 so the text is compact + width := min(m.layout.main.Dx(), 60) + + return lipgloss.NewStyle(). + Width(width). + Height(m.layout.main.Dy()). + PaddingBottom(1). + AlignVertical(lipgloss.Bottom). + Render(strings.Join( + []string{ + header, + path, + desc, + hint, + prompt, + buttons, + }, + "\n\n", + )) +} diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go new file mode 100644 index 0000000000000000000000000000000000000000..7662b10cc61c19b5333f7487747354341e35aa99 --- /dev/null +++ b/internal/ui/model/pills.go @@ -0,0 +1,283 @@ +package model + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/chat" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// pillStyle returns the appropriate style for a pill based on focus state. +func pillStyle(focused, panelFocused bool, t *styles.Styles) lipgloss.Style { + if !panelFocused || focused { + return t.Pills.Focused + } + return t.Pills.Blurred +} + +const ( + // pillHeightWithBorder is the height of a pill including its border. + pillHeightWithBorder = 3 + // maxTaskDisplayLength is the maximum length of a task name in the pill. + maxTaskDisplayLength = 40 + // maxQueueDisplayLength is the maximum length of a queue item in the list. + maxQueueDisplayLength = 60 +) + +// pillSection represents which section of the pills panel is focused. +type pillSection int + +const ( + pillSectionTodos pillSection = iota + pillSectionQueue +) + +// hasIncompleteTodos returns true if there are any non-completed todos. +func hasIncompleteTodos(todos []session.Todo) bool { + for _, todo := range todos { + if todo.Status != session.TodoStatusCompleted { + return true + } + } + return false +} + +// hasInProgressTodo returns true if there is at least one in-progress todo. +func hasInProgressTodo(todos []session.Todo) bool { + for _, todo := range todos { + if todo.Status == session.TodoStatusInProgress { + return true + } + } + return false +} + +// queuePill renders the queue count pill with gradient triangles. +func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string { + if queue <= 0 { + return "" + } + triangles := styles.ForegroundGrad(t, "▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Secondary) + if queue < len(triangles) { + triangles = triangles[:queue] + } + + content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue) + return pillStyle(focused, panelFocused, t).Render(content) +} + +// todoPill renders the todo progress pill with optional spinner and task name. +func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) string { + if !hasIncompleteTodos(todos) { + return "" + } + + completed := 0 + var currentTodo *session.Todo + for i := range todos { + switch todos[i].Status { + case session.TodoStatusCompleted: + completed++ + case session.TodoStatusInProgress: + if currentTodo == nil { + currentTodo = &todos[i] + } + } + } + + total := len(todos) + + label := t.Base.Render("To-Do") + progress := t.Muted.Render(fmt.Sprintf("%d/%d", completed, total)) + + var content string + if panelFocused { + content = fmt.Sprintf("%s %s", label, progress) + } else if currentTodo != nil { + taskText := currentTodo.Content + if currentTodo.ActiveForm != "" { + taskText = currentTodo.ActiveForm + } + if len(taskText) > maxTaskDisplayLength { + taskText = taskText[:maxTaskDisplayLength-1] + "…" + } + task := t.Subtle.Render(taskText) + content = fmt.Sprintf("%s %s %s %s", spinnerView, label, progress, task) + } else { + content = fmt.Sprintf("%s %s", label, progress) + } + + return pillStyle(focused, panelFocused, t).Render(content) +} + +// todoList renders the expanded todo list. +func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string { + return chat.FormatTodosList(t, sessionTodos, spinnerView, width) +} + +// queueList renders the expanded queue items list. +func queueList(queueItems []string, t *styles.Styles) string { + if len(queueItems) == 0 { + return "" + } + + var lines []string + for _, item := range queueItems { + text := item + if len(text) > maxQueueDisplayLength { + text = text[:maxQueueDisplayLength-1] + "…" + } + prefix := t.Pills.QueueItemPrefix.Render() + " " + lines = append(lines, prefix+t.Muted.Render(text)) + } + + return strings.Join(lines, "\n") +} + +// togglePillsExpanded toggles the pills panel expansion state. +func (m *UI) togglePillsExpanded() tea.Cmd { + if !m.hasSession() { + return nil + } + if m.layout.pills.Dy() > 0 { + if cmd := m.chat.ScrollByAndAnimate(0); cmd != nil { + return cmd + } + } + hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0 + if !hasPills { + return nil + } + m.pillsExpanded = !m.pillsExpanded + if m.pillsExpanded { + if hasIncompleteTodos(m.session.Todos) { + m.focusedPillSection = pillSectionTodos + } else { + m.focusedPillSection = pillSectionQueue + } + } + m.updateLayoutAndSize() + return nil +} + +// switchPillSection changes focus between todo and queue sections. +func (m *UI) switchPillSection(dir int) tea.Cmd { + if !m.pillsExpanded || !m.hasSession() { + return nil + } + hasIncompleteTodos := hasIncompleteTodos(m.session.Todos) + hasQueue := m.promptQueue > 0 + + if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos { + m.focusedPillSection = pillSectionTodos + m.updateLayoutAndSize() + return nil + } + if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue { + m.focusedPillSection = pillSectionQueue + m.updateLayoutAndSize() + return nil + } + return nil +} + +// pillsAreaHeight calculates the total height needed for the pills area. +func (m *UI) pillsAreaHeight() int { + if !m.hasSession() { + return 0 + } + hasIncomplete := hasIncompleteTodos(m.session.Todos) + hasQueue := m.promptQueue > 0 + hasPills := hasIncomplete || hasQueue + if !hasPills { + return 0 + } + + pillsAreaHeight := pillHeightWithBorder + if m.pillsExpanded { + if m.focusedPillSection == pillSectionTodos && hasIncomplete { + pillsAreaHeight += len(m.session.Todos) + } else if m.focusedPillSection == pillSectionQueue && hasQueue { + pillsAreaHeight += m.promptQueue + } + } + return pillsAreaHeight +} + +// renderPills renders the pills panel and stores it in m.pillsView. +func (m *UI) renderPills() { + m.pillsView = "" + if !m.hasSession() { + return + } + + width := m.layout.pills.Dx() + if width <= 0 { + return + } + + paddingLeft := 3 + contentWidth := max(width-paddingLeft, 0) + + hasIncomplete := hasIncompleteTodos(m.session.Todos) + hasQueue := m.promptQueue > 0 + + if !hasIncomplete && !hasQueue { + return + } + + t := m.com.Styles + todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos + queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue + + inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon) + if m.todoIsSpinning { + inProgressIcon = m.todoSpinner.View() + } + + var pills []string + if hasIncomplete { + pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t)) + } + if hasQueue { + pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t)) + } + + var expandedList string + if m.pillsExpanded { + if todosFocused && hasIncomplete { + expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth) + } else if queueFocused && hasQueue { + if m.com.App != nil && m.com.App.AgentCoordinator != nil { + queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID) + expandedList = queueList(queueItems, t) + } + } + } + + if len(pills) == 0 { + return + } + + pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...) + + helpDesc := "open" + if m.pillsExpanded { + helpDesc = "close" + } + helpKey := t.Pills.HelpKey.Render("ctrl+space") + helpText := t.Pills.HelpText.Render(helpDesc) + helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText) + pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint) + + pillsArea := pillsRow + if expandedList != "" { + pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList) + } + + m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea) +} diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go new file mode 100644 index 0000000000000000000000000000000000000000..38fd718db9cf2b44eb48538a9debb25870b90a7d --- /dev/null +++ b/internal/ui/model/session.go @@ -0,0 +1,244 @@ +package model + +import ( + "context" + "fmt" + "path/filepath" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/diff" + "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/x/ansi" +) + +// loadSessionMsg is a message indicating that a session and its files have +// been loaded. +type loadSessionMsg struct { + session *session.Session + files []SessionFile +} + +// SessionFile tracks the first and latest versions of a file in a session, +// along with the total additions and deletions. +type SessionFile struct { + FirstVersion history.File + LatestVersion history.File + Additions int + Deletions int +} + +// loadSession loads the session along with its associated files and computes +// the diff statistics (additions and deletions) for each file in the session. +// It returns a tea.Cmd that, when executed, fetches the session data and +// returns a sessionFilesLoadedMsg containing the processed session files. +func (m *UI) loadSession(sessionID string) tea.Cmd { + return func() tea.Msg { + session, err := m.com.App.Sessions.Get(context.Background(), sessionID) + if err != nil { + // TODO: better error handling + return uiutil.ReportError(err)() + } + + files, err := m.com.App.History.ListBySession(context.Background(), sessionID) + if err != nil { + // TODO: better error handling + return uiutil.ReportError(err)() + } + + filesByPath := make(map[string][]history.File) + for _, f := range files { + filesByPath[f.Path] = append(filesByPath[f.Path], f) + } + + sessionFiles := make([]SessionFile, 0, len(filesByPath)) + for _, versions := range filesByPath { + if len(versions) == 0 { + continue + } + + first := versions[0] + last := versions[0] + for _, v := range versions { + if v.Version < first.Version { + first = v + } + if v.Version > last.Version { + last = v + } + } + + _, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path) + + sessionFiles = append(sessionFiles, SessionFile{ + FirstVersion: first, + LatestVersion: last, + Additions: additions, + Deletions: deletions, + }) + } + + slices.SortFunc(sessionFiles, func(a, b SessionFile) int { + if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt { + return -1 + } + if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt { + return 1 + } + return 0 + }) + + return loadSessionMsg{ + session: &session, + files: sessionFiles, + } + } +} + +// handleFileEvent processes file change events and updates the session file +// list with new or updated file information. +func (m *UI) handleFileEvent(file history.File) tea.Cmd { + if m.session == nil || file.SessionID != m.session.ID { + return nil + } + + return func() tea.Msg { + existingIdx := -1 + for i, sf := range m.sessionFiles { + if sf.FirstVersion.Path == file.Path { + existingIdx = i + break + } + } + + if existingIdx == -1 { + newFiles := make([]SessionFile, 0, len(m.sessionFiles)+1) + newFiles = append(newFiles, SessionFile{ + FirstVersion: file, + LatestVersion: file, + Additions: 0, + Deletions: 0, + }) + newFiles = append(newFiles, m.sessionFiles...) + + return loadSessionMsg{ + session: m.session, + files: newFiles, + } + } + + updated := m.sessionFiles[existingIdx] + + if file.Version < updated.FirstVersion.Version { + updated.FirstVersion = file + } + + if file.Version > updated.LatestVersion.Version { + updated.LatestVersion = file + } + + _, additions, deletions := diff.GenerateDiff( + updated.FirstVersion.Content, + updated.LatestVersion.Content, + updated.FirstVersion.Path, + ) + updated.Additions = additions + updated.Deletions = deletions + + newFiles := make([]SessionFile, 0, len(m.sessionFiles)) + newFiles = append(newFiles, updated) + for i, sf := range m.sessionFiles { + if i != existingIdx { + newFiles = append(newFiles, sf) + } + } + + return loadSessionMsg{ + session: m.session, + files: newFiles, + } + } +} + +// filesInfo renders the modified files section for the sidebar, showing files +// with their addition/deletion counts. +func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string { + t := m.com.Styles + + title := t.Subtle.Render("Modified Files") + if isSection { + title = common.Section(t, "Modified Files", width) + } + list := t.Subtle.Render("None") + + if len(m.sessionFiles) > 0 { + list = fileList(t, cwd, m.sessionFiles, width, maxItems) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +// fileList renders a list of files with their diff statistics, truncating to +// maxItems and showing a "...and N more" message if needed. +func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string { + if maxItems <= 0 { + return "" + } + var renderedFiles []string + filesShown := 0 + + var filesWithChanges []SessionFile + for _, f := range files { + if f.Additions == 0 && f.Deletions == 0 { + continue + } + filesWithChanges = append(filesWithChanges, f) + } + + for _, f := range filesWithChanges { + // Skip files with no changes + if filesShown >= maxItems { + break + } + + // Build stats string with colors + var statusParts []string + if f.Additions > 0 { + statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions))) + } + if f.Deletions > 0 { + statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions))) + } + extraContent := strings.Join(statusParts, " ") + + // Format file path + filePath := f.FirstVersion.Path + if rel, err := filepath.Rel(cwd, filePath); err == nil { + filePath = rel + } + filePath = fsext.DirTrim(filePath, 2) + filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "…") + + line := t.Files.Path.Render(filePath) + if extraContent != "" { + line = fmt.Sprintf("%s %s", line, extraContent) + } + + renderedFiles = append(renderedFiles, line) + filesShown++ + } + + if len(filesWithChanges) > maxItems { + remaining := len(filesWithChanges) - maxItems + renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + } + + return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...) +} diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go new file mode 100644 index 0000000000000000000000000000000000000000..2437fab9177b9186cfcd4c185c45c48204cea7d9 --- /dev/null +++ b/internal/ui/model/sidebar.go @@ -0,0 +1,163 @@ +package model + +import ( + "cmp" + "fmt" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/logo" + uv "github.com/charmbracelet/ultraviolet" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// modelInfo renders the current model information including reasoning +// settings and context usage/cost for the sidebar. +func (m *UI) modelInfo(width int) string { + model := m.selectedLargeModel() + reasoningInfo := "" + providerName := "" + + if model != nil { + // Get provider name first + providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider) + if ok { + providerName = providerConfig.Name + + // Only check reasoning if model can reason + if model.CatwalkCfg.CanReason { + switch providerConfig.Type { + case catwalk.TypeAnthropic: + if model.ModelCfg.Think { + reasoningInfo = "Thinking On" + } else { + reasoningInfo = "Thinking Off" + } + default: + formatter := cases.Title(language.English, cases.NoLower) + reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort) + reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)) + } + } + } + } + + var modelContext *common.ModelContextInfo + if m.session != nil { + modelContext = &common.ModelContextInfo{ + ContextUsed: m.session.CompletionTokens + m.session.PromptTokens, + Cost: m.session.Cost, + ModelContext: model.CatwalkCfg.ContextWindow, + } + } + return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width) +} + +// getDynamicHeightLimits will give us the num of items to show in each section based on the hight +// some items are more important than others. +func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int) { + const ( + minItemsPerSection = 2 + defaultMaxFilesShown = 10 + defaultMaxLSPsShown = 8 + defaultMaxMCPsShown = 8 + minAvailableHeightLimit = 10 + ) + + // If we have very little space, use minimum values + if availableHeight < minAvailableHeightLimit { + return minItemsPerSection, minItemsPerSection, minItemsPerSection + } + + // Distribute available height among the three sections + // Give priority to files, then LSPs, then MCPs + totalSections := 3 + heightPerSection := availableHeight / totalSections + + // Calculate limits for each section, ensuring minimums + maxFiles = max(minItemsPerSection, min(defaultMaxFilesShown, heightPerSection)) + maxLSPs = max(minItemsPerSection, min(defaultMaxLSPsShown, heightPerSection)) + maxMCPs = max(minItemsPerSection, min(defaultMaxMCPsShown, heightPerSection)) + + // If we have extra space, give it to files first + remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs) + if remainingHeight > 0 { + extraForFiles := min(remainingHeight, defaultMaxFilesShown-maxFiles) + maxFiles += extraForFiles + remainingHeight -= extraForFiles + + if remainingHeight > 0 { + extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs) + maxLSPs += extraForLSPs + remainingHeight -= extraForLSPs + + if remainingHeight > 0 { + maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs) + } + } + } + + return maxFiles, maxLSPs, maxMCPs +} + +// sidebar renders the chat sidebar containing session title, working +// directory, model info, file list, LSP status, and MCP status. +func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) { + if m.session == nil { + return + } + + const logoHeightBreakpoint = 30 + + t := m.com.Styles + width := area.Dx() + height := area.Dy() + + title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title) + cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) + sidebarLogo := m.sidebarLogo + if height < logoHeightBreakpoint { + sidebarLogo = logo.SmallRender(width) + } + blocks := []string{ + sidebarLogo, + title, + "", + cwd, + "", + m.modelInfo(width), + "", + } + + sidebarHeader := lipgloss.JoinVertical( + lipgloss.Left, + blocks..., + ) + + _, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader))) + remainingHeight := remainingHeightArea.Dy() - 10 + maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight) + + lspSection := m.lspInfo(width, maxLSPs, true) + mcpSection := m.mcpInfo(width, maxMCPs, true) + filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true) + + uv.NewStyledString( + lipgloss.NewStyle(). + MaxWidth(width). + MaxHeight(height). + Render( + lipgloss.JoinVertical( + lipgloss.Left, + sidebarHeader, + filesSection, + "", + lspSection, + "", + mcpSection, + ), + ), + ).Draw(scr, area) +} diff --git a/internal/ui/model/status.go b/internal/ui/model/status.go new file mode 100644 index 0000000000000000000000000000000000000000..a3371d27d2f19f3236734ea8a31602fa5d518e62 --- /dev/null +++ b/internal/ui/model/status.go @@ -0,0 +1,106 @@ +package model + +import ( + "time" + + "charm.land/bubbles/v2/help" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +// DefaultStatusTTL is the default time-to-live for status messages. +const DefaultStatusTTL = 5 * time.Second + +// Status is the status bar and help model. +type Status struct { + com *common.Common + help help.Model + helpKm help.KeyMap + msg uiutil.InfoMsg +} + +// NewStatus creates a new status bar and help model. +func NewStatus(com *common.Common, km help.KeyMap) *Status { + s := new(Status) + s.com = com + s.help = help.New() + s.help.Styles = com.Styles.Help + s.helpKm = km + return s +} + +// SetInfoMsg sets the status info message. +func (s *Status) SetInfoMsg(msg uiutil.InfoMsg) { + s.msg = msg +} + +// ClearInfoMsg clears the status info message. +func (s *Status) ClearInfoMsg() { + s.msg = uiutil.InfoMsg{} +} + +// SetWidth sets the width of the status bar and help view. +func (s *Status) SetWidth(width int) { + s.help.SetWidth(width) +} + +// ShowingAll returns whether the full help view is shown. +func (s *Status) ShowingAll() bool { + return s.help.ShowAll +} + +// ToggleHelp toggles the full help view. +func (s *Status) ToggleHelp() { + s.help.ShowAll = !s.help.ShowAll +} + +// Draw draws the status bar onto the screen. +func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) { + helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm)) + uv.NewStyledString(helpView).Draw(scr, area) + + // Render notifications + if s.msg.IsEmpty() { + return + } + + var indStyle lipgloss.Style + var msgStyle lipgloss.Style + switch s.msg.Type { + case uiutil.InfoTypeError: + indStyle = s.com.Styles.Status.ErrorIndicator + msgStyle = s.com.Styles.Status.ErrorMessage + case uiutil.InfoTypeWarn: + indStyle = s.com.Styles.Status.WarnIndicator + msgStyle = s.com.Styles.Status.WarnMessage + case uiutil.InfoTypeUpdate: + indStyle = s.com.Styles.Status.UpdateIndicator + msgStyle = s.com.Styles.Status.UpdateMessage + case uiutil.InfoTypeInfo: + indStyle = s.com.Styles.Status.InfoIndicator + msgStyle = s.com.Styles.Status.InfoMessage + case uiutil.InfoTypeSuccess: + indStyle = s.com.Styles.Status.SuccessIndicator + msgStyle = s.com.Styles.Status.SuccessMessage + } + + ind := indStyle.String() + messageWidth := area.Dx() - lipgloss.Width(ind) + msg := ansi.Truncate(s.msg.Msg, messageWidth, "…") + info := msgStyle.Width(messageWidth).Render(msg) + + // Draw the info message over the help view + uv.NewStyledString(ind+info).Draw(scr, area) +} + +// clearInfoMsgCmd returns a command that clears the info message after the +// given TTL. +func clearInfoMsgCmd(ttl time.Duration) tea.Cmd { + return tea.Tick(ttl, func(time.Time) tea.Msg { + return uiutil.ClearStatusMsg{} + }) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go new file mode 100644 index 0000000000000000000000000000000000000000..1c3320225b190d028a83c902f60c99c614023f3d --- /dev/null +++ b/internal/ui/model/ui.go @@ -0,0 +1,2895 @@ +package model + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "log/slog" + "math/rand" + "net/http" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/atotto/clipboard" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/commands" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/filetracker" + "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/session" + "github.com/charmbracelet/crush/internal/ui/anim" + "github.com/charmbracelet/crush/internal/ui/attachments" + "github.com/charmbracelet/crush/internal/ui/chat" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/completions" + "github.com/charmbracelet/crush/internal/ui/dialog" + timage "github.com/charmbracelet/crush/internal/ui/image" + "github.com/charmbracelet/crush/internal/ui/logo" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/uiutil" + "github.com/charmbracelet/crush/internal/version" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/ultraviolet/screen" + "github.com/charmbracelet/x/editor" +) + +// Compact mode breakpoints. +const ( + compactModeWidthBreakpoint = 120 + compactModeHeightBreakpoint = 30 +) + +// If pasted text has more than 2 newlines, treat it as a file attachment. +const pasteLinesThreshold = 10 + +// Session details panel max height. +const sessionDetailsMaxHeight = 20 + +// uiFocusState represents the current focus state of the UI. +type uiFocusState uint8 + +// Possible uiFocusState values. +const ( + uiFocusNone uiFocusState = iota + uiFocusEditor + uiFocusMain +) + +type uiState uint8 + +// Possible uiState values. +const ( + uiConfigure uiState = iota + uiInitialize + uiLanding + uiChat +) + +type openEditorMsg struct { + Text string +} + +type ( + // cancelTimerExpiredMsg is sent when the cancel timer expires. + cancelTimerExpiredMsg struct{} + // userCommandsLoadedMsg is sent when user commands are loaded. + userCommandsLoadedMsg struct { + Commands []commands.CustomCommand + } + // mcpPromptsLoadedMsg is sent when mcp prompts are loaded. + mcpPromptsLoadedMsg struct { + Prompts []commands.MCPPrompt + } + // sendMessageMsg is sent to send a message. + // currently only used for mcp prompts. + sendMessageMsg struct { + Content string + Attachments []message.Attachment + } + + // closeDialogMsg is sent to close the current dialog. + closeDialogMsg struct{} + + // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard. + copyChatHighlightMsg struct{} +) + +// UI represents the main user interface model. +type UI struct { + com *common.Common + session *session.Session + sessionFiles []SessionFile + + lastUserMessageTime int64 + + // The width and height of the terminal in cells. + width int + height int + layout layout + + focus uiFocusState + state uiState + + keyMap KeyMap + keyenh tea.KeyboardEnhancementsMsg + + dialog *dialog.Overlay + status *Status + + // isCanceling tracks whether the user has pressed escape once to cancel. + isCanceling bool + + // header is the last cached header logo + header string + + // sendProgressBar instructs the TUI to send progress bar updates to the + // terminal. + sendProgressBar bool + + // QueryVersion instructs the TUI to query for the terminal version when it + // starts. + QueryVersion bool + + // Editor components + textarea textarea.Model + + // Attachment list + attachments *attachments.Attachments + + readyPlaceholder string + workingPlaceholder string + + // Completions state + completions *completions.Completions + completionsOpen bool + completionsStartIndex int + completionsQuery string + completionsPositionStart image.Point // x,y where user typed '@' + + // Chat components + chat *Chat + + // onboarding state + onboarding struct { + yesInitializeSelected bool + } + + // lsp + lspStates map[string]app.LSPClientInfo + + // mcp + mcpStates map[string]mcp.ClientInfo + + // sidebarLogo keeps a cached version of the sidebar sidebarLogo. + sidebarLogo string + + // imgCaps stores the terminal image capabilities. + imgCaps timage.Capabilities + + // custom commands & mcp commands + customCommands []commands.CustomCommand + mcpPrompts []commands.MCPPrompt + + // forceCompactMode tracks whether compact mode is forced by user toggle + forceCompactMode bool + + // isCompact tracks whether we're currently in compact layout mode (either + // by user toggle or auto-switch based on window size) + isCompact bool + + // detailsOpen tracks whether the details panel is open (in compact mode) + detailsOpen bool + + // pills state + pillsExpanded bool + focusedPillSection pillSection + promptQueue int + pillsView string + + // Todo spinner + todoSpinner spinner.Model + todoIsSpinning bool + + // mouse highlighting related state + lastClickTime time.Time +} + +// New creates a new instance of the [UI] model. +func New(com *common.Common) *UI { + // Editor components + ta := textarea.New() + ta.SetStyles(com.Styles.TextArea) + ta.ShowLineNumbers = false + ta.CharLimit = -1 + ta.SetVirtualCursor(false) + ta.Focus() + + ch := NewChat(com) + + keyMap := DefaultKeyMap() + + // Completions component + comp := completions.New( + com.Styles.Completions.Normal, + com.Styles.Completions.Focused, + com.Styles.Completions.Match, + ) + + todoSpinner := spinner.New( + spinner.WithSpinner(spinner.MiniDot), + spinner.WithStyle(com.Styles.Pills.TodoSpinner), + ) + + // Attachments component + attachments := attachments.New( + attachments.NewRenderer( + com.Styles.Attachments.Normal, + com.Styles.Attachments.Deleting, + com.Styles.Attachments.Image, + com.Styles.Attachments.Text, + ), + attachments.Keymap{ + DeleteMode: keyMap.Editor.AttachmentDeleteMode, + DeleteAll: keyMap.Editor.DeleteAllAttachments, + Escape: keyMap.Editor.Escape, + }, + ) + + ui := &UI{ + com: com, + dialog: dialog.NewOverlay(), + keyMap: keyMap, + focus: uiFocusNone, + state: uiConfigure, + textarea: ta, + chat: ch, + completions: comp, + attachments: attachments, + todoSpinner: todoSpinner, + } + + status := NewStatus(com, ui) + + // set onboarding state defaults + ui.onboarding.yesInitializeSelected = true + + // If no provider is configured show the user the provider list + if !com.Config().IsConfigured() { + ui.state = uiConfigure + // if the project needs initialization show the user the question + } else if n, _ := config.ProjectNeedsInitialization(); n { + ui.state = uiInitialize + // otherwise go to the landing UI + } else { + ui.state = uiLanding + ui.focus = uiFocusEditor + } + + ui.setEditorPrompt(false) + ui.randomizePlaceholders() + ui.textarea.Placeholder = ui.readyPlaceholder + ui.status = status + + // Initialize compact mode from config + ui.forceCompactMode = com.Config().Options.TUI.CompactMode + + return ui +} + +// Init initializes the UI model. +func (m *UI) Init() tea.Cmd { + var cmds []tea.Cmd + if m.QueryVersion { + cmds = append(cmds, tea.RequestTerminalVersion) + } + // load the user commands async + cmds = append(cmds, m.loadCustomCommands()) + return tea.Batch(cmds...) +} + +// loadCustomCommands loads the custom commands asynchronously. +func (m *UI) loadCustomCommands() tea.Cmd { + return func() tea.Msg { + customCommands, err := commands.LoadCustomCommands(m.com.Config()) + if err != nil { + slog.Error("failed to load custom commands", "error", err) + } + return userCommandsLoadedMsg{Commands: customCommands} + } +} + +// loadMCPrompts loads the MCP prompts asynchronously. +func (m *UI) loadMCPrompts() tea.Cmd { + return func() tea.Msg { + prompts, err := commands.LoadMCPPrompts() + if err != nil { + slog.Error("failed to load mcp prompts", "error", err) + } + if prompts == nil { + // flag them as loaded even if there is none or an error + prompts = []commands.MCPPrompt{} + } + return mcpPromptsLoadedMsg{Prompts: prompts} + } +} + +// Update handles updates to the UI model. +func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + if m.hasSession() && m.isAgentBusy() { + queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) + if queueSize != m.promptQueue { + m.promptQueue = queueSize + m.updateLayoutAndSize() + } + } + switch msg := msg.(type) { + case tea.EnvMsg: + // Is this Windows Terminal? + if !m.sendProgressBar { + m.sendProgressBar = slices.Contains(msg, "WT_SESSION") + } + m.imgCaps.Env = uv.Environ(msg) + // XXX: Right now, we're using the same logic to determine image + // support. Terminals like Apple Terminal and possibly others might + // bleed characters when querying for Kitty graphics via APC escape + // sequences. + cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env)) + case loadSessionMsg: + m.state = uiChat + if m.forceCompactMode { + m.isCompact = true + } + m.session = msg.session + m.sessionFiles = msg.files + msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID) + if err != nil { + cmds = append(cmds, uiutil.ReportError(err)) + break + } + if cmd := m.setSessionMessages(msgs); cmd != nil { + cmds = append(cmds, cmd) + } + if hasInProgressTodo(m.session.Todos) { + // only start spinner if there is an in-progress todo + if m.isAgentBusy() { + m.todoIsSpinning = true + cmds = append(cmds, m.todoSpinner.Tick) + } + m.updateLayoutAndSize() + } + + case sendMessageMsg: + cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...)) + + case userCommandsLoadedMsg: + m.customCommands = msg.Commands + dia := m.dialog.Dialog(dialog.CommandsID) + if dia == nil { + break + } + + commands, ok := dia.(*dialog.Commands) + if ok { + commands.SetCustomCommands(m.customCommands) + } + case mcpPromptsLoadedMsg: + m.mcpPrompts = msg.Prompts + dia := m.dialog.Dialog(dialog.CommandsID) + if dia == nil { + break + } + + commands, ok := dia.(*dialog.Commands) + if ok { + commands.SetMCPPrompts(m.mcpPrompts) + } + + case closeDialogMsg: + m.dialog.CloseFrontDialog() + + case pubsub.Event[session.Session]: + if m.session != nil && msg.Payload.ID == m.session.ID { + prevHasInProgress := hasInProgressTodo(m.session.Todos) + m.session = &msg.Payload + if !prevHasInProgress && hasInProgressTodo(m.session.Todos) { + m.todoIsSpinning = true + cmds = append(cmds, m.todoSpinner.Tick) + m.updateLayoutAndSize() + } + } + case pubsub.Event[message.Message]: + // Check if this is a child session message for an agent tool. + if m.session == nil { + break + } + if msg.Payload.SessionID != m.session.ID { + // This might be a child session message from an agent tool. + if cmd := m.handleChildSessionMessage(msg); cmd != nil { + cmds = append(cmds, cmd) + } + break + } + switch msg.Type { + case pubsub.CreatedEvent: + cmds = append(cmds, m.appendSessionMessage(msg.Payload)) + case pubsub.UpdatedEvent: + cmds = append(cmds, m.updateSessionMessage(msg.Payload)) + case pubsub.DeletedEvent: + m.chat.RemoveMessage(msg.Payload.ID) + } + // start the spinner if there is a new message + if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning { + m.todoIsSpinning = true + cmds = append(cmds, m.todoSpinner.Tick) + } + // stop the spinner if the agent is not busy anymore + if m.todoIsSpinning && !m.isAgentBusy() { + m.todoIsSpinning = false + } + // there is a number of things that could change the pills here so we want to re-render + m.renderPills() + case pubsub.Event[history.File]: + cmds = append(cmds, m.handleFileEvent(msg.Payload)) + case pubsub.Event[app.LSPEvent]: + m.lspStates = app.GetLSPStates() + case pubsub.Event[mcp.Event]: + m.mcpStates = mcp.GetStates() + // check if all mcps are initialized + initialized := true + for _, state := range m.mcpStates { + if state.State == mcp.StateStarting { + initialized = false + break + } + } + if initialized && m.mcpPrompts == nil { + cmds = append(cmds, m.loadMCPrompts()) + } + case pubsub.Event[permission.PermissionRequest]: + if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil { + cmds = append(cmds, cmd) + } + case pubsub.Event[permission.PermissionNotification]: + m.handlePermissionNotification(msg.Payload) + case cancelTimerExpiredMsg: + m.isCanceling = false + case tea.TerminalVersionMsg: + termVersion := strings.ToLower(msg.Name) + // Only enable progress bar for the following terminals. + if !m.sendProgressBar { + m.sendProgressBar = strings.Contains(termVersion, "ghostty") + } + return m, nil + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.handleCompactMode(m.width, m.height) + m.updateLayoutAndSize() + // XXX: We need to store cell dimensions for image rendering. + m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height + case tea.KeyboardEnhancementsMsg: + m.keyenh = msg + if msg.SupportsKeyDisambiguation() { + m.keyMap.Models.SetHelp("ctrl+m", "models") + m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline") + } + case copyChatHighlightMsg: + cmds = append(cmds, m.copyChatHighlight()) + case tea.MouseClickMsg: + switch m.state { + case uiChat: + x, y := msg.X, msg.Y + // Adjust for chat area position + x -= m.layout.main.Min.X + y -= m.layout.main.Min.Y + if m.chat.HandleMouseDown(x, y) { + m.lastClickTime = time.Now() + } + } + + case tea.MouseMotionMsg: + switch m.state { + case uiChat: + if msg.Y <= 0 { + if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectPrev() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } else if msg.Y >= m.chat.Height()-1 { + if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectNext() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + + x, y := msg.X, msg.Y + // Adjust for chat area position + x -= m.layout.main.Min.X + y -= m.layout.main.Min.Y + m.chat.HandleMouseDrag(x, y) + } + + case tea.MouseReleaseMsg: + const doubleClickThreshold = 500 * time.Millisecond + + switch m.state { + case uiChat: + x, y := msg.X, msg.Y + // Adjust for chat area position + x -= m.layout.main.Min.X + y -= m.layout.main.Min.Y + if m.chat.HandleMouseUp(x, y) { + cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg { + if time.Since(m.lastClickTime) >= doubleClickThreshold { + return copyChatHighlightMsg{} + } + return nil + })) + } + } + case tea.MouseWheelMsg: + // Pass mouse events to dialogs first if any are open. + if m.dialog.HasDialogs() { + m.dialog.Update(msg) + return m, tea.Batch(cmds...) + } + + // Otherwise handle mouse wheel for chat. + switch m.state { + case uiChat: + switch msg.Button { + case tea.MouseWheelUp: + if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectPrev() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + case tea.MouseWheelDown: + if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectNext() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + } + case anim.StepMsg: + if m.state == uiChat { + if cmd := m.chat.Animate(msg); cmd != nil { + cmds = append(cmds, cmd) + } + } + case spinner.TickMsg: + if m.dialog.HasDialogs() { + // route to dialog + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning { + var cmd tea.Cmd + m.todoSpinner, cmd = m.todoSpinner.Update(msg) + if cmd != nil { + m.renderPills() + cmds = append(cmds, cmd) + } + } + + case tea.KeyPressMsg: + if cmd := m.handleKeyPressMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + case tea.PasteMsg: + if cmd := m.handlePasteMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + case openEditorMsg: + m.textarea.SetValue(msg.Text) + m.textarea.MoveToEnd() + case uiutil.InfoMsg: + m.status.SetInfoMsg(msg) + ttl := msg.TTL + if ttl <= 0 { + ttl = DefaultStatusTTL + } + cmds = append(cmds, clearInfoMsgCmd(ttl)) + case uiutil.ClearStatusMsg: + m.status.ClearInfoMsg() + case completions.FilesLoadedMsg: + // Handle async file loading for completions. + if m.completionsOpen { + m.completions.SetFiles(msg.Files) + } + case uv.WindowPixelSizeEvent: + // [timage.RequestCapabilities] requests the terminal to send a window + // size event to help determine pixel dimensions. + m.imgCaps.PixelWidth = msg.Width + m.imgCaps.PixelHeight = msg.Height + case uv.KittyGraphicsEvent: + // [timage.RequestCapabilities] sends a Kitty graphics query and this + // captures the response. Any response means the terminal understands + // the protocol. + m.imgCaps.SupportsKittyGraphics = true + if !bytes.HasPrefix(msg.Payload, []byte("OK")) { + slog.Warn("unexpected Kitty graphics response", + "response", string(msg.Payload), + "options", msg.Options) + } + default: + if m.dialog.HasDialogs() { + if cmd := m.handleDialogMsg(msg); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + + // This logic gets triggered on any message type, but should it? + switch m.focus { + case uiFocusMain: + case uiFocusEditor: + // Textarea placeholder logic + if m.isAgentBusy() { + m.textarea.Placeholder = m.workingPlaceholder + } else { + m.textarea.Placeholder = m.readyPlaceholder + } + if m.com.App.Permissions.SkipRequests() { + m.textarea.Placeholder = "Yolo mode!" + } + } + + // at this point this can only handle [message.Attachment] message, and we + // should return all cmds anyway. + _ = m.attachments.Update(msg) + return m, tea.Batch(cmds...) +} + +// setSessionMessages sets the messages for the current session in the chat +func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { + var cmds []tea.Cmd + // Build tool result map to link tool calls with their results + msgPtrs := make([]*message.Message, len(msgs)) + for i := range msgs { + msgPtrs[i] = &msgs[i] + } + toolResultMap := chat.BuildToolResultMap(msgPtrs) + if len(msgPtrs) > 0 { + m.lastUserMessageTime = msgPtrs[0].CreatedAt + } + + // Add messages to chat with linked tool results + items := make([]chat.MessageItem, 0, len(msgs)*2) + for _, msg := range msgPtrs { + switch msg.Role { + case message.User: + m.lastUserMessageTime = msg.CreatedAt + items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) + case message.Assistant: + items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) + if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { + infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0)) + items = append(items, infoItem) + } + default: + items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...) + } + } + + // Load nested tool calls for agent/agentic_fetch tools. + m.loadNestedToolCalls(items) + + // If the user switches between sessions while the agent is working we want + // to make sure the animations are shown. + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + + m.chat.SetMessages(items...) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectLast() + return tea.Batch(cmds...) +} + +// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools. +func (m *UI) loadNestedToolCalls(items []chat.MessageItem) { + for _, item := range items { + nestedContainer, ok := item.(chat.NestedToolContainer) + if !ok { + continue + } + toolItem, ok := item.(chat.ToolMessageItem) + if !ok { + continue + } + + tc := toolItem.ToolCall() + messageID := toolItem.MessageID() + + // Get the agent tool session ID. + agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID) + + // Fetch nested messages. + nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID) + if err != nil || len(nestedMsgs) == 0 { + continue + } + + // Build tool result map for nested messages. + nestedMsgPtrs := make([]*message.Message, len(nestedMsgs)) + for i := range nestedMsgs { + nestedMsgPtrs[i] = &nestedMsgs[i] + } + nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs) + + // Extract nested tool items. + var nestedTools []chat.ToolMessageItem + for _, nestedMsg := range nestedMsgPtrs { + nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap) + for _, nestedItem := range nestedItems { + if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok { + // Mark nested tools as simple (compact) rendering. + if simplifiable, ok := nestedToolItem.(chat.Compactable); ok { + simplifiable.SetCompact(true) + } + nestedTools = append(nestedTools, nestedToolItem) + } + } + } + + // Recursively load nested tool calls for any agent tools within. + nestedMessageItems := make([]chat.MessageItem, len(nestedTools)) + for i, nt := range nestedTools { + nestedMessageItems[i] = nt + } + m.loadNestedToolCalls(nestedMessageItems) + + // Set nested tools on the parent. + nestedContainer.SetNestedTools(nestedTools) + } +} + +// appendSessionMessage appends a new message to the current session in the chat +// if the message is a tool result it will update the corresponding tool call message +func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { + var cmds []tea.Cmd + existing := m.chat.MessageItem(msg.ID) + if existing != nil { + // message already exists, skip + return nil + } + switch msg.Role { + case message.User: + m.lastUserMessageTime = msg.CreatedAt + items := chat.ExtractMessageItems(m.com.Styles, &msg, nil) + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m.chat.AppendMessages(items...) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + case message.Assistant: + items := chat.ExtractMessageItems(m.com.Styles, &msg, nil) + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m.chat.AppendMessages(items...) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { + infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0)) + m.chat.AppendMessages(infoItem) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + case message.Tool: + for _, tr := range msg.ToolResults() { + toolItem := m.chat.MessageItem(tr.ToolCallID) + if toolItem == nil { + // we should have an item! + continue + } + if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok { + toolMsgItem.SetResult(&tr) + } + } + } + return tea.Batch(cmds...) +} + +// updateSessionMessage updates an existing message in the current session in the chat +// when an assistant message is updated it may include updated tool calls as well +// that is why we need to handle creating/updating each tool call message too +func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { + var cmds []tea.Cmd + existingItem := m.chat.MessageItem(msg.ID) + + if existingItem != nil { + if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok { + assistantItem.SetMessage(&msg) + } + } + + shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg) + // if the message of the assistant does not have any response just tool calls we need to remove it + if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil { + m.chat.RemoveMessage(msg.ID) + if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil { + m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID)) + } + } + + if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { + if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil { + newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0)) + m.chat.AppendMessages(newInfoItem) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + + var items []chat.MessageItem + for _, tc := range msg.ToolCalls() { + existingToolItem := m.chat.MessageItem(tc.ID) + if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok { + existingToolCall := toolItem.ToolCall() + // only update if finished state changed or input changed + // to avoid clearing the cache + if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input { + toolItem.SetToolCall(tc) + } + } + if existingToolItem == nil { + items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false)) + } + } + + for _, item := range items { + if animatable, ok := item.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + m.chat.AppendMessages(items...) + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + + return tea.Batch(cmds...) +} + +// handleChildSessionMessage handles messages from child sessions (agent tools). +func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd { + var cmds []tea.Cmd + + // Only process messages with tool calls or results. + if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 { + return nil + } + + // Check if this is an agent tool session and parse it. + childSessionID := event.Payload.SessionID + _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID) + if !ok { + return nil + } + + // Find the parent agent tool item. + var agentItem chat.NestedToolContainer + for i := 0; i < m.chat.Len(); i++ { + item := m.chat.MessageItem(toolCallID) + if item == nil { + continue + } + if agent, ok := item.(chat.NestedToolContainer); ok { + if toolMessageItem, ok := item.(chat.ToolMessageItem); ok { + if toolMessageItem.ToolCall().ID == toolCallID { + // Verify this agent belongs to the correct parent message. + // We can't directly check parentMessageID on the item, so we trust the session parsing. + agentItem = agent + break + } + } + } + } + + if agentItem == nil { + return nil + } + + // Get existing nested tools. + nestedTools := agentItem.NestedTools() + + // Update or create nested tool calls. + for _, tc := range event.Payload.ToolCalls() { + found := false + for _, existingTool := range nestedTools { + if existingTool.ToolCall().ID == tc.ID { + existingTool.SetToolCall(tc) + found = true + break + } + } + if !found { + // Create a new nested tool item. + nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false) + if simplifiable, ok := nestedItem.(chat.Compactable); ok { + simplifiable.SetCompact(true) + } + if animatable, ok := nestedItem.(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + nestedTools = append(nestedTools, nestedItem) + } + } + + // Update nested tool results. + for _, tr := range event.Payload.ToolResults() { + for _, nestedTool := range nestedTools { + if nestedTool.ToolCall().ID == tr.ToolCallID { + nestedTool.SetResult(&tr) + break + } + } + } + + // Update the agent item with the new nested tools. + agentItem.SetNestedTools(nestedTools) + + // Update the chat so it updates the index map for animations to work as expected + m.chat.UpdateNestedToolIDs(toolCallID) + + return tea.Batch(cmds...) +} + +func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + action := m.dialog.Update(msg) + if action == nil { + return tea.Batch(cmds...) + } + + switch msg := action.(type) { + // Generic dialog messages + case dialog.ActionClose: + m.dialog.CloseFrontDialog() + if m.focus == uiFocusEditor { + cmds = append(cmds, m.textarea.Focus()) + } + case dialog.ActionCmd: + if msg.Cmd != nil { + cmds = append(cmds, msg.Cmd) + } + + // Session dialog messages + case dialog.ActionSelectSession: + m.dialog.CloseDialog(dialog.SessionsID) + cmds = append(cmds, m.loadSession(msg.Session.ID)) + + // Open dialog message + case dialog.ActionOpenDialog: + m.dialog.CloseDialog(dialog.CommandsID) + if cmd := m.openDialog(msg.DialogID); cmd != nil { + cmds = append(cmds, cmd) + } + + // Command dialog messages + case dialog.ActionToggleYoloMode: + yolo := !m.com.App.Permissions.SkipRequests() + m.com.App.Permissions.SetSkipRequests(yolo) + m.setEditorPrompt(yolo) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionNewSession: + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) + break + } + m.newSession() + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionSummarize: + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) + break + } + cmds = append(cmds, func() tea.Msg { + err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) + if err != nil { + return uiutil.ReportError(err)() + } + return nil + }) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionToggleHelp: + m.status.ToggleHelp() + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionExternalEditor: + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait...")) + break + } + cmds = append(cmds, m.openEditor(m.textarea.Value())) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionToggleCompactMode: + cmds = append(cmds, m.toggleCompactMode()) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionToggleThinking: + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + break + } + + cmds = append(cmds, func() tea.Msg { + cfg := m.com.Config() + if cfg == nil { + return uiutil.ReportError(errors.New("configuration not found"))() + } + + agentCfg, ok := cfg.Agents[config.AgentCoder] + if !ok { + return uiutil.ReportError(errors.New("agent configuration not found"))() + } + + currentModel := cfg.Models[agentCfg.Model] + currentModel.Think = !currentModel.Think + if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { + return uiutil.ReportError(err)() + } + m.com.App.UpdateAgentModel(context.TODO()) + status := "disabled" + if currentModel.Think { + status = "enabled" + } + return uiutil.NewInfoMsg("Thinking mode " + status) + }) + m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionQuit: + cmds = append(cmds, tea.Quit) + case dialog.ActionInitializeProject: + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) + break + } + cmds = append(cmds, m.initializeProject()) + + case dialog.ActionSelectModel: + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + break + } + + cfg := m.com.Config() + if cfg == nil { + cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found"))) + break + } + + var ( + providerID = msg.Model.Provider + isCopilot = providerID == string(catwalk.InferenceProviderCopilot) + isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok } + ) + + // Attempt to import GitHub Copilot tokens from VSCode if available. + if isCopilot && !isConfigured() { + config.Get().ImportCopilot() + } + + if !isConfigured() { + m.dialog.CloseDialog(dialog.ModelsID) + if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { + cmds = append(cmds, cmd) + } + break + } + + if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil { + cmds = append(cmds, uiutil.ReportError(err)) + } + + cmds = append(cmds, func() tea.Msg { + m.com.App.UpdateAgentModel(context.TODO()) + + modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model) + + return uiutil.NewInfoMsg(modelMsg) + }) + + m.dialog.CloseDialog(dialog.APIKeyInputID) + m.dialog.CloseDialog(dialog.OAuthID) + m.dialog.CloseDialog(dialog.ModelsID) + case dialog.ActionSelectReasoningEffort: + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) + break + } + + cfg := m.com.Config() + if cfg == nil { + cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found"))) + break + } + + agentCfg, ok := cfg.Agents[config.AgentCoder] + if !ok { + cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found"))) + break + } + + currentModel := cfg.Models[agentCfg.Model] + currentModel.ReasoningEffort = msg.Effort + if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil { + cmds = append(cmds, uiutil.ReportError(err)) + break + } + + cmds = append(cmds, func() tea.Msg { + m.com.App.UpdateAgentModel(context.TODO()) + return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort) + }) + m.dialog.CloseDialog(dialog.ReasoningID) + case dialog.ActionPermissionResponse: + m.dialog.CloseDialog(dialog.PermissionsID) + switch msg.Action { + case dialog.PermissionAllow: + m.com.App.Permissions.Grant(msg.Permission) + case dialog.PermissionAllowForSession: + m.com.App.Permissions.GrantPersistent(msg.Permission) + case dialog.PermissionDeny: + m.com.App.Permissions.Deny(msg.Permission) + } + + case dialog.ActionFilePickerSelected: + cmds = append(cmds, tea.Sequence( + msg.Cmd(), + func() tea.Msg { + m.dialog.CloseDialog(dialog.FilePickerID) + return nil + }, + )) + + case dialog.ActionRunCustomCommand: + if len(msg.Arguments) > 0 && msg.Args == nil { + m.dialog.CloseFrontDialog() + argsDialog := dialog.NewArguments( + m.com, + "Custom Command Arguments", + "", + msg.Arguments, + msg, // Pass the action as the result + ) + m.dialog.OpenDialog(argsDialog) + break + } + content := msg.Content + if msg.Args != nil { + content = substituteArgs(content, msg.Args) + } + cmds = append(cmds, m.sendMessage(content)) + m.dialog.CloseFrontDialog() + case dialog.ActionRunMCPPrompt: + if len(msg.Arguments) > 0 && msg.Args == nil { + m.dialog.CloseFrontDialog() + title := msg.Title + if title == "" { + title = "MCP Prompt Arguments" + } + argsDialog := dialog.NewArguments( + m.com, + title, + msg.Description, + msg.Arguments, + msg, // Pass the action as the result + ) + m.dialog.OpenDialog(argsDialog) + break + } + cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args)) + default: + cmds = append(cmds, uiutil.CmdHandler(msg)) + } + + return tea.Batch(cmds...) +} + +// substituteArgs replaces $ARG_NAME placeholders in content with actual values. +func substituteArgs(content string, args map[string]string) string { + for name, value := range args { + placeholder := "$" + name + content = strings.ReplaceAll(content, placeholder, value) + } + return content +} + +func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + var ( + dlg dialog.Dialog + cmd tea.Cmd + ) + + switch provider.ID { + case "hyper": + dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType) + case catwalk.InferenceProviderCopilot: + dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType) + default: + dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType) + } + + if m.dialog.ContainsDialog(dlg.ID()) { + m.dialog.BringToFront(dlg.ID()) + return nil + } + + m.dialog.OpenDialog(dlg) + return cmd +} + +func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { + var cmds []tea.Cmd + + handleGlobalKeys := func(msg tea.KeyPressMsg) bool { + switch { + case key.Matches(msg, m.keyMap.Help): + m.status.ToggleHelp() + m.updateLayoutAndSize() + return true + case key.Matches(msg, m.keyMap.Commands): + if cmd := m.openCommandsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + return true + case key.Matches(msg, m.keyMap.Models): + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + return true + case key.Matches(msg, m.keyMap.Sessions): + if cmd := m.openSessionsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + return true + case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact: + m.detailsOpen = !m.detailsOpen + m.updateLayoutAndSize() + return true + case key.Matches(msg, m.keyMap.Chat.TogglePills): + if m.state == uiChat && m.hasSession() { + if cmd := m.togglePillsExpanded(); cmd != nil { + cmds = append(cmds, cmd) + } + return true + } + case key.Matches(msg, m.keyMap.Chat.PillLeft): + if m.state == uiChat && m.hasSession() && m.pillsExpanded { + if cmd := m.switchPillSection(-1); cmd != nil { + cmds = append(cmds, cmd) + } + return true + } + case key.Matches(msg, m.keyMap.Chat.PillRight): + if m.state == uiChat && m.hasSession() && m.pillsExpanded { + if cmd := m.switchPillSection(1); cmd != nil { + cmds = append(cmds, cmd) + } + return true + } + } + return false + } + + if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) { + // Always handle quit keys first + if cmd := m.openQuitDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + + return tea.Batch(cmds...) + } + + // Route all messages to dialog if one is open. + if m.dialog.HasDialogs() { + return m.handleDialogMsg(msg) + } + + // Handle cancel key when agent is busy. + if key.Matches(msg, m.keyMap.Chat.Cancel) { + if m.isAgentBusy() { + if cmd := m.cancelAgent(); cmd != nil { + cmds = append(cmds, cmd) + } + return tea.Batch(cmds...) + } + } + + switch m.state { + case uiConfigure: + return tea.Batch(cmds...) + case uiInitialize: + cmds = append(cmds, m.updateInitializeView(msg)...) + return tea.Batch(cmds...) + case uiChat, uiLanding: + switch m.focus { + case uiFocusEditor: + // Handle completions if open. + if m.completionsOpen { + if msg, ok := m.completions.Update(msg); ok { + switch msg := msg.(type) { + case completions.SelectionMsg: + // Handle file completion selection. + if item, ok := msg.Value.(completions.FileCompletionValue); ok { + cmds = append(cmds, m.insertFileCompletion(item.Path)) + } + if !msg.Insert { + m.closeCompletions() + } + case completions.ClosedMsg: + m.completionsOpen = false + } + return tea.Batch(cmds...) + } + } + + if ok := m.attachments.Update(msg); ok { + return tea.Batch(cmds...) + } + + switch { + case key.Matches(msg, m.keyMap.Editor.AddImage): + if cmd := m.openFilesDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + + case key.Matches(msg, m.keyMap.Editor.SendMessage): + value := m.textarea.Value() + if before, ok := strings.CutSuffix(value, "\\"); ok { + // If the last character is a backslash, remove it and add a newline. + m.textarea.SetValue(before) + break + } + + // Otherwise, send the message + m.textarea.Reset() + + value = strings.TrimSpace(value) + if value == "exit" || value == "quit" { + return m.openQuitDialog() + } + + attachments := m.attachments.List() + m.attachments.Reset() + if len(value) == 0 && !message.ContainsTextAttachment(attachments) { + return nil + } + + m.randomizePlaceholders() + + return m.sendMessage(value, attachments...) + case key.Matches(msg, m.keyMap.Chat.NewSession): + if !m.hasSession() { + break + } + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) + break + } + m.newSession() + case key.Matches(msg, m.keyMap.Tab): + m.focus = uiFocusMain + m.textarea.Blur() + m.chat.Focus() + m.chat.SetSelected(m.chat.Len() - 1) + case key.Matches(msg, m.keyMap.Editor.OpenEditor): + if m.isAgentBusy() { + cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait...")) + break + } + cmds = append(cmds, m.openEditor(m.textarea.Value())) + case key.Matches(msg, m.keyMap.Editor.Newline): + m.textarea.InsertRune('\n') + m.closeCompletions() + default: + if handleGlobalKeys(msg) { + // Handle global keys first before passing to textarea. + break + } + + // Check for @ trigger before passing to textarea. + curValue := m.textarea.Value() + curIdx := len(curValue) + + // Trigger completions on @. + if msg.String() == "@" && !m.completionsOpen { + // Only show if beginning of prompt or after whitespace. + if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) { + m.completionsOpen = true + m.completionsQuery = "" + m.completionsStartIndex = curIdx + m.completionsPositionStart = m.completionsPosition() + depth, limit := m.com.Config().Options.TUI.Completions.Limits() + cmds = append(cmds, m.completions.OpenWithFiles(depth, limit)) + } + } + + // remove the details if they are open when user starts typing + if m.detailsOpen { + m.detailsOpen = false + m.updateLayoutAndSize() + } + + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + cmds = append(cmds, cmd) + + // After updating textarea, check if we need to filter completions. + // Skip filtering on the initial @ keystroke since items are loading async. + if m.completionsOpen && msg.String() != "@" { + newValue := m.textarea.Value() + newIdx := len(newValue) + + // Close completions if cursor moved before start. + if newIdx <= m.completionsStartIndex { + m.closeCompletions() + } else if msg.String() == "space" { + // Close on space. + m.closeCompletions() + } else { + // Extract current word and filter. + word := m.textareaWord() + if strings.HasPrefix(word, "@") { + m.completionsQuery = word[1:] + m.completions.Filter(m.completionsQuery) + } else if m.completionsOpen { + m.closeCompletions() + } + } + } + } + case uiFocusMain: + switch { + case key.Matches(msg, m.keyMap.Tab): + m.focus = uiFocusEditor + cmds = append(cmds, m.textarea.Focus()) + m.chat.Blur() + case key.Matches(msg, m.keyMap.Chat.Expand): + m.chat.ToggleExpandedSelectedItem() + case key.Matches(msg, m.keyMap.Chat.Up): + if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectPrev() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + case key.Matches(msg, m.keyMap.Chat.Down): + if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil { + cmds = append(cmds, cmd) + } + if !m.chat.SelectedItemInView() { + m.chat.SelectNext() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } + case key.Matches(msg, m.keyMap.Chat.UpOneItem): + m.chat.SelectPrev() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Chat.DownOneItem): + m.chat.SelectNext() + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Chat.HalfPageUp): + if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectFirstInView() + case key.Matches(msg, m.keyMap.Chat.HalfPageDown): + if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectLastInView() + case key.Matches(msg, m.keyMap.Chat.PageUp): + if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectFirstInView() + case key.Matches(msg, m.keyMap.Chat.PageDown): + if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectLastInView() + case key.Matches(msg, m.keyMap.Chat.Home): + if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectFirst() + case key.Matches(msg, m.keyMap.Chat.End): + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + m.chat.SelectLast() + default: + handleGlobalKeys(msg) + } + default: + handleGlobalKeys(msg) + } + default: + handleGlobalKeys(msg) + } + + return tea.Batch(cmds...) +} + +// Draw implements [uv.Drawable] and draws the UI model. +func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + layout := m.generateLayout(area.Dx(), area.Dy()) + + if m.layout != layout { + m.layout = layout + m.updateSize() + } + + // Clear the screen first + screen.Clear(scr) + + switch m.state { + case uiConfigure: + header := uv.NewStyledString(m.header) + header.Draw(scr, layout.header) + + mainView := lipgloss.NewStyle().Width(layout.main.Dx()). + Height(layout.main.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Configure ") + main := uv.NewStyledString(mainView) + main.Draw(scr, layout.main) + + case uiInitialize: + header := uv.NewStyledString(m.header) + header.Draw(scr, layout.header) + + main := uv.NewStyledString(m.initializeView()) + main.Draw(scr, layout.main) + + case uiLanding: + header := uv.NewStyledString(m.header) + header.Draw(scr, layout.header) + main := uv.NewStyledString(m.landingView()) + main.Draw(scr, layout.main) + + editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx())) + editor.Draw(scr, layout.editor) + + case uiChat: + if m.isCompact { + header := uv.NewStyledString(m.header) + header.Draw(scr, layout.header) + } else { + m.drawSidebar(scr, layout.sidebar) + } + + m.chat.Draw(scr, layout.main) + if layout.pills.Dy() > 0 && m.pillsView != "" { + uv.NewStyledString(m.pillsView).Draw(scr, layout.pills) + } + + editorWidth := scr.Bounds().Dx() + if !m.isCompact { + editorWidth -= layout.sidebar.Dx() + } + editor := uv.NewStyledString(m.renderEditorView(editorWidth)) + editor.Draw(scr, layout.editor) + + // Draw details overlay in compact mode when open + if m.isCompact && m.detailsOpen { + m.drawSessionDetails(scr, layout.sessionDetails) + } + } + + // Add status and help layer + m.status.Draw(scr, layout.status) + + // Draw completions popup if open + if m.completionsOpen && m.completions.HasItems() { + w, h := m.completions.Size() + x := m.completionsPositionStart.X + y := m.completionsPositionStart.Y - h + + screenW := area.Dx() + if x+w > screenW { + x = screenW - w + } + x = max(0, x) + y = max(0, y) + + completionsView := uv.NewStyledString(m.completions.Render()) + completionsView.Draw(scr, image.Rectangle{ + Min: image.Pt(x, y), + Max: image.Pt(x+w, y+h), + }) + } + + // Debugging rendering (visually see when the tui rerenders) + if os.Getenv("CRUSH_UI_DEBUG") == "true" { + debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2) + debug := uv.NewStyledString(debugView.String()) + debug.Draw(scr, image.Rectangle{ + Min: image.Pt(4, 1), + Max: image.Pt(8, 3), + }) + } + + // This needs to come last to overlay on top of everything. We always pass + // the full screen bounds because the dialogs will position themselves + // accordingly. + if m.dialog.HasDialogs() { + return m.dialog.Draw(scr, scr.Bounds()) + } + + switch m.focus { + case uiFocusEditor: + if m.layout.editor.Dy() <= 0 { + // Don't show cursor if editor is not visible + return nil + } + if m.detailsOpen && m.isCompact { + // Don't show cursor if details overlay is open + return nil + } + + if m.textarea.Focused() { + cur := m.textarea.Cursor() + cur.X++ // Adjust for app margins + cur.Y += m.layout.editor.Min.Y + // Offset for attachment row if present. + if len(m.attachments.List()) > 0 { + cur.Y++ + } + return cur + } + } + return nil +} + +// View renders the UI model's view. +func (m *UI) View() tea.View { + var v tea.View + v.AltScreen = true + v.BackgroundColor = m.com.Styles.Background + v.MouseMode = tea.MouseModeCellMotion + v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir()) + + canvas := uv.NewScreenBuffer(m.width, m.height) + v.Cursor = m.Draw(canvas, canvas.Bounds()) + + content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines + contentLines := strings.Split(content, "\n") + for i, line := range contentLines { + // Trim trailing spaces for concise rendering + contentLines[i] = strings.TrimRight(line, " ") + } + + content = strings.Join(contentLines, "\n") + + v.Content = content + if m.sendProgressBar && m.isAgentBusy() { + // HACK: use a random percentage to prevent ghostty from hiding it + // after a timeout. + v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100)) + } + + return v +} + +// ShortHelp implements [help.KeyMap]. +func (m *UI) ShortHelp() []key.Binding { + var binds []key.Binding + k := &m.keyMap + tab := k.Tab + commands := k.Commands + if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 { + commands.SetHelp("/ or ctrl+p", "commands") + } + + switch m.state { + case uiInitialize: + binds = append(binds, k.Quit) + case uiChat: + // Show cancel binding if agent is busy. + if m.isAgentBusy() { + cancelBinding := k.Chat.Cancel + if m.isCanceling { + cancelBinding.SetHelp("esc", "press again to cancel") + } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 { + cancelBinding.SetHelp("esc", "clear queue") + } + binds = append(binds, cancelBinding) + } + + if m.focus == uiFocusEditor { + tab.SetHelp("tab", "focus chat") + } else { + tab.SetHelp("tab", "focus editor") + } + + binds = append(binds, + tab, + commands, + k.Models, + ) + + switch m.focus { + case uiFocusEditor: + binds = append(binds, + k.Editor.Newline, + ) + case uiFocusMain: + binds = append(binds, + k.Chat.UpDown, + k.Chat.UpDownOneItem, + k.Chat.PageUp, + k.Chat.PageDown, + k.Chat.Copy, + ) + if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 { + binds = append(binds, k.Chat.PillLeft) + } + } + default: + // TODO: other states + // if m.session == nil { + // no session selected + binds = append(binds, + commands, + k.Models, + k.Editor.Newline, + ) + } + + binds = append(binds, + k.Quit, + k.Help, + ) + + return binds +} + +// FullHelp implements [help.KeyMap]. +func (m *UI) FullHelp() [][]key.Binding { + var binds [][]key.Binding + k := &m.keyMap + help := k.Help + help.SetHelp("ctrl+g", "less") + hasAttachments := len(m.attachments.List()) > 0 + hasSession := m.hasSession() + commands := k.Commands + if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 { + commands.SetHelp("/ or ctrl+p", "commands") + } + + switch m.state { + case uiInitialize: + binds = append(binds, + []key.Binding{ + k.Quit, + }) + case uiChat: + // Show cancel binding if agent is busy. + if m.isAgentBusy() { + cancelBinding := k.Chat.Cancel + if m.isCanceling { + cancelBinding.SetHelp("esc", "press again to cancel") + } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 { + cancelBinding.SetHelp("esc", "clear queue") + } + binds = append(binds, []key.Binding{cancelBinding}) + } + + mainBinds := []key.Binding{} + tab := k.Tab + if m.focus == uiFocusEditor { + tab.SetHelp("tab", "focus chat") + } else { + tab.SetHelp("tab", "focus editor") + } + + mainBinds = append(mainBinds, + tab, + commands, + k.Models, + k.Sessions, + ) + if hasSession { + mainBinds = append(mainBinds, k.Chat.NewSession) + } + + binds = append(binds, mainBinds) + + switch m.focus { + case uiFocusEditor: + binds = append(binds, + []key.Binding{ + k.Editor.Newline, + k.Editor.AddImage, + k.Editor.MentionFile, + k.Editor.OpenEditor, + }, + ) + if hasAttachments { + binds = append(binds, + []key.Binding{ + k.Editor.AttachmentDeleteMode, + k.Editor.DeleteAllAttachments, + k.Editor.Escape, + }, + ) + } + case uiFocusMain: + binds = append(binds, + []key.Binding{ + k.Chat.UpDown, + k.Chat.UpDownOneItem, + k.Chat.PageUp, + k.Chat.PageDown, + }, + []key.Binding{ + k.Chat.HalfPageUp, + k.Chat.HalfPageDown, + k.Chat.Home, + k.Chat.End, + }, + []key.Binding{ + k.Chat.Copy, + k.Chat.ClearHighlight, + }, + ) + if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 { + binds = append(binds, []key.Binding{k.Chat.PillLeft}) + } + } + default: + if m.session == nil { + // no session selected + binds = append(binds, + []key.Binding{ + commands, + k.Models, + k.Sessions, + }, + []key.Binding{ + k.Editor.Newline, + k.Editor.AddImage, + k.Editor.MentionFile, + k.Editor.OpenEditor, + }, + ) + if hasAttachments { + binds = append(binds, + []key.Binding{ + k.Editor.AttachmentDeleteMode, + k.Editor.DeleteAllAttachments, + k.Editor.Escape, + }, + ) + } + binds = append(binds, + []key.Binding{ + help, + }, + ) + } + } + + binds = append(binds, + []key.Binding{ + help, + k.Quit, + }, + ) + + return binds +} + +// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states. +func (m *UI) toggleCompactMode() tea.Cmd { + m.forceCompactMode = !m.forceCompactMode + + err := m.com.Config().SetCompactMode(m.forceCompactMode) + if err != nil { + return uiutil.ReportError(err) + } + + m.handleCompactMode(m.width, m.height) + m.updateLayoutAndSize() + + return nil +} + +// handleCompactMode updates the UI state based on window size and compact mode setting. +func (m *UI) handleCompactMode(newWidth, newHeight int) { + if m.state == uiChat { + if m.forceCompactMode { + m.isCompact = true + return + } + if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint { + m.isCompact = true + } else { + m.isCompact = false + } + } +} + +// updateLayoutAndSize updates the layout and sizes of UI components. +func (m *UI) updateLayoutAndSize() { + m.layout = m.generateLayout(m.width, m.height) + m.updateSize() +} + +// updateSize updates the sizes of UI components based on the current layout. +func (m *UI) updateSize() { + // Set status width + m.status.SetWidth(m.layout.status.Dx()) + + m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + m.renderPills() + + // Handle different app states + switch m.state { + case uiConfigure, uiInitialize, uiLanding: + m.renderHeader(false, m.layout.header.Dx()) + + case uiChat: + if m.isCompact { + m.renderHeader(true, m.layout.header.Dx()) + } else { + m.renderSidebarLogo(m.layout.sidebar.Dx()) + } + } +} + +// generateLayout calculates the layout rectangles for all UI components based +// on the current UI state and terminal dimensions. +func (m *UI) generateLayout(w, h int) layout { + // The screen area we're working with + area := image.Rect(0, 0, w, h) + + // The help height + helpHeight := 1 + // The editor height + editorHeight := 5 + // The sidebar width + sidebarWidth := 30 + // The header height + const landingHeaderHeight = 4 + + var helpKeyMap help.KeyMap = m + if m.status.ShowingAll() { + for _, row := range helpKeyMap.FullHelp() { + helpHeight = max(helpHeight, len(row)) + } + } + + // Add app margins + appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight)) + appRect.Min.Y += 1 + appRect.Max.Y -= 1 + helpRect.Min.Y -= 1 + appRect.Min.X += 1 + appRect.Max.X -= 1 + + if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) { + // extra padding on left and right for these states + appRect.Min.X += 1 + appRect.Max.X -= 1 + } + + layout := layout{ + area: area, + status: helpRect, + } + + // Handle different app states + switch m.state { + case uiConfigure, uiInitialize: + // Layout + // + // header + // ------ + // main + // ------ + // help + + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight)) + layout.header = headerRect + layout.main = mainRect + + case uiLanding: + // Layout + // + // header + // ------ + // main + // ------ + // editor + // ------ + // help + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight)) + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + // Remove extra padding from editor (but keep it for header and main) + editorRect.Min.X -= 1 + editorRect.Max.X += 1 + layout.header = headerRect + layout.main = mainRect + layout.editor = editorRect + + case uiChat: + if m.isCompact { + // Layout + // + // compact-header + // ------ + // main + // ------ + // editor + // ------ + // help + const compactHeaderHeight = 1 + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight)) + detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header + sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight)) + layout.sessionDetails = sessionDetailsArea + layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header + // Add one line gap between header and main content + mainRect.Min.Y += 1 + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + mainRect.Max.X -= 1 // Add padding right + layout.header = headerRect + pillsHeight := m.pillsAreaHeight() + if pillsHeight > 0 { + pillsHeight = min(pillsHeight, mainRect.Dy()) + chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight)) + layout.main = chatRect + layout.pills = pillsRect + } else { + layout.main = mainRect + } + // Add bottom margin to main + layout.main.Max.Y -= 1 + layout.editor = editorRect + } else { + // Layout + // + // ------|--- + // main | + // ------| side + // editor| + // ---------- + // help + + mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) + // Add padding left + sideRect.Min.X += 1 + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + mainRect.Max.X -= 1 // Add padding right + layout.sidebar = sideRect + pillsHeight := m.pillsAreaHeight() + if pillsHeight > 0 { + pillsHeight = min(pillsHeight, mainRect.Dy()) + chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight)) + layout.main = chatRect + layout.pills = pillsRect + } else { + layout.main = mainRect + } + // Add bottom margin to main + layout.main.Max.Y -= 1 + layout.editor = editorRect + } + } + + if !layout.editor.Empty() { + // Add editor margins 1 top and bottom + layout.editor.Min.Y += 1 + layout.editor.Max.Y -= 1 + } + + return layout +} + +// layout defines the positioning of UI elements. +type layout struct { + // area is the overall available area. + area uv.Rectangle + + // header is the header shown in special cases + // e.x when the sidebar is collapsed + // or when in the landing page + // or in init/config + header uv.Rectangle + + // main is the area for the main pane. (e.x chat, configure, landing) + main uv.Rectangle + + // pills is the area for the pills panel. + pills uv.Rectangle + + // editor is the area for the editor pane. + editor uv.Rectangle + + // sidebar is the area for the sidebar. + sidebar uv.Rectangle + + // status is the area for the status view. + status uv.Rectangle + + // session details is the area for the session details overlay in compact mode. + sessionDetails uv.Rectangle +} + +func (m *UI) openEditor(value string) tea.Cmd { + tmpfile, err := os.CreateTemp("", "msg_*.md") + if err != nil { + return uiutil.ReportError(err) + } + defer tmpfile.Close() //nolint:errcheck + if _, err := tmpfile.WriteString(value); err != nil { + return uiutil.ReportError(err) + } + cmd, err := editor.Command( + "crush", + tmpfile.Name(), + editor.AtPosition( + m.textarea.Line()+1, + m.textarea.Column()+1, + ), + ) + if err != nil { + return uiutil.ReportError(err) + } + return tea.ExecProcess(cmd, func(err error) tea.Msg { + if err != nil { + return uiutil.ReportError(err) + } + content, err := os.ReadFile(tmpfile.Name()) + if err != nil { + return uiutil.ReportError(err) + } + if len(content) == 0 { + return uiutil.ReportWarn("Message is empty") + } + os.Remove(tmpfile.Name()) + return openEditorMsg{ + Text: strings.TrimSpace(string(content)), + } + }) +} + +// setEditorPrompt configures the textarea prompt function based on whether +// yolo mode is enabled. +func (m *UI) setEditorPrompt(yolo bool) { + if yolo { + m.textarea.SetPromptFunc(4, m.yoloPromptFunc) + return + } + m.textarea.SetPromptFunc(4, m.normalPromptFunc) +} + +// normalPromptFunc returns the normal editor prompt style (" > " on first +// line, "::: " on subsequent lines). +func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + if info.Focused { + return " > " + } + return "::: " + } + if info.Focused { + return t.EditorPromptNormalFocused.Render() + } + return t.EditorPromptNormalBlurred.Render() +} + +// yoloPromptFunc returns the yolo mode editor prompt style with warning icon +// and colored dots. +func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { + t := m.com.Styles + if info.LineNumber == 0 { + if info.Focused { + return t.EditorPromptYoloIconFocused.Render() + } else { + return t.EditorPromptYoloIconBlurred.Render() + } + } + if info.Focused { + return t.EditorPromptYoloDotsFocused.Render() + } + return t.EditorPromptYoloDotsBlurred.Render() +} + +// closeCompletions closes the completions popup and resets state. +func (m *UI) closeCompletions() { + m.completionsOpen = false + m.completionsQuery = "" + m.completionsStartIndex = 0 + m.completions.Close() +} + +// insertFileCompletion inserts the selected file path into the textarea, +// replacing the @query, and adds the file as an attachment. +func (m *UI) insertFileCompletion(path string) tea.Cmd { + value := m.textarea.Value() + word := m.textareaWord() + + // Find the @ and query to replace. + if m.completionsStartIndex > len(value) { + return nil + } + + // Build the new value: everything before @, the path, everything after query. + endIdx := min(m.completionsStartIndex+len(word), len(value)) + + newValue := value[:m.completionsStartIndex] + path + value[endIdx:] + m.textarea.SetValue(newValue) + m.textarea.MoveToEnd() + m.textarea.InsertRune(' ') + + return func() tea.Msg { + absPath, _ := filepath.Abs(path) + // Skip attachment if file was already read and hasn't been modified. + lastRead := filetracker.LastReadTime(absPath) + if !lastRead.IsZero() { + if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) { + return nil + } + } + + // Add file as attachment. + content, err := os.ReadFile(path) + if err != nil { + // If it fails, let the LLM handle it later. + return nil + } + filetracker.RecordRead(absPath) + + return message.Attachment{ + FilePath: path, + FileName: filepath.Base(path), + MimeType: mimeOf(content), + Content: content, + } + } +} + +// completionsPosition returns the X and Y position for the completions popup. +func (m *UI) completionsPosition() image.Point { + cur := m.textarea.Cursor() + if cur == nil { + return image.Point{ + X: m.layout.editor.Min.X, + Y: m.layout.editor.Min.Y, + } + } + return image.Point{ + X: cur.X + m.layout.editor.Min.X, + Y: m.layout.editor.Min.Y + cur.Y, + } +} + +// textareaWord returns the current word at the cursor position. +func (m *UI) textareaWord() string { + return m.textarea.Word() +} + +// isWhitespace returns true if the byte is a whitespace character. +func isWhitespace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// isAgentBusy returns true if the agent coordinator exists and is currently +// busy processing a request. +func (m *UI) isAgentBusy() bool { + return m.com.App != nil && + m.com.App.AgentCoordinator != nil && + m.com.App.AgentCoordinator.IsBusy() +} + +// hasSession returns true if there is an active session with a valid ID. +func (m *UI) hasSession() bool { + return m.session != nil && m.session.ID != "" +} + +// mimeOf detects the MIME type of the given content. +func mimeOf(content []byte) string { + mimeBufferSize := min(512, len(content)) + return http.DetectContentType(content[:mimeBufferSize]) +} + +var readyPlaceholders = [...]string{ + "Ready!", + "Ready...", + "Ready?", + "Ready for instructions", +} + +var workingPlaceholders = [...]string{ + "Working!", + "Working...", + "Brrrrr...", + "Prrrrrrrr...", + "Processing...", + "Thinking...", +} + +// randomizePlaceholders selects random placeholder text for the textarea's +// ready and working states. +func (m *UI) randomizePlaceholders() { + m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] + m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] +} + +// renderEditorView renders the editor view with attachments if any. +func (m *UI) renderEditorView(width int) string { + if len(m.attachments.List()) == 0 { + return m.textarea.View() + } + return lipgloss.JoinVertical( + lipgloss.Top, + m.attachments.Render(width), + m.textarea.View(), + ) +} + +// renderHeader renders and caches the header logo at the specified width. +func (m *UI) renderHeader(compact bool, width int) { + if compact && m.session != nil && m.com.App != nil { + m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width) + } else { + m.header = renderLogo(m.com.Styles, compact, width) + } +} + +// renderSidebarLogo renders and caches the sidebar logo at the specified +// width. +func (m *UI) renderSidebarLogo(width int) { + m.sidebarLogo = renderLogo(m.com.Styles, true, width) +} + +// sendMessage sends a message with the given content and attachments. +func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd { + if m.com.App.AgentCoordinator == nil { + return uiutil.ReportError(fmt.Errorf("coder agent is not initialized")) + } + + var cmds []tea.Cmd + if !m.hasSession() { + newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session") + if err != nil { + return uiutil.ReportError(err) + } + m.state = uiChat + if m.forceCompactMode { + m.isCompact = true + } + if newSession.ID != "" { + m.session = &newSession + cmds = append(cmds, m.loadSession(newSession.ID)) + } + } + + // Capture session ID to avoid race with main goroutine updating m.session. + sessionID := m.session.ID + cmds = append(cmds, func() tea.Msg { + _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...) + if err != nil { + isCancelErr := errors.Is(err, context.Canceled) + isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied) + if isCancelErr || isPermissionErr { + return nil + } + return uiutil.InfoMsg{ + Type: uiutil.InfoTypeError, + Msg: err.Error(), + } + } + return nil + }) + return tea.Batch(cmds...) +} + +const cancelTimerDuration = 2 * time.Second + +// cancelTimerCmd creates a command that expires the cancel timer. +func cancelTimerCmd() tea.Cmd { + return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg { + return cancelTimerExpiredMsg{} + }) +} + +// cancelAgent handles the cancel key press. The first press sets isCanceling to true +// and starts a timer. The second press (before the timer expires) actually +// cancels the agent. +func (m *UI) cancelAgent() tea.Cmd { + if !m.hasSession() { + return nil + } + + coordinator := m.com.App.AgentCoordinator + if coordinator == nil { + return nil + } + + if m.isCanceling { + // Second escape press - actually cancel the agent. + m.isCanceling = false + coordinator.Cancel(m.session.ID) + // Stop the spinning todo indicator. + m.todoIsSpinning = false + m.renderPills() + return nil + } + + // Check if there are queued prompts - if so, clear the queue. + if coordinator.QueuedPrompts(m.session.ID) > 0 { + coordinator.ClearQueue(m.session.ID) + return nil + } + + // First escape press - set canceling state and start timer. + m.isCanceling = true + return cancelTimerCmd() +} + +// openDialog opens a dialog by its ID. +func (m *UI) openDialog(id string) tea.Cmd { + var cmds []tea.Cmd + switch id { + case dialog.SessionsID: + if cmd := m.openSessionsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.ModelsID: + if cmd := m.openModelsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.CommandsID: + if cmd := m.openCommandsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.ReasoningID: + if cmd := m.openReasoningDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + case dialog.QuitID: + if cmd := m.openQuitDialog(); cmd != nil { + cmds = append(cmds, cmd) + } + default: + // Unknown dialog + break + } + return tea.Batch(cmds...) +} + +// openQuitDialog opens the quit confirmation dialog. +func (m *UI) openQuitDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.QuitID) { + // Bring to front + m.dialog.BringToFront(dialog.QuitID) + return nil + } + + quitDialog := dialog.NewQuit(m.com) + m.dialog.OpenDialog(quitDialog) + return nil +} + +// openModelsDialog opens the models dialog. +func (m *UI) openModelsDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.ModelsID) { + // Bring to front + m.dialog.BringToFront(dialog.ModelsID) + return nil + } + + modelsDialog, err := dialog.NewModels(m.com) + if err != nil { + return uiutil.ReportError(err) + } + + m.dialog.OpenDialog(modelsDialog) + + return nil +} + +// openCommandsDialog opens the commands dialog. +func (m *UI) openCommandsDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.CommandsID) { + // Bring to front + m.dialog.BringToFront(dialog.CommandsID) + return nil + } + + sessionID := "" + if m.session != nil { + sessionID = m.session.ID + } + + commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts) + if err != nil { + return uiutil.ReportError(err) + } + + m.dialog.OpenDialog(commands) + + return nil +} + +// openReasoningDialog opens the reasoning effort dialog. +func (m *UI) openReasoningDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.ReasoningID) { + m.dialog.BringToFront(dialog.ReasoningID) + return nil + } + + reasoningDialog, err := dialog.NewReasoning(m.com) + if err != nil { + return uiutil.ReportError(err) + } + + m.dialog.OpenDialog(reasoningDialog) + return nil +} + +// openSessionsDialog opens the sessions dialog. If the dialog is already open, +// it brings it to the front. Otherwise, it will list all the sessions and open +// the dialog. +func (m *UI) openSessionsDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.SessionsID) { + // Bring to front + m.dialog.BringToFront(dialog.SessionsID) + return nil + } + + selectedSessionID := "" + if m.session != nil { + selectedSessionID = m.session.ID + } + + dialog, err := dialog.NewSessions(m.com, selectedSessionID) + if err != nil { + return uiutil.ReportError(err) + } + + m.dialog.OpenDialog(dialog) + return nil +} + +// openFilesDialog opens the file picker dialog. +func (m *UI) openFilesDialog() tea.Cmd { + if m.dialog.ContainsDialog(dialog.FilePickerID) { + // Bring to front + m.dialog.BringToFront(dialog.FilePickerID) + return nil + } + + filePicker, cmd := dialog.NewFilePicker(m.com) + filePicker.SetImageCapabilities(&m.imgCaps) + m.dialog.OpenDialog(filePicker) + + return cmd +} + +// openPermissionsDialog opens the permissions dialog for a permission request. +func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd { + // Close any existing permissions dialog first. + m.dialog.CloseDialog(dialog.PermissionsID) + + // Get diff mode from config. + var opts []dialog.PermissionsOption + if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" { + opts = append(opts, dialog.WithDiffMode(diffMode == "split")) + } + + permDialog := dialog.NewPermissions(m.com, perm, opts...) + m.dialog.OpenDialog(permDialog) + return nil +} + +// handlePermissionNotification updates tool items when permission state changes. +func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) { + toolItem := m.chat.MessageItem(notification.ToolCallID) + if toolItem == nil { + return + } + + if permItem, ok := toolItem.(chat.ToolMessageItem); ok { + if notification.Granted { + permItem.SetStatus(chat.ToolStatusRunning) + } else { + permItem.SetStatus(chat.ToolStatusAwaitingPermission) + } + } +} + +// newSession clears the current session state and prepares for a new session. +// The actual session creation happens when the user sends their first message. +func (m *UI) newSession() { + if !m.hasSession() { + return + } + + m.session = nil + m.sessionFiles = nil + m.state = uiLanding + m.focus = uiFocusEditor + m.textarea.Focus() + m.chat.Blur() + m.chat.ClearMessages() + m.pillsExpanded = false + m.promptQueue = 0 + m.pillsView = "" +} + +// handlePasteMsg handles a paste message. +func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { + if m.dialog.HasDialogs() { + return m.handleDialogMsg(msg) + } + + if m.focus != uiFocusEditor { + return nil + } + + if strings.Count(msg.Content, "\n") > pasteLinesThreshold { + return func() tea.Msg { + content := []byte(msg.Content) + if int64(len(content)) > common.MaxAttachmentSize { + return uiutil.ReportWarn("Paste is too big (>5mb)") + } + name := fmt.Sprintf("paste_%d.txt", m.pasteIdx()) + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + return message.Attachment{ + FileName: name, + FilePath: name, + MimeType: mimeType, + Content: content, + } + } + } + + var cmd tea.Cmd + path := strings.ReplaceAll(msg.Content, "\\ ", " ") + // Try to get an image. + path, err := filepath.Abs(strings.TrimSpace(path)) + if err != nil { + m.textarea, cmd = m.textarea.Update(msg) + return cmd + } + + // Check if file has an allowed image extension. + isAllowedType := false + lowerPath := strings.ToLower(path) + for _, ext := range common.AllowedImageTypes { + if strings.HasSuffix(lowerPath, ext) { + isAllowedType = true + break + } + } + if !isAllowedType { + m.textarea, cmd = m.textarea.Update(msg) + return cmd + } + + return func() tea.Msg { + fileInfo, err := os.Stat(path) + if err != nil { + return uiutil.ReportError(err) + } + if fileInfo.Size() > common.MaxAttachmentSize { + return uiutil.ReportWarn("File is too big (>5mb)") + } + + content, err := os.ReadFile(path) + if err != nil { + return uiutil.ReportError(err) + } + + mimeBufferSize := min(512, len(content)) + mimeType := http.DetectContentType(content[:mimeBufferSize]) + fileName := filepath.Base(path) + return message.Attachment{ + FilePath: path, + FileName: fileName, + MimeType: mimeType, + Content: content, + } + } +} + +var pasteRE = regexp.MustCompile(`paste_(\d+).txt`) + +func (m *UI) pasteIdx() int { + result := 0 + for _, at := range m.attachments.List() { + found := pasteRE.FindStringSubmatch(at.FileName) + if len(found) == 0 { + continue + } + idx, err := strconv.Atoi(found[1]) + if err == nil { + result = max(result, idx) + } + } + return result + 1 +} + +// drawSessionDetails draws the session details in compact mode. +func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) { + if m.session == nil { + return + } + + s := m.com.Styles + + width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize() + height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize() + + title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title) + blocks := []string{ + title, + "", + m.modelInfo(width), + "", + } + + detailsHeader := lipgloss.JoinVertical( + lipgloss.Left, + blocks..., + ) + + version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version) + + remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version) + + const maxSectionWidth = 50 + sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces + maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing + + lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false) + mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false) + filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false) + sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection) + uv.NewStyledString( + s.CompactDetails.View. + Width(area.Dx()). + Render( + lipgloss.JoinVertical( + lipgloss.Left, + detailsHeader, + sections, + version, + ), + ), + ).Draw(scr, area) +} + +func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd { + load := func() tea.Msg { + prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments) + if err != nil { + // TODO: make this better + return uiutil.ReportError(err)() + } + + if prompt == "" { + return nil + } + return sendMessageMsg{ + Content: prompt, + } + } + + var cmds []tea.Cmd + if cmd := m.dialog.StartLoading(); cmd != nil { + cmds = append(cmds, cmd) + } + cmds = append(cmds, load, func() tea.Msg { + return closeDialogMsg{} + }) + + return tea.Sequence(cmds...) +} + +func (m *UI) copyChatHighlight() tea.Cmd { + text := m.chat.HighlighContent() + return tea.Sequence( + tea.SetClipboard(text), + func() tea.Msg { + _ = clipboard.WriteAll(text) + return nil + }, + func() tea.Msg { + m.chat.ClearMouse() + return nil + }, + uiutil.ReportInfo("Selected text copied to clipboard"), + ) +} + +// renderLogo renders the Crush logo with the given styles and dimensions. +func renderLogo(t *styles.Styles, compact bool, width int) string { + return logo.Render(version.Version, compact, logo.Opts{ + FieldColor: t.LogoFieldColor, + TitleColorA: t.LogoTitleColorA, + TitleColorB: t.LogoTitleColorB, + CharmColor: t.LogoCharmColor, + VersionColor: t.LogoVersionColor, + Width: width, + }) +} diff --git a/internal/ui/styles/grad.go b/internal/ui/styles/grad.go new file mode 100644 index 0000000000000000000000000000000000000000..866a00fa501b48caa2a69f559efd7d45964cec97 --- /dev/null +++ b/internal/ui/styles/grad.go @@ -0,0 +1,117 @@ +package styles + +import ( + "fmt" + "image/color" + "strings" + + "github.com/lucasb-eyer/go-colorful" + "github.com/rivo/uniseg" +) + +// ForegroundGrad returns a slice of strings representing the input string +// rendered with a horizontal gradient foreground from color1 to color2. Each +// string in the returned slice corresponds to a grapheme cluster in the input +// string. If bold is true, the rendered strings will be bolded. +func ForegroundGrad(t *Styles, input string, bold bool, color1, color2 color.Color) []string { + if input == "" { + return []string{""} + } + if len(input) == 1 { + style := t.Base.Foreground(color1) + if bold { + style.Bold(true) + } + return []string{style.Render(input)} + } + var clusters []string + gr := uniseg.NewGraphemes(input) + for gr.Next() { + clusters = append(clusters, string(gr.Runes())) + } + + ramp := blendColors(len(clusters), color1, color2) + for i, c := range ramp { + style := t.Base.Foreground(c) + if bold { + style.Bold(true) + } + clusters[i] = style.Render(clusters[i]) + } + return clusters +} + +// ApplyForegroundGrad renders a given string with a horizontal gradient +// foreground. +func ApplyForegroundGrad(t *Styles, input string, color1, color2 color.Color) string { + if input == "" { + return "" + } + var o strings.Builder + clusters := ForegroundGrad(t, input, false, color1, color2) + for _, c := range clusters { + fmt.Fprint(&o, c) + } + return o.String() +} + +// ApplyBoldForegroundGrad renders a given string with a horizontal gradient +// foreground. +func ApplyBoldForegroundGrad(t *Styles, input string, color1, color2 color.Color) string { + if input == "" { + return "" + } + var o strings.Builder + clusters := ForegroundGrad(t, input, true, color1, color2) + for _, c := range clusters { + fmt.Fprint(&o, c) + } + return o.String() +} + +// blendColors returns a slice of colors blended between the given keys. +// Blending is done in Hcl to stay in gamut. +func blendColors(size int, stops ...color.Color) []color.Color { + if len(stops) < 2 { + return nil + } + + stopsPrime := make([]colorful.Color, len(stops)) + for i, k := range stops { + stopsPrime[i], _ = colorful.MakeColor(k) + } + + numSegments := len(stopsPrime) - 1 + blended := make([]color.Color, 0, size) + + // Calculate how many colors each segment should have. + segmentSizes := make([]int, numSegments) + baseSize := size / numSegments + remainder := size % numSegments + + // Distribute the remainder across segments. + for i := range numSegments { + segmentSizes[i] = baseSize + if i < remainder { + segmentSizes[i]++ + } + } + + // Generate colors for each segment. + for i := range numSegments { + c1 := stopsPrime[i] + c2 := stopsPrime[i+1] + segmentSize := segmentSizes[i] + + for j := range segmentSize { + var t float64 + if segmentSize > 1 { + t = float64(j) / float64(segmentSize-1) + } + c := c1.BlendHcl(c2, t) + blended = append(blended, c) + } + } + + return blended +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go new file mode 100644 index 0000000000000000000000000000000000000000..f40fd5113bf1495fa5d35e0b891e397e6a90b6ec --- /dev/null +++ b/internal/ui/styles/styles.go @@ -0,0 +1,1344 @@ +package styles + +import ( + "image/color" + + "charm.land/bubbles/v2/filepicker" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2/ansi" + "charm.land/lipgloss/v2" + "github.com/alecthomas/chroma/v2" + "github.com/charmbracelet/crush/internal/tui/exp/diffview" + "github.com/charmbracelet/x/exp/charmtone" +) + +const ( + CheckIcon string = "✓" + ErrorIcon string = "×" + WarningIcon string = "⚠" + InfoIcon string = "ⓘ" + HintIcon string = "∵" + SpinnerIcon string = "⋯" + LoadingIcon string = "⟳" + ModelIcon string = "◇" + + ArrowRightIcon string = "→" + + ToolPending string = "●" + ToolSuccess string = "✓" + ToolError string = "×" + + RadioOn string = "◉" + RadioOff string = "○" + + BorderThin string = "│" + BorderThick string = "▌" + + SectionSeparator string = "─" + + TodoCompletedIcon string = "✓" + TodoPendingIcon string = "•" + TodoInProgressIcon string = "→" + + ImageIcon string = "■" + TextIcon string = "≡" + + ScrollbarThumb string = "┃" + ScrollbarTrack string = "│" +) + +const ( + defaultMargin = 2 + defaultListIndent = 2 +) + +type Styles struct { + WindowTooSmall lipgloss.Style + + // Reusable text styles + Base lipgloss.Style + Muted lipgloss.Style + HalfMuted lipgloss.Style + Subtle lipgloss.Style + + // Tags + TagBase lipgloss.Style + TagError lipgloss.Style + TagInfo lipgloss.Style + + // Header + Header struct { + Charm lipgloss.Style // Style for "Charm™" label + Diagonals lipgloss.Style // Style for diagonal separators (╱) + Percentage lipgloss.Style // Style for context percentage + Keystroke lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d") + KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close") + WorkingDir lipgloss.Style // Style for current working directory + Separator lipgloss.Style // Style for separator dots (•) + } + + CompactDetails struct { + View lipgloss.Style + Version lipgloss.Style + Title lipgloss.Style + } + + // Panels + PanelMuted lipgloss.Style + PanelBase lipgloss.Style + + // Line numbers for code blocks + LineNumber lipgloss.Style + + // Message borders + FocusedMessageBorder lipgloss.Border + + // Tool calls + ToolCallPending lipgloss.Style + ToolCallError lipgloss.Style + ToolCallSuccess lipgloss.Style + ToolCallCancelled lipgloss.Style + EarlyStateMessage lipgloss.Style + + // Text selection + TextSelection lipgloss.Style + + // LSP and MCP status indicators + ItemOfflineIcon lipgloss.Style + ItemBusyIcon lipgloss.Style + ItemErrorIcon lipgloss.Style + ItemOnlineIcon lipgloss.Style + + // Markdown & Chroma + Markdown ansi.StyleConfig + PlainMarkdown ansi.StyleConfig + + // Inputs + TextInput textinput.Styles + TextArea textarea.Styles + + // Help + Help help.Styles + + // Diff + Diff diffview.Style + + // FilePicker + FilePicker filepicker.Styles + + // Buttons + ButtonFocus lipgloss.Style + ButtonBlur lipgloss.Style + + // Borders + BorderFocus lipgloss.Style + BorderBlur lipgloss.Style + + // Editor + EditorPromptNormalFocused lipgloss.Style + EditorPromptNormalBlurred lipgloss.Style + EditorPromptYoloIconFocused lipgloss.Style + EditorPromptYoloIconBlurred lipgloss.Style + EditorPromptYoloDotsFocused lipgloss.Style + EditorPromptYoloDotsBlurred lipgloss.Style + + // Radio + RadioOn lipgloss.Style + RadioOff lipgloss.Style + + // Background + Background color.Color + + // Logo + LogoFieldColor color.Color + LogoTitleColorA color.Color + LogoTitleColorB color.Color + LogoCharmColor color.Color + LogoVersionColor color.Color + + // Colors - semantic colors for tool rendering. + Primary color.Color + Secondary color.Color + Tertiary color.Color + BgBase color.Color + BgBaseLighter color.Color + BgSubtle color.Color + BgOverlay color.Color + FgBase color.Color + FgMuted color.Color + FgHalfMuted color.Color + FgSubtle color.Color + Border color.Color + BorderColor color.Color // Border focus color + Error color.Color + Warning color.Color + Info color.Color + White color.Color + BlueLight color.Color + Blue color.Color + BlueDark color.Color + GreenLight color.Color + Green color.Color + GreenDark color.Color + Red color.Color + RedDark color.Color + Yellow color.Color + + // Section Title + Section struct { + Title lipgloss.Style + Line lipgloss.Style + } + + // Initialize + Initialize struct { + Header lipgloss.Style + Content lipgloss.Style + Accent lipgloss.Style + } + + // LSP + LSP struct { + ErrorDiagnostic lipgloss.Style + WarningDiagnostic lipgloss.Style + HintDiagnostic lipgloss.Style + InfoDiagnostic lipgloss.Style + } + + // Files + Files struct { + Path lipgloss.Style + Additions lipgloss.Style + Deletions lipgloss.Style + } + + // Chat + Chat struct { + // Message item styles + Message struct { + UserBlurred lipgloss.Style + UserFocused lipgloss.Style + AssistantBlurred lipgloss.Style + AssistantFocused lipgloss.Style + NoContent lipgloss.Style + Thinking lipgloss.Style + ErrorTag lipgloss.Style + ErrorTitle lipgloss.Style + ErrorDetails lipgloss.Style + ToolCallFocused lipgloss.Style + ToolCallCompact lipgloss.Style + ToolCallBlurred lipgloss.Style + SectionHeader lipgloss.Style + + // Thinking section styles + ThinkingBox lipgloss.Style // Background for thinking content + ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint + ThinkingFooterTitle lipgloss.Style // "Thought for" text + ThinkingFooterDuration lipgloss.Style // Duration value + AssistantInfoIcon lipgloss.Style + AssistantInfoModel lipgloss.Style + AssistantInfoProvider lipgloss.Style + AssistantInfoDuration lipgloss.Style + } + } + + // Tool - styles for tool call rendering + Tool struct { + // Icon styles with tool status + IconPending lipgloss.Style // Pending operation icon + IconSuccess lipgloss.Style // Successful operation icon + IconError lipgloss.Style // Error operation icon + IconCancelled lipgloss.Style // Cancelled operation icon + + // Tool name styles + NameNormal lipgloss.Style // Normal tool name + NameNested lipgloss.Style // Nested tool name + + // Parameter list styles + ParamMain lipgloss.Style // Main parameter + ParamKey lipgloss.Style // Parameter keys + + // Content rendering styles + ContentLine lipgloss.Style // Individual content line with background and width + ContentTruncation lipgloss.Style // Truncation message "… (N lines)" + ContentCodeLine lipgloss.Style // Code line with background and width + ContentCodeTruncation lipgloss.Style // Code truncation message with bgBase + ContentCodeBg color.Color // Background color for syntax highlighting + Body lipgloss.Style // Body content padding (PaddingLeft(2)) + + // Deprecated - kept for backward compatibility + ContentBg lipgloss.Style // Content background + ContentText lipgloss.Style // Content text + ContentLineNumber lipgloss.Style // Line numbers in code + + // State message styles + StateWaiting lipgloss.Style // "Waiting for tool response..." + StateCancelled lipgloss.Style // "Canceled." + + // Error styles + ErrorTag lipgloss.Style // ERROR tag + ErrorMessage lipgloss.Style // Error message text + + // Diff styles + DiffTruncation lipgloss.Style // Diff truncation message with padding + + // Multi-edit note styles + NoteTag lipgloss.Style // NOTE tag (yellow background) + NoteMessage lipgloss.Style // Note message text + + // Job header styles (for bash jobs) + JobIconPending lipgloss.Style // Pending job icon (green dark) + JobIconError lipgloss.Style // Error job icon (red dark) + JobIconSuccess lipgloss.Style // Success job icon (green) + JobToolName lipgloss.Style // Job tool name "Bash" (blue) + JobAction lipgloss.Style // Action text (Start, Output, Kill) + JobPID lipgloss.Style // PID text + JobDescription lipgloss.Style // Description text + + // Agent task styles + AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold) + AgentPrompt lipgloss.Style // Agent prompt text + + // Agentic fetch styles + AgenticFetchPromptTag lipgloss.Style // Agentic fetch prompt tag (green background, bold) + + // Todo styles + TodoRatio lipgloss.Style // Todo ratio (e.g., "2/5") + TodoCompletedIcon lipgloss.Style // Completed todo icon + TodoInProgressIcon lipgloss.Style // In-progress todo icon + TodoPendingIcon lipgloss.Style // Pending todo icon + + // MCP tools + MCPName lipgloss.Style // The mcp name + MCPToolName lipgloss.Style // The mcp tool name + MCPArrow lipgloss.Style // The mcp arrow icon + } + + // Dialog styles + Dialog struct { + Title lipgloss.Style + TitleText lipgloss.Style + TitleError lipgloss.Style + TitleAccent lipgloss.Style + // View is the main content area style. + View lipgloss.Style + PrimaryText lipgloss.Style + SecondaryText lipgloss.Style + // HelpView is the line that contains the help. + HelpView lipgloss.Style + Help struct { + Ellipsis lipgloss.Style + ShortKey lipgloss.Style + ShortDesc lipgloss.Style + ShortSeparator lipgloss.Style + FullKey lipgloss.Style + FullDesc lipgloss.Style + FullSeparator lipgloss.Style + } + NormalItem lipgloss.Style + SelectedItem lipgloss.Style + InputPrompt lipgloss.Style + + List lipgloss.Style + + Spinner lipgloss.Style + + // ContentPanel is used for content blocks with subtle background. + ContentPanel lipgloss.Style + + // Scrollbar styles for scrollable content. + ScrollbarThumb lipgloss.Style + ScrollbarTrack lipgloss.Style + + // Arguments + Arguments struct { + Content lipgloss.Style + Description lipgloss.Style + InputLabelBlurred lipgloss.Style + InputLabelFocused lipgloss.Style + InputRequiredMarkBlurred lipgloss.Style + InputRequiredMarkFocused lipgloss.Style + } + + Commands struct{} + + ImagePreview lipgloss.Style + } + + // Status bar and help + Status struct { + Help lipgloss.Style + + ErrorIndicator lipgloss.Style + WarnIndicator lipgloss.Style + InfoIndicator lipgloss.Style + UpdateIndicator lipgloss.Style + SuccessIndicator lipgloss.Style + + ErrorMessage lipgloss.Style + WarnMessage lipgloss.Style + InfoMessage lipgloss.Style + UpdateMessage lipgloss.Style + SuccessMessage lipgloss.Style + } + + // Completions popup styles + Completions struct { + Normal lipgloss.Style + Focused lipgloss.Style + Match lipgloss.Style + } + + // Attachments styles + Attachments struct { + Normal lipgloss.Style + Image lipgloss.Style + Text lipgloss.Style + Deleting lipgloss.Style + } + + // Pills styles for todo/queue pills + Pills struct { + Base lipgloss.Style // Base pill style with padding + Focused lipgloss.Style // Focused pill with visible border + Blurred lipgloss.Style // Blurred pill with hidden border + QueueItemPrefix lipgloss.Style // Prefix for queue list items + HelpKey lipgloss.Style // Keystroke hint style + HelpText lipgloss.Style // Help action text style + Area lipgloss.Style // Pills area container + TodoSpinner lipgloss.Style // Todo spinner style + } +} + +// ChromaTheme converts the current markdown chroma styles to a chroma +// StyleEntries map. +func (s *Styles) ChromaTheme() chroma.StyleEntries { + rules := s.Markdown.CodeBlock + + return chroma.StyleEntries{ + chroma.Text: chromaStyle(rules.Chroma.Text), + chroma.Error: chromaStyle(rules.Chroma.Error), + chroma.Comment: chromaStyle(rules.Chroma.Comment), + chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc), + chroma.Keyword: chromaStyle(rules.Chroma.Keyword), + chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved), + chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace), + chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType), + chroma.Operator: chromaStyle(rules.Chroma.Operator), + chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation), + chroma.Name: chromaStyle(rules.Chroma.Name), + chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin), + chroma.NameTag: chromaStyle(rules.Chroma.NameTag), + chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute), + chroma.NameClass: chromaStyle(rules.Chroma.NameClass), + chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant), + chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator), + chroma.NameException: chromaStyle(rules.Chroma.NameException), + chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction), + chroma.NameOther: chromaStyle(rules.Chroma.NameOther), + chroma.Literal: chromaStyle(rules.Chroma.Literal), + chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber), + chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate), + chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString), + chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape), + chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted), + chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph), + chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted), + chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong), + chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading), + chroma.Background: chromaStyle(rules.Chroma.Background), + } +} + +// DialogHelpStyles returns the styles for dialog help. +func (s *Styles) DialogHelpStyles() help.Styles { + return help.Styles(s.Dialog.Help) +} + +// DefaultStyles returns the default styles for the UI. +func DefaultStyles() Styles { + var ( + primary = charmtone.Charple + secondary = charmtone.Dolly + tertiary = charmtone.Bok + // accent = charmtone.Zest + + // Backgrounds + bgBase = charmtone.Pepper + bgBaseLighter = charmtone.BBQ + bgSubtle = charmtone.Charcoal + bgOverlay = charmtone.Iron + + // Foregrounds + fgBase = charmtone.Ash + fgMuted = charmtone.Squid + fgHalfMuted = charmtone.Smoke + fgSubtle = charmtone.Oyster + // fgSelected = charmtone.Salt + + // Borders + border = charmtone.Charcoal + borderFocus = charmtone.Charple + + // Status + error = charmtone.Sriracha + warning = charmtone.Zest + info = charmtone.Malibu + + // Colors + white = charmtone.Butter + + blueLight = charmtone.Sardine + blue = charmtone.Malibu + blueDark = charmtone.Damson + + // yellow = charmtone.Mustard + yellow = charmtone.Mustard + // citron = charmtone.Citron + + greenLight = charmtone.Bok + green = charmtone.Julep + greenDark = charmtone.Guac + // greenLight = charmtone.Bok + + red = charmtone.Coral + redDark = charmtone.Sriracha + // redLight = charmtone.Salmon + // cherry = charmtone.Cherry + ) + + normalBorder := lipgloss.NormalBorder() + + base := lipgloss.NewStyle().Foreground(fgBase) + + s := Styles{} + + s.Background = bgBase + + // Populate color fields + s.Primary = primary + s.Secondary = secondary + s.Tertiary = tertiary + s.BgBase = bgBase + s.BgBaseLighter = bgBaseLighter + s.BgSubtle = bgSubtle + s.BgOverlay = bgOverlay + s.FgBase = fgBase + s.FgMuted = fgMuted + s.FgHalfMuted = fgHalfMuted + s.FgSubtle = fgSubtle + s.Border = border + s.BorderColor = borderFocus + s.Error = error + s.Warning = warning + s.Info = info + s.White = white + s.BlueLight = blueLight + s.Blue = blue + s.BlueDark = blueDark + s.GreenLight = greenLight + s.Green = green + s.GreenDark = greenDark + s.Red = red + s.RedDark = redDark + s.Yellow = yellow + + s.TextInput = textinput.Styles{ + Focused: textinput.StyleState{ + Text: base, + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(tertiary), + Suggestion: base.Foreground(fgSubtle), + }, + Blurred: textinput.StyleState{ + Text: base.Foreground(fgMuted), + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(fgMuted), + Suggestion: base.Foreground(fgSubtle), + }, + Cursor: textinput.CursorStyle{ + Color: secondary, + Shape: tea.CursorBlock, + Blink: true, + }, + } + + s.TextArea = textarea.Styles{ + Focused: textarea.StyleState{ + Base: base, + Text: base, + LineNumber: base.Foreground(fgSubtle), + CursorLine: base, + CursorLineNumber: base.Foreground(fgSubtle), + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(tertiary), + }, + Blurred: textarea.StyleState{ + Base: base, + Text: base.Foreground(fgMuted), + LineNumber: base.Foreground(fgMuted), + CursorLine: base, + CursorLineNumber: base.Foreground(fgMuted), + Placeholder: base.Foreground(fgSubtle), + Prompt: base.Foreground(fgMuted), + }, + Cursor: textarea.CursorStyle{ + Color: secondary, + Shape: tea.CursorBlock, + Blink: true, + }, + } + + s.Markdown = ansi.StyleConfig{ + Document: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + // BlockPrefix: "\n", + // BlockSuffix: "\n", + Color: stringPtr(charmtone.Smoke.Hex()), + }, + // Margin: uintPtr(defaultMargin), + }, + BlockQuote: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + Indent: uintPtr(1), + IndentToken: stringPtr("│ "), + }, + List: ansi.StyleList{ + LevelIndent: defaultListIndent, + }, + Heading: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockSuffix: "\n", + Color: stringPtr(charmtone.Malibu.Hex()), + Bold: boolPtr(true), + }, + }, + H1: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: stringPtr(charmtone.Zest.Hex()), + BackgroundColor: stringPtr(charmtone.Charple.Hex()), + Bold: boolPtr(true), + }, + }, + H2: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "## ", + }, + }, + H3: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "### ", + }, + }, + H4: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "#### ", + }, + }, + H5: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "##### ", + }, + }, + H6: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "###### ", + Color: stringPtr(charmtone.Guac.Hex()), + Bold: boolPtr(false), + }, + }, + Strikethrough: ansi.StylePrimitive{ + CrossedOut: boolPtr(true), + }, + Emph: ansi.StylePrimitive{ + Italic: boolPtr(true), + }, + Strong: ansi.StylePrimitive{ + Bold: boolPtr(true), + }, + HorizontalRule: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Charcoal.Hex()), + Format: "\n--------\n", + }, + Item: ansi.StylePrimitive{ + BlockPrefix: "• ", + }, + Enumeration: ansi.StylePrimitive{ + BlockPrefix: ". ", + }, + Task: ansi.StyleTask{ + StylePrimitive: ansi.StylePrimitive{}, + Ticked: "[✓] ", + Unticked: "[ ] ", + }, + Link: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Zinc.Hex()), + Underline: boolPtr(true), + }, + LinkText: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Guac.Hex()), + Bold: boolPtr(true), + }, + Image: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Cheeky.Hex()), + Underline: boolPtr(true), + }, + ImageText: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Squid.Hex()), + Format: "Image: {{.text}} →", + }, + Code: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: stringPtr(charmtone.Coral.Hex()), + BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), + }, + }, + CodeBlock: ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Charcoal.Hex()), + }, + Margin: uintPtr(defaultMargin), + }, + Chroma: &ansi.Chroma{ + Text: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Smoke.Hex()), + }, + Error: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Butter.Hex()), + BackgroundColor: stringPtr(charmtone.Sriracha.Hex()), + }, + Comment: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Oyster.Hex()), + }, + CommentPreproc: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Bengal.Hex()), + }, + Keyword: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Malibu.Hex()), + }, + KeywordReserved: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Pony.Hex()), + }, + KeywordNamespace: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Pony.Hex()), + }, + KeywordType: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Guppy.Hex()), + }, + Operator: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Salmon.Hex()), + }, + Punctuation: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Zest.Hex()), + }, + Name: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Smoke.Hex()), + }, + NameBuiltin: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Cheeky.Hex()), + }, + NameTag: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Mauve.Hex()), + }, + NameAttribute: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Hazy.Hex()), + }, + NameClass: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Salt.Hex()), + Underline: boolPtr(true), + Bold: boolPtr(true), + }, + NameDecorator: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Citron.Hex()), + }, + NameFunction: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Guac.Hex()), + }, + LiteralNumber: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Julep.Hex()), + }, + LiteralString: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Cumin.Hex()), + }, + LiteralStringEscape: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Bok.Hex()), + }, + GenericDeleted: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Coral.Hex()), + }, + GenericEmph: ansi.StylePrimitive{ + Italic: boolPtr(true), + }, + GenericInserted: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Guac.Hex()), + }, + GenericStrong: ansi.StylePrimitive{ + Bold: boolPtr(true), + }, + GenericSubheading: ansi.StylePrimitive{ + Color: stringPtr(charmtone.Squid.Hex()), + }, + Background: ansi.StylePrimitive{ + BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), + }, + }, + }, + Table: ansi.StyleTable{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + }, + DefinitionDescription: ansi.StylePrimitive{ + BlockPrefix: "\n ", + }, + } + + // PlainMarkdown style - muted colors on subtle background for thinking content. + plainBg := stringPtr(bgBaseLighter.Hex()) + plainFg := stringPtr(fgMuted.Hex()) + s.PlainMarkdown = ansi.StyleConfig{ + Document: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + BlockQuote: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + Indent: uintPtr(1), + IndentToken: stringPtr("│ "), + }, + List: ansi.StyleList{ + LevelIndent: defaultListIndent, + }, + Heading: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockSuffix: "\n", + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H1: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H2: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "## ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H3: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H4: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "#### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H5: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "##### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + H6: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "###### ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + Strikethrough: ansi.StylePrimitive{ + CrossedOut: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + Emph: ansi.StylePrimitive{ + Italic: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + Strong: ansi.StylePrimitive{ + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + HorizontalRule: ansi.StylePrimitive{ + Format: "\n--------\n", + Color: plainFg, + BackgroundColor: plainBg, + }, + Item: ansi.StylePrimitive{ + BlockPrefix: "• ", + Color: plainFg, + BackgroundColor: plainBg, + }, + Enumeration: ansi.StylePrimitive{ + BlockPrefix: ". ", + Color: plainFg, + BackgroundColor: plainBg, + }, + Task: ansi.StyleTask{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + Ticked: "[✓] ", + Unticked: "[ ] ", + }, + Link: ansi.StylePrimitive{ + Underline: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + LinkText: ansi.StylePrimitive{ + Bold: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + Image: ansi.StylePrimitive{ + Underline: boolPtr(true), + Color: plainFg, + BackgroundColor: plainBg, + }, + ImageText: ansi.StylePrimitive{ + Format: "Image: {{.text}} →", + Color: plainFg, + BackgroundColor: plainBg, + }, + Code: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: " ", + Suffix: " ", + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + CodeBlock: ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + Margin: uintPtr(defaultMargin), + }, + }, + Table: ansi.StyleTable{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: plainFg, + BackgroundColor: plainBg, + }, + }, + }, + DefinitionDescription: ansi.StylePrimitive{ + BlockPrefix: "\n ", + Color: plainFg, + BackgroundColor: plainBg, + }, + } + + s.Help = help.Styles{ + ShortKey: base.Foreground(fgMuted), + ShortDesc: base.Foreground(fgSubtle), + ShortSeparator: base.Foreground(border), + Ellipsis: base.Foreground(border), + FullKey: base.Foreground(fgMuted), + FullDesc: base.Foreground(fgSubtle), + FullSeparator: base.Foreground(border), + } + + s.Diff = diffview.Style{ + DividerLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Foreground(fgHalfMuted). + Background(bgBaseLighter), + Code: lipgloss.NewStyle(). + Foreground(fgHalfMuted). + Background(bgBaseLighter), + }, + MissingLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Background(bgBaseLighter), + Code: lipgloss.NewStyle(). + Background(bgBaseLighter), + }, + EqualLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Foreground(fgMuted). + Background(bgBase), + Code: lipgloss.NewStyle(). + Foreground(fgMuted). + Background(bgBase), + }, + InsertLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#629657")). + Background(lipgloss.Color("#2b322a")), + Symbol: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#629657")). + Background(lipgloss.Color("#323931")), + Code: lipgloss.NewStyle(). + Background(lipgloss.Color("#323931")), + }, + DeleteLine: diffview.LineStyle{ + LineNumber: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#a45c59")). + Background(lipgloss.Color("#312929")), + Symbol: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#a45c59")). + Background(lipgloss.Color("#383030")), + Code: lipgloss.NewStyle(). + Background(lipgloss.Color("#383030")), + }, + } + + s.FilePicker = filepicker.Styles{ + DisabledCursor: base.Foreground(fgMuted), + Cursor: base.Foreground(fgBase), + Symlink: base.Foreground(fgSubtle), + Directory: base.Foreground(primary), + File: base.Foreground(fgBase), + DisabledFile: base.Foreground(fgMuted), + DisabledSelected: base.Background(bgOverlay).Foreground(fgMuted), + Permission: base.Foreground(fgMuted), + Selected: base.Background(primary).Foreground(fgBase), + FileSize: base.Foreground(fgMuted), + EmptyDirectory: base.Foreground(fgMuted).PaddingLeft(2).SetString("Empty directory"), + } + + // borders + s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick} + + // text presets + s.Base = lipgloss.NewStyle().Foreground(fgBase) + s.Muted = lipgloss.NewStyle().Foreground(fgMuted) + s.HalfMuted = lipgloss.NewStyle().Foreground(fgHalfMuted) + s.Subtle = lipgloss.NewStyle().Foreground(fgSubtle) + + s.WindowTooSmall = s.Muted + + // tag presets + s.TagBase = lipgloss.NewStyle().Padding(0, 1).Foreground(white) + s.TagError = s.TagBase.Background(redDark) + s.TagInfo = s.TagBase.Background(blueLight) + + // Compact header styles + s.Header.Charm = base.Foreground(secondary) + s.Header.Diagonals = base.Foreground(primary) + s.Header.Percentage = s.Muted + s.Header.Keystroke = s.Muted + s.Header.KeystrokeTip = s.Subtle + s.Header.WorkingDir = s.Muted + s.Header.Separator = s.Subtle + + s.CompactDetails.Title = s.Base + s.CompactDetails.View = s.Base.Padding(0, 1, 1, 1).Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus) + s.CompactDetails.Version = s.Muted + + // panels + s.PanelMuted = s.Muted.Background(bgBaseLighter) + s.PanelBase = lipgloss.NewStyle().Background(bgBase) + + // code line number + s.LineNumber = lipgloss.NewStyle().Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1) + + // Tool calls + s.ToolCallPending = lipgloss.NewStyle().Foreground(greenDark).SetString(ToolPending) + s.ToolCallError = lipgloss.NewStyle().Foreground(redDark).SetString(ToolError) + s.ToolCallSuccess = lipgloss.NewStyle().Foreground(green).SetString(ToolSuccess) + // Cancelled uses muted tone but same glyph as pending + s.ToolCallCancelled = s.Muted.SetString(ToolPending) + s.EarlyStateMessage = s.Subtle.PaddingLeft(2) + + // Tool rendering styles + s.Tool.IconPending = base.Foreground(greenDark).SetString(ToolPending) + s.Tool.IconSuccess = base.Foreground(green).SetString(ToolSuccess) + s.Tool.IconError = base.Foreground(redDark).SetString(ToolError) + s.Tool.IconCancelled = s.Muted.SetString(ToolPending) + + s.Tool.NameNormal = base.Foreground(blue) + s.Tool.NameNested = base.Foreground(fgHalfMuted) + + s.Tool.ParamMain = s.Subtle + s.Tool.ParamKey = s.Subtle + + // Content rendering - prepared styles that accept width parameter + s.Tool.ContentLine = s.Muted.Background(bgBaseLighter) + s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter) + s.Tool.ContentCodeLine = s.Base.Background(bgBase) + s.Tool.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2) + s.Tool.ContentCodeBg = bgBase + s.Tool.Body = base.PaddingLeft(2) + + // Deprecated - kept for backward compatibility + s.Tool.ContentBg = s.Muted.Background(bgBaseLighter) + s.Tool.ContentText = s.Muted + s.Tool.ContentLineNumber = base.Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1) + + s.Tool.StateWaiting = base.Foreground(fgSubtle) + s.Tool.StateCancelled = base.Foreground(fgSubtle) + + s.Tool.ErrorTag = base.Padding(0, 1).Background(red).Foreground(white) + s.Tool.ErrorMessage = base.Foreground(fgHalfMuted) + + // Diff and multi-edit styles + s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2) + s.Tool.NoteTag = base.Padding(0, 1).Background(info).Foreground(white) + s.Tool.NoteMessage = base.Foreground(fgHalfMuted) + + // Job header styles + s.Tool.JobIconPending = base.Foreground(greenDark) + s.Tool.JobIconError = base.Foreground(redDark) + s.Tool.JobIconSuccess = base.Foreground(green) + s.Tool.JobToolName = base.Foreground(blue) + s.Tool.JobAction = base.Foreground(blueDark) + s.Tool.JobPID = s.Muted + s.Tool.JobDescription = s.Subtle + + // Agent task styles + s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white) + s.Tool.AgentPrompt = s.Muted + + // Agentic fetch styles + s.Tool.AgenticFetchPromptTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(green).Foreground(border) + + // Todo styles + s.Tool.TodoRatio = base.Foreground(blueDark) + s.Tool.TodoCompletedIcon = base.Foreground(green) + s.Tool.TodoInProgressIcon = base.Foreground(greenDark) + s.Tool.TodoPendingIcon = base.Foreground(fgMuted) + + // MCP styles + s.Tool.MCPName = base.Foreground(blue) + s.Tool.MCPToolName = base.Foreground(blueDark) + s.Tool.MCPArrow = base.Foreground(blue).SetString(ArrowRightIcon) + + // Buttons + s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary) + s.ButtonBlur = s.Base.Background(bgSubtle) + + // Borders + s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2) + + // Editor + s.EditorPromptNormalFocused = lipgloss.NewStyle().Foreground(greenDark).SetString("::: ") + s.EditorPromptNormalBlurred = s.EditorPromptNormalFocused.Foreground(fgMuted) + s.EditorPromptYoloIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ") + s.EditorPromptYoloIconBlurred = s.EditorPromptYoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid) + s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Zest).SetString(":::") + s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid) + + s.RadioOn = s.HalfMuted.SetString(RadioOn) + s.RadioOff = s.HalfMuted.SetString(RadioOff) + + // Logo colors + s.LogoFieldColor = primary + s.LogoTitleColorA = secondary + s.LogoTitleColorB = primary + s.LogoCharmColor = secondary + s.LogoVersionColor = primary + + // Section + s.Section.Title = s.Subtle + s.Section.Line = s.Base.Foreground(charmtone.Charcoal) + + // Initialize + s.Initialize.Header = s.Base + s.Initialize.Content = s.Muted + s.Initialize.Accent = s.Base.Foreground(greenDark) + + // LSP and MCP status. + s.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●") + s.ItemBusyIcon = s.ItemOfflineIcon.Foreground(charmtone.Citron) + s.ItemErrorIcon = s.ItemOfflineIcon.Foreground(charmtone.Coral) + s.ItemOnlineIcon = s.ItemOfflineIcon.Foreground(charmtone.Guac) + + // LSP + s.LSP.ErrorDiagnostic = s.Base.Foreground(redDark) + s.LSP.WarningDiagnostic = s.Base.Foreground(warning) + s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted) + s.LSP.InfoDiagnostic = s.Base.Foreground(info) + + // Files + s.Files.Path = s.Muted + s.Files.Additions = s.Base.Foreground(greenDark) + s.Files.Deletions = s.Base.Foreground(redDark) + + // Chat + messageFocussedBorder := lipgloss.Border{ + Left: "▌", + } + + s.Chat.Message.NoContent = lipgloss.NewStyle().Foreground(fgBase) + s.Chat.Message.UserBlurred = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true). + BorderForeground(primary).BorderStyle(normalBorder) + s.Chat.Message.UserFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true). + BorderForeground(primary).BorderStyle(messageFocussedBorder) + s.Chat.Message.AssistantBlurred = s.Chat.Message.NoContent.PaddingLeft(2) + s.Chat.Message.AssistantFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true). + BorderForeground(greenDark).BorderStyle(messageFocussedBorder) + s.Chat.Message.Thinking = lipgloss.NewStyle().MaxHeight(10) + s.Chat.Message.ErrorTag = lipgloss.NewStyle().Padding(0, 1). + Background(red).Foreground(white) + s.Chat.Message.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted) + s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle) + + // Message item styles + s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1). + BorderStyle(messageFocussedBorder). + BorderLeft(true). + BorderForeground(greenDark) + s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2) + // No padding or border for compact tool calls within messages + s.Chat.Message.ToolCallCompact = s.Muted + s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2) + s.Chat.Message.AssistantInfoIcon = s.Subtle + s.Chat.Message.AssistantInfoModel = s.Muted + s.Chat.Message.AssistantInfoProvider = s.Subtle + s.Chat.Message.AssistantInfoDuration = s.Subtle + + // Thinking section styles + s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter) + s.Chat.Message.ThinkingTruncationHint = s.Muted + s.Chat.Message.ThinkingFooterTitle = s.Muted + s.Chat.Message.ThinkingFooterDuration = s.Subtle + + // Text selection. + s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) + + // Dialog styles + s.Dialog.Title = base.Padding(0, 1).Foreground(primary) + s.Dialog.TitleText = base.Foreground(primary) + s.Dialog.TitleError = base.Foreground(red) + s.Dialog.TitleAccent = base.Foreground(green).Bold(true) + s.Dialog.View = base.Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus) + s.Dialog.PrimaryText = base.Padding(0, 1).Foreground(primary) + s.Dialog.SecondaryText = base.Padding(0, 1).Foreground(fgSubtle) + s.Dialog.HelpView = base.Padding(0, 1).AlignHorizontal(lipgloss.Left) + s.Dialog.Help.ShortKey = base.Foreground(fgMuted) + s.Dialog.Help.ShortDesc = base.Foreground(fgSubtle) + s.Dialog.Help.ShortSeparator = base.Foreground(border) + s.Dialog.Help.Ellipsis = base.Foreground(border) + s.Dialog.Help.FullKey = base.Foreground(fgMuted) + s.Dialog.Help.FullDesc = base.Foreground(fgSubtle) + s.Dialog.Help.FullSeparator = base.Foreground(border) + s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase) + s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase) + s.Dialog.InputPrompt = base.Margin(1, 1) + + s.Dialog.List = base.Margin(0, 0, 1, 0) + s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2) + s.Dialog.Spinner = base.Foreground(secondary) + s.Dialog.ScrollbarThumb = base.Foreground(secondary) + s.Dialog.ScrollbarTrack = base.Foreground(border) + + s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle) + + s.Dialog.Arguments.Content = base.Padding(1) + s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3) + s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted) + s.Dialog.Arguments.InputLabelFocused = base.Bold(true) + s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*") + s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*") + + s.Status.Help = lipgloss.NewStyle().Padding(0, 1) + s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") + s.Status.InfoIndicator = s.Status.SuccessIndicator + s.Status.UpdateIndicator = s.Status.SuccessIndicator.SetString("HEY!") + s.Status.WarnIndicator = s.Status.SuccessIndicator.Foreground(bgOverlay).Background(yellow).SetString("WARNING") + s.Status.ErrorIndicator = s.Status.SuccessIndicator.Foreground(bgBase).Background(red).SetString("ERROR") + s.Status.SuccessMessage = base.Foreground(bgSubtle).Background(greenDark).Padding(0, 1) + s.Status.InfoMessage = s.Status.SuccessMessage + s.Status.UpdateMessage = s.Status.SuccessMessage + s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning) + s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark) + + // Completions styles + s.Completions.Normal = base.Background(bgSubtle).Foreground(fgBase) + s.Completions.Focused = base.Background(primary).Foreground(white) + s.Completions.Match = base.Underline(true) + + // Attachments styles + attachmentIconStyle := base.Foreground(bgSubtle).Background(green).Padding(0, 1) + s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon) + s.Attachments.Text = attachmentIconStyle.SetString(TextIcon) + s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase) + s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase) + + // Pills styles + s.Pills.Base = base.Padding(0, 1) + s.Pills.Focused = base.Padding(0, 1).BorderStyle(lipgloss.RoundedBorder()).BorderForeground(bgOverlay) + s.Pills.Blurred = base.Padding(0, 1).BorderStyle(lipgloss.HiddenBorder()) + s.Pills.QueueItemPrefix = s.Muted.SetString(" •") + s.Pills.HelpKey = s.Muted + s.Pills.HelpText = s.Subtle + s.Pills.Area = base + s.Pills.TodoSpinner = base.Foreground(greenDark) + + return s +} + +// Helper functions for style pointers +func boolPtr(b bool) *bool { return &b } +func stringPtr(s string) *string { return &s } +func uintPtr(u uint) *uint { return &u } +func chromaStyle(style ansi.StylePrimitive) string { + var s string + + if style.Color != nil { + s = *style.Color + } + if style.BackgroundColor != nil { + if s != "" { + s += " " + } + s += "bg:" + *style.BackgroundColor + } + if style.Italic != nil && *style.Italic { + if s != "" { + s += " " + } + s += "italic" + } + if style.Bold != nil && *style.Bold { + if s != "" { + s += " " + } + s += "bold" + } + if style.Underline != nil && *style.Underline { + if s != "" { + s += " " + } + s += "underline" + } + + return s +} diff --git a/internal/uicmd/uicmd.go b/internal/uicmd/uicmd.go index c571dacd1989c518347e3a773b36d6d5fd2b8878..c2ce2d89d1457459ac84c9e97c6e68b371e042d8 100644 --- a/internal/uicmd/uicmd.go +++ b/internal/uicmd/uicmd.go @@ -1,6 +1,7 @@ // Package uicmd provides functionality to load and handle custom commands // from markdown files and MCP prompts. // TODO: Move this into internal/ui after refactoring. +// TODO: DELETE when we delete the old tui package uicmd import ( diff --git a/internal/uiutil/uiutil.go b/internal/uiutil/uiutil.go index efd89dda69f780b354777916b459675154780372..d0443f9c1e4b40fc23b3fb9d597a1d0cd785e1b0 100644 --- a/internal/uiutil/uiutil.go +++ b/internal/uiutil/uiutil.go @@ -26,10 +26,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd { func ReportError(err error) tea.Cmd { slog.Error("Error reported", "error", err) - return CmdHandler(InfoMsg{ - Type: InfoTypeError, - Msg: err.Error(), - }) + return CmdHandler(NewErrorMsg(err)) } type InfoType int @@ -42,18 +39,33 @@ const ( InfoTypeUpdate ) -func ReportInfo(info string) tea.Cmd { - return CmdHandler(InfoMsg{ +func NewInfoMsg(info string) InfoMsg { + return InfoMsg{ Type: InfoTypeInfo, Msg: info, - }) + } } -func ReportWarn(warn string) tea.Cmd { - return CmdHandler(InfoMsg{ +func NewWarnMsg(warn string) InfoMsg { + return InfoMsg{ Type: InfoTypeWarn, Msg: warn, - }) + } +} + +func NewErrorMsg(err error) InfoMsg { + return InfoMsg{ + Type: InfoTypeError, + Msg: err.Error(), + } +} + +func ReportInfo(info string) tea.Cmd { + return CmdHandler(NewInfoMsg(info)) +} + +func ReportWarn(warn string) tea.Cmd { + return CmdHandler(NewWarnMsg(warn)) } type ( @@ -65,6 +77,12 @@ type ( ClearStatusMsg struct{} ) +// IsEmpty checks if the [InfoMsg] is empty. +func (m InfoMsg) IsEmpty() bool { + var zero InfoMsg + return m == zero +} + // ExecShell parses a shell command string and executes it with exec.Command. // Uses shell.Fields for proper handling of shell syntax like quotes and // arguments while preserving TTY handling for terminal editors.