.gitignore π
@@ -48,6 +48,5 @@ Thumbs.db
/tmp/
manpages/
-completions/
-!internal/tui/components/completions/
+completions/crush.*sh
.prettierignore
Ayman Bagabas , Kujtim Hoxha , Andrey Nering , Carlos Alexandro Becker , Copilot , and Christian Rocha created
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>
Co-authored-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Christian Rocha <christian@rocha.is>
.gitignore | 3
AGENTS.md | 3
go.mod | 6
go.sum | 9
internal/cmd/root.go | 18
internal/commands/commands.go | 237 ++
internal/config/config.go | 5
internal/message/attachment.go | 2
internal/ui/AGENTS.md | 61
internal/ui/anim/anim.go | 445 ++++
internal/ui/attachments/attachments.go | 135 +
internal/ui/chat/agent.go | 302 ++
internal/ui/chat/assistant.go | 257 ++
internal/ui/chat/bash.go | 248 ++
internal/ui/chat/diagnostics.go | 68
internal/ui/chat/fetch.go | 192 +
internal/ui/chat/file.go | 340 +++
internal/ui/chat/mcp.go | 121 +
internal/ui/chat/messages.go | 312 +++
internal/ui/chat/search.go | 256 ++
internal/ui/chat/todos.go | 192 +
internal/ui/chat/tools.go | 805 +++++++
internal/ui/chat/user.go | 94
internal/ui/common/button.go | 69
internal/ui/common/common.go | 65
internal/ui/common/diff.go | 16
internal/ui/common/elements.go | 190 +
internal/ui/common/highlight.go | 57
internal/ui/common/interface.go | 11
internal/ui/common/markdown.go | 26
internal/ui/common/scrollbar.go | 46
internal/ui/completions/completions.go | 267 ++
internal/ui/completions/item.go | 185 +
internal/ui/completions/keys.go | 74
internal/ui/dialog/actions.go | 165 +
internal/ui/dialog/api_key_input.go | 302 ++
internal/ui/dialog/arguments.go | 399 +++
internal/ui/dialog/commands.go | 477 ++++
internal/ui/dialog/commands_item.go | 70
internal/ui/dialog/common.go | 130 +
internal/ui/dialog/dialog.go | 197 +
internal/ui/dialog/filepicker.go | 304 ++
internal/ui/dialog/models.go | 478 ++++
internal/ui/dialog/models_item.go | 124 +
internal/ui/dialog/models_list.go | 273 ++
internal/ui/dialog/oauth.go | 369 +++
internal/ui/dialog/oauth_copilot.go | 72
internal/ui/dialog/oauth_hyper.go | 90
internal/ui/dialog/permissions.go | 760 +++++++
internal/ui/dialog/quit.go | 133 +
internal/ui/dialog/reasoning.go | 297 ++
internal/ui/dialog/sessions.go | 194 +
internal/ui/dialog/sessions_item.go | 187 +
internal/ui/image/image.go | 299 ++
internal/ui/list/filterable.go | 125 +
internal/ui/list/focus.go | 13
internal/ui/list/highlight.go | 208 ++
internal/ui/list/item.go | 61
internal/ui/list/list.go | 634 ++++++
internal/ui/logo/logo.go | 346 +++
internal/ui/logo/rand.go | 24
internal/ui/model/chat.go | 600 +++++
internal/ui/model/header.go | 112 +
internal/ui/model/keys.go | 246 ++
internal/ui/model/landing.go | 50
internal/ui/model/lsp.go | 118 +
internal/ui/model/mcp.go | 98
internal/ui/model/onboarding.go | 101
internal/ui/model/pills.go | 283 ++
internal/ui/model/session.go | 244 ++
internal/ui/model/sidebar.go | 163 +
internal/ui/model/status.go | 106 +
internal/ui/model/ui.go | 2895 ++++++++++++++++++++++++++++
internal/ui/styles/grad.go | 117 +
internal/ui/styles/styles.go | 1344 ++++++++++++
internal/uicmd/uicmd.go | 1
internal/uiutil/uiutil.go | 38
77 files changed, 18,344 insertions(+), 20 deletions(-)
@@ -48,6 +48,5 @@ Thumbs.db
/tmp/
manpages/
-completions/
-!internal/tui/components/completions/
+completions/crush.*sh
.prettierignore
@@ -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
@@ -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
@@ -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=
@@ -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
@@ -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
+}
@@ -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"
@@ -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()
@@ -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
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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")
+}
@@ -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)
+}
@@ -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)
+}
@@ -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)
+}
@@ -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
+}
@@ -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
+}
@@ -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)
+}
@@ -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
+ }
+}
@@ -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"))
+}
@@ -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)
+}
@@ -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)
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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()
+}
@@ -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()
+}
@@ -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)
+)
@@ -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,
+ }
+}
@@ -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,
+ }
+ }
+}
@@ -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,
+ }
+}
@@ -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},
+ }
+}
@@ -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
+}
@@ -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)
+}
@@ -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)
+}
@@ -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:]...)
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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 ""
+}
@@ -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,
+ }
+}
@@ -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
+}
@@ -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
+}
@@ -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_<mcpname>_<toolname>).
+ 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()}
+}
@@ -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},
+ }
+}
@@ -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)
+}
@@ -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
+}
@@ -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
+}
@@ -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 ""
+ }
+}
@@ -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()
+}
@@ -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
+ }
+}
@@ -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),
+ },
+ }
+}
@@ -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)
+}
@@ -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
+}
@@ -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...)
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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),
+ )
+}
@@ -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...)
+}
@@ -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...)
+}
@@ -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",
+ ))
+}
@@ -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)
+}
@@ -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...)
+}
@@ -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)
+}
@@ -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{}
+ })
+}
@@ -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,
+ })
+}
@@ -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
+}
@@ -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
+}
@@ -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 (
@@ -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.