on main help sign in
refactor(ui): reimplement UI components (#1652)
c444075Ayman Bagabas
.gitignore+1−2
@@ -48,6 +48,5 @@ Thumbs.db
48/tmp/
48/tmp/
49
49
50manpages/
50manpages/
51−completions/
51+completions/crush.*sh
52−!internal/tui/components/completions/
53.prettierignore
52.prettierignore
CRUSH.md → AGENTS.mdrenamed+3
@@ -70,3 +70,6 @@ func TestYourFunction(t *testing.T) {
70- ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc).
70- ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc).
71- Try to keep commits to one line, not including your attribution. Only use
71- Try to keep commits to one line, not including your attribution. Only use
72 multi-line commits when additional context is truly necessary.
72 multi-line commits when additional context is truly necessary.
73+
74+## Working on the TUI (UI)
75+Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file
go.mod+4−2
@@ -31,10 +31,13 @@ require (
31 github.com/charmbracelet/x/exp/ordered v0.1.0
31 github.com/charmbracelet/x/exp/ordered v0.1.0
32 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
32 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
33 github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59
33 github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59
34+ github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383
34 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
35 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
35 github.com/charmbracelet/x/term v0.2.2
36 github.com/charmbracelet/x/term v0.2.2
36 github.com/denisbrodbeck/machineid v1.0.1
37 github.com/denisbrodbeck/machineid v1.0.1
37 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
38 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
39+ github.com/disintegration/imaging v1.6.2
40+ github.com/dustin/go-humanize v1.0.1
38 github.com/google/uuid v1.6.0
41 github.com/google/uuid v1.6.0
39 github.com/invopop/jsonschema v0.13.0
42 github.com/invopop/jsonschema v0.13.0
40 github.com/joho/godotenv v1.5.1
43 github.com/joho/godotenv v1.5.1
@@ -109,8 +112,7 @@ require (
109 github.com/davecgh/go-spew v1.1.1 // indirect
112 github.com/davecgh/go-spew v1.1.1 // indirect
110 github.com/disintegration/gift v1.1.2 // indirect
113 github.com/disintegration/gift v1.1.2 // indirect
111 github.com/dlclark/regexp2 v1.11.5 // indirect
114 github.com/dlclark/regexp2 v1.11.5 // indirect
112− github.com/dustin/go-humanize v1.0.1 // indirect
113− github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 // indirect
115+ github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
114 github.com/felixge/httpsnoop v1.0.4 // indirect
116 github.com/felixge/httpsnoop v1.0.4 // indirect
115 github.com/fsnotify/fsnotify v1.9.0 // indirect
117 github.com/fsnotify/fsnotify v1.9.0 // indirect
116 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
118 github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
go.sum+7−2
@@ -122,6 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59 h1:cvP
122github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
122github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
123github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
123github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
124github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
124github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
125+github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y=
126+github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383/go.mod h1:r+fiJS0jb0Z5XKO+1mgKbwbPWzTy8e2dMjBMqa+XqsY=
125github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
127github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
126github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
128github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
127github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
129github.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
150github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
152github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
151github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
153github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
152github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
154github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
155+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
156+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
153github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
157github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
154github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
158github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
155github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
159github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
156github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
160github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
157−github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72 h1:7LxHj6bTGLfcjjDMZyTH8ZDB8nQrcwoFNr1s4yiWtac=
161+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA=
158−github.com/ebitengine/purego v0.10.0-alpha.3.0.20260115160133-57859678ab72/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
162+github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
159github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
163github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
160github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
164github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
161github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
165github.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=
397golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
401golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
398golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
402golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
399golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
403golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
404+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
400golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
405golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
401golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
406golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
402golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
407golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
internal/cmd/root.go+15−3
@@ -22,6 +22,8 @@ import (
22 "github.com/charmbracelet/crush/internal/projects"
22 "github.com/charmbracelet/crush/internal/projects"
23 "github.com/charmbracelet/crush/internal/stringext"
23 "github.com/charmbracelet/crush/internal/stringext"
24 "github.com/charmbracelet/crush/internal/tui"
24 "github.com/charmbracelet/crush/internal/tui"
25+ "github.com/charmbracelet/crush/internal/ui/common"
26+ ui "github.com/charmbracelet/crush/internal/ui/model"
25 "github.com/charmbracelet/crush/internal/version"
27 "github.com/charmbracelet/crush/internal/version"
26 "github.com/charmbracelet/fang"
28 "github.com/charmbracelet/fang"
27 uv "github.com/charmbracelet/ultraviolet"
29 uv "github.com/charmbracelet/ultraviolet"
@@ -86,11 +88,21 @@ crush -y
86
88
87 // Set up the TUI.
89 // Set up the TUI.
88 var env uv.Environ = os.Environ()
90 var env uv.Environ = os.Environ()
89− ui := tui.New(app)
90− ui.QueryVersion = shouldQueryTerminalVersion(env)
91
91
92+ var model tea.Model
93+ if v, _ := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); v {
94+ slog.Info("New UI in control!")
95+ com := common.DefaultCommon(app)
96+ ui := ui.New(com)
97+ ui.QueryVersion = shouldQueryTerminalVersion(env)
98+ model = ui
99+ } else {
100+ ui := tui.New(app)
101+ ui.QueryVersion = shouldQueryTerminalVersion(env)
102+ model = ui
103+ }
92 program := tea.NewProgram(
104 program := tea.NewProgram(
93− ui,
105+ model,
94 tea.WithEnvironment(env),
106 tea.WithEnvironment(env),
95 tea.WithContext(cmd.Context()),
107 tea.WithContext(cmd.Context()),
96 tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
108 tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
internal/commands/commands.gonew file+237
@@ -0,0 +1,237 @@
1+package commands
2+
3+import (
4+ "context"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "regexp"
9+ "strings"
10+
11+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/home"
14+)
15+
16+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
17+
18+const (
19+ userCommandPrefix = "user:"
20+ projectCommandPrefix = "project:"
21+)
22+
23+// Argument represents a command argument with its metadata.
24+type Argument struct {
25+ ID string
26+ Title string
27+ Description string
28+ Required bool
29+}
30+
31+// MCPPrompt represents a custom command loaded from an MCP server.
32+type MCPPrompt struct {
33+ ID string
34+ Title string
35+ Description string
36+ PromptID string
37+ ClientID string
38+ Arguments []Argument
39+}
40+
41+// CustomCommand represents a user-defined custom command loaded from markdown files.
42+type CustomCommand struct {
43+ ID string
44+ Name string
45+ Content string
46+ Arguments []Argument
47+}
48+
49+type commandSource struct {
50+ path string
51+ prefix string
52+}
53+
54+// LoadCustomCommands loads custom commands from multiple sources including
55+// XDG config directory, home directory, and project directory.
56+func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
57+ return loadAll(buildCommandSources(cfg))
58+}
59+
60+// LoadMCPPrompts loads custom commands from available MCP servers.
61+func LoadMCPPrompts() ([]MCPPrompt, error) {
62+ var commands []MCPPrompt
63+ for mcpName, prompts := range mcp.Prompts() {
64+ for _, prompt := range prompts {
65+ key := mcpName + ":" + prompt.Name
66+ var args []Argument
67+ for _, arg := range prompt.Arguments {
68+ title := arg.Title
69+ if title == "" {
70+ title = arg.Name
71+ }
72+ args = append(args, Argument{
73+ ID: arg.Name,
74+ Title: title,
75+ Description: arg.Description,
76+ Required: arg.Required,
77+ })
78+ }
79+ commands = append(commands, MCPPrompt{
80+ ID: key,
81+ Title: prompt.Title,
82+ Description: prompt.Description,
83+ PromptID: prompt.Name,
84+ ClientID: mcpName,
85+ Arguments: args,
86+ })
87+ }
88+ }
89+ return commands, nil
90+}
91+
92+func buildCommandSources(cfg *config.Config) []commandSource {
93+ var sources []commandSource
94+
95+ // XDG config directory
96+ if dir := getXDGCommandsDir(); dir != "" {
97+ sources = append(sources, commandSource{
98+ path: dir,
99+ prefix: userCommandPrefix,
100+ })
101+ }
102+
103+ // Home directory
104+ if home := home.Dir(); home != "" {
105+ sources = append(sources, commandSource{
106+ path: filepath.Join(home, ".crush", "commands"),
107+ prefix: userCommandPrefix,
108+ })
109+ }
110+
111+ // Project directory
112+ sources = append(sources, commandSource{
113+ path: filepath.Join(cfg.Options.DataDirectory, "commands"),
114+ prefix: projectCommandPrefix,
115+ })
116+
117+ return sources
118+}
119+
120+func loadAll(sources []commandSource) ([]CustomCommand, error) {
121+ var commands []CustomCommand
122+
123+ for _, source := range sources {
124+ if cmds, err := loadFromSource(source); err == nil {
125+ commands = append(commands, cmds...)
126+ }
127+ }
128+
129+ return commands, nil
130+}
131+
132+func loadFromSource(source commandSource) ([]CustomCommand, error) {
133+ if err := ensureDir(source.path); err != nil {
134+ return nil, err
135+ }
136+
137+ var commands []CustomCommand
138+
139+ err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
140+ if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
141+ return err
142+ }
143+
144+ cmd, err := loadCommand(path, source.path, source.prefix)
145+ if err != nil {
146+ return nil // Skip invalid files
147+ }
148+
149+ commands = append(commands, cmd)
150+ return nil
151+ })
152+
153+ return commands, err
154+}
155+
156+func loadCommand(path, baseDir, prefix string) (CustomCommand, error) {
157+ content, err := os.ReadFile(path)
158+ if err != nil {
159+ return CustomCommand{}, err
160+ }
161+
162+ id := buildCommandID(path, baseDir, prefix)
163+
164+ return CustomCommand{
165+ ID: id,
166+ Name: id,
167+ Content: string(content),
168+ Arguments: extractArgNames(string(content)),
169+ }, nil
170+}
171+
172+func extractArgNames(content string) []Argument {
173+ matches := namedArgPattern.FindAllStringSubmatch(content, -1)
174+ if len(matches) == 0 {
175+ return nil
176+ }
177+
178+ seen := make(map[string]bool)
179+ var args []Argument
180+
181+ for _, match := range matches {
182+ arg := match[1]
183+ if !seen[arg] {
184+ seen[arg] = true
185+ // for normal custom commands, all args are required
186+ args = append(args, Argument{ID: arg, Title: arg, Required: true})
187+ }
188+ }
189+
190+ return args
191+}
192+
193+func buildCommandID(path, baseDir, prefix string) string {
194+ relPath, _ := filepath.Rel(baseDir, path)
195+ parts := strings.Split(relPath, string(filepath.Separator))
196+
197+ // Remove .md extension from last part
198+ if len(parts) > 0 {
199+ lastIdx := len(parts) - 1
200+ parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
201+ }
202+
203+ return prefix + strings.Join(parts, ":")
204+}
205+
206+func getXDGCommandsDir() string {
207+ xdgHome := os.Getenv("XDG_CONFIG_HOME")
208+ if xdgHome == "" {
209+ if home := home.Dir(); home != "" {
210+ xdgHome = filepath.Join(home, ".config")
211+ }
212+ }
213+ if xdgHome != "" {
214+ return filepath.Join(xdgHome, "crush", "commands")
215+ }
216+ return ""
217+}
218+
219+func ensureDir(path string) error {
220+ if _, err := os.Stat(path); os.IsNotExist(err) {
221+ return os.MkdirAll(path, 0o755)
222+ }
223+ return nil
224+}
225+
226+func isMarkdownFile(name string) bool {
227+ return strings.HasSuffix(strings.ToLower(name), ".md")
228+}
229+
230+func GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
231+ // TODO: we should pass the context down
232+ result, err := mcp.GetPromptMessages(context.Background(), clientID, promptID, args)
233+ if err != nil {
234+ return "", err
235+ }
236+ return strings.Join(result, " "), nil
237+}
internal/config/config.go+5
@@ -53,6 +53,11 @@ var defaultContextPaths = []string{
53
53
54type SelectedModelType string
54type SelectedModelType string
55
55
56+// String returns the string representation of the [SelectedModelType].
57+func (s SelectedModelType) String() string {
58+ return string(s)
59+}
60+
56const (
61const (
57 SelectedModelTypeLarge SelectedModelType = "large"
62 SelectedModelTypeLarge SelectedModelType = "large"
58 SelectedModelTypeSmall SelectedModelType = "small"
63 SelectedModelTypeSmall SelectedModelType = "small"
internal/message/attachment.go+1−1
@@ -15,7 +15,7 @@ type Attachment struct {
15func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
15func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
16func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
16func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
17
17
18−// ContainsTextAttachment returns true if any of the attachments is a text attachments.
18+// ContainsTextAttachment returns true if any of the attachments is a text attachment.
19func ContainsTextAttachment(attachments []Attachment) bool {
19func ContainsTextAttachment(attachments []Attachment) bool {
20 return slices.ContainsFunc(attachments, func(a Attachment) bool {
20 return slices.ContainsFunc(attachments, func(a Attachment) bool {
21 return a.IsText()
21 return a.IsText()
internal/ui/AGENTS.mdnew file+61
@@ -0,0 +1,61 @@
1+# UI Development Instructions
2+
3+## General Guidelines
4+- Never use commands to send messages when you can directly mutate children or state.
5+- Keep things simple; do not overcomplicate.
6+- Create files if needed to separate logic; do not nest models.
7+- Always do IO in commands
8+- Never change the model state inside of a command use messages and than update the state in the main loop
9+
10+## Architecture
11+
12+### Main Model (`model/ui.go`)
13+Keep most of the logic and state in the main model. This is where:
14+- Message routing happens
15+- Focus and UI state is managed
16+- Layout calculations are performed
17+- Dialogs are orchestrated
18+
19+### Components Should Be Dumb
20+Components should not handle bubbletea messages directly. Instead:
21+- Expose methods for state changes
22+- Return `tea.Cmd` from methods when side effects are needed
23+- Handle their own rendering via `Render(width int) string`
24+
25+### Chat Logic (`model/chat.go`)
26+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`).
27+
28+## Key Patterns
29+
30+### Composition Over Inheritance
31+Use struct embedding for shared behaviors. See `chat/messages.go` for examples of reusable embedded structs for highlighting, caching, and focus.
32+
33+### Interfaces
34+- List item interfaces are in `list/item.go`
35+- Chat message interfaces are in `chat/messages.go`
36+- Dialog interface is in `dialog/dialog.go`
37+
38+### Styling
39+- All styles are defined in `styles/styles.go`
40+- Access styles via `*common.Common` passed to components
41+- Use semantic color fields rather than hardcoded colors
42+
43+### Dialogs
44+- Implement the dialog interface in `dialog/dialog.go`
45+- Return message types from `Update()` to signal actions to the main model
46+- Use the overlay system for managing dialog lifecycle
47+
48+## File Organization
49+- `model/` - Main UI model and major components (chat, sidebar, etc.)
50+- `chat/` - Chat message item types and renderers
51+- `dialog/` - Dialog implementations
52+- `list/` - Generic list component with lazy rendering
53+- `common/` - Shared utilities and the Common struct
54+- `styles/` - All style definitions
55+- `anim/` - Animation system
56+- `logo/` - Logo rendering
57+
58+## Common Gotchas
59+- Always account for padding/borders in width calculations
60+- Use `tea.Batch()` when returning multiple commands
61+- Pass `*common.Common` to components that need styles or app access
internal/ui/anim/anim.gonew file+445
@@ -0,0 +1,445 @@
1+// Package anim provides an animated spinner.
2+package anim
3+
4+import (
5+ "fmt"
6+ "image/color"
7+ "math/rand/v2"
8+ "strings"
9+ "sync/atomic"
10+ "time"
11+
12+ "github.com/zeebo/xxh3"
13+
14+ tea "charm.land/bubbletea/v2"
15+ "charm.land/lipgloss/v2"
16+ "github.com/lucasb-eyer/go-colorful"
17+
18+ "github.com/charmbracelet/crush/internal/csync"
19+)
20+
21+const (
22+ fps = 20
23+ initialChar = '.'
24+ labelGap = " "
25+ labelGapWidth = 1
26+
27+ // Periods of ellipsis animation speed in steps.
28+ //
29+ // If the FPS is 20 (50 milliseconds) this means that the ellipsis will
30+ // change every 8 frames (400 milliseconds).
31+ ellipsisAnimSpeed = 8
32+
33+ // The maximum amount of time that can pass before a character appears.
34+ // This is used to create a staggered entrance effect.
35+ maxBirthOffset = time.Second
36+
37+ // Number of frames to prerender for the animation. After this number
38+ // of frames, the animation will loop. This only applies when color
39+ // cycling is disabled.
40+ prerenderedFrames = 10
41+
42+ // Default number of cycling chars.
43+ defaultNumCyclingChars = 10
44+)
45+
46+// Default colors for gradient.
47+var (
48+ defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
49+ defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
50+ defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
51+)
52+
53+var (
54+ availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
55+ ellipsisFrames = []string{".", "..", "...", ""}
56+)
57+
58+// Internal ID management. Used during animating to ensure that frame messages
59+// are received only by spinner components that sent them.
60+var lastID int64
61+
62+func nextID() int {
63+ return int(atomic.AddInt64(&lastID, 1))
64+}
65+
66+// Cache for expensive animation calculations
67+type animCache struct {
68+ initialFrames [][]string
69+ cyclingFrames [][]string
70+ width int
71+ labelWidth int
72+ label []string
73+ ellipsisFrames []string
74+}
75+
76+var animCacheMap = csync.NewMap[string, *animCache]()
77+
78+// settingsHash creates a hash key for the settings to use for caching
79+func settingsHash(opts Settings) string {
80+ h := xxh3.New()
81+ fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
82+ opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
83+ return fmt.Sprintf("%x", h.Sum(nil))
84+}
85+
86+// StepMsg is a message type used to trigger the next step in the animation.
87+type StepMsg struct{ ID string }
88+
89+// Settings defines settings for the animation.
90+type Settings struct {
91+ ID string
92+ Size int
93+ Label string
94+ LabelColor color.Color
95+ GradColorA color.Color
96+ GradColorB color.Color
97+ CycleColors bool
98+}
99+
100+// Default settings.
101+const ()
102+
103+// Anim is a Bubble for an animated spinner.
104+type Anim struct {
105+ width int
106+ cyclingCharWidth int
107+ label *csync.Slice[string]
108+ labelWidth int
109+ labelColor color.Color
110+ startTime time.Time
111+ birthOffsets []time.Duration
112+ initialFrames [][]string // frames for the initial characters
113+ initialized atomic.Bool
114+ cyclingFrames [][]string // frames for the cycling characters
115+ step atomic.Int64 // current main frame step
116+ ellipsisStep atomic.Int64 // current ellipsis frame step
117+ ellipsisFrames *csync.Slice[string] // ellipsis animation frames
118+ id string
119+}
120+
121+// New creates a new Anim instance with the specified width and label.
122+func New(opts Settings) *Anim {
123+ a := &Anim{}
124+ // Validate settings.
125+ if opts.Size < 1 {
126+ opts.Size = defaultNumCyclingChars
127+ }
128+ if colorIsUnset(opts.GradColorA) {
129+ opts.GradColorA = defaultGradColorA
130+ }
131+ if colorIsUnset(opts.GradColorB) {
132+ opts.GradColorB = defaultGradColorB
133+ }
134+ if colorIsUnset(opts.LabelColor) {
135+ opts.LabelColor = defaultLabelColor
136+ }
137+
138+ if opts.ID != "" {
139+ a.id = opts.ID
140+ } else {
141+ a.id = fmt.Sprintf("%d", nextID())
142+ }
143+ a.startTime = time.Now()
144+ a.cyclingCharWidth = opts.Size
145+ a.labelColor = opts.LabelColor
146+
147+ // Check cache first
148+ cacheKey := settingsHash(opts)
149+ cached, exists := animCacheMap.Get(cacheKey)
150+
151+ if exists {
152+ // Use cached values
153+ a.width = cached.width
154+ a.labelWidth = cached.labelWidth
155+ a.label = csync.NewSliceFrom(cached.label)
156+ a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
157+ a.initialFrames = cached.initialFrames
158+ a.cyclingFrames = cached.cyclingFrames
159+ } else {
160+ // Generate new values and cache them
161+ a.labelWidth = lipgloss.Width(opts.Label)
162+
163+ // Total width of anim, in cells.
164+ a.width = opts.Size
165+ if opts.Label != "" {
166+ a.width += labelGapWidth + lipgloss.Width(opts.Label)
167+ }
168+
169+ // Render the label
170+ a.renderLabel(opts.Label)
171+
172+ // Pre-generate gradient.
173+ var ramp []color.Color
174+ numFrames := prerenderedFrames
175+ if opts.CycleColors {
176+ ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
177+ numFrames = a.width * 2
178+ } else {
179+ ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
180+ }
181+
182+ // Pre-render initial characters.
183+ a.initialFrames = make([][]string, numFrames)
184+ offset := 0
185+ for i := range a.initialFrames {
186+ a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
187+ for j := range a.initialFrames[i] {
188+ if j+offset >= len(ramp) {
189+ continue // skip if we run out of colors
190+ }
191+
192+ var c color.Color
193+ if j <= a.cyclingCharWidth {
194+ c = ramp[j+offset]
195+ } else {
196+ c = opts.LabelColor
197+ }
198+
199+ // Also prerender the initial character with Lip Gloss to avoid
200+ // processing in the render loop.
201+ a.initialFrames[i][j] = lipgloss.NewStyle().
202+ Foreground(c).
203+ Render(string(initialChar))
204+ }
205+ if opts.CycleColors {
206+ offset++
207+ }
208+ }
209+
210+ // Prerender scrambled rune frames for the animation.
211+ a.cyclingFrames = make([][]string, numFrames)
212+ offset = 0
213+ for i := range a.cyclingFrames {
214+ a.cyclingFrames[i] = make([]string, a.width)
215+ for j := range a.cyclingFrames[i] {
216+ if j+offset >= len(ramp) {
217+ continue // skip if we run out of colors
218+ }
219+
220+ // Also prerender the color with Lip Gloss here to avoid processing
221+ // in the render loop.
222+ r := availableRunes[rand.IntN(len(availableRunes))]
223+ a.cyclingFrames[i][j] = lipgloss.NewStyle().
224+ Foreground(ramp[j+offset]).
225+ Render(string(r))
226+ }
227+ if opts.CycleColors {
228+ offset++
229+ }
230+ }
231+
232+ // Cache the results
233+ labelSlice := make([]string, a.label.Len())
234+ for i, v := range a.label.Seq2() {
235+ labelSlice[i] = v
236+ }
237+ ellipsisSlice := make([]string, a.ellipsisFrames.Len())
238+ for i, v := range a.ellipsisFrames.Seq2() {
239+ ellipsisSlice[i] = v
240+ }
241+ cached = &animCache{
242+ initialFrames: a.initialFrames,
243+ cyclingFrames: a.cyclingFrames,
244+ width: a.width,
245+ labelWidth: a.labelWidth,
246+ label: labelSlice,
247+ ellipsisFrames: ellipsisSlice,
248+ }
249+ animCacheMap.Set(cacheKey, cached)
250+ }
251+
252+ // Random assign a birth to each character for a stagged entrance effect.
253+ a.birthOffsets = make([]time.Duration, a.width)
254+ for i := range a.birthOffsets {
255+ a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
256+ }
257+
258+ return a
259+}
260+
261+// SetLabel updates the label text and re-renders it.
262+func (a *Anim) SetLabel(newLabel string) {
263+ a.labelWidth = lipgloss.Width(newLabel)
264+
265+ // Update total width
266+ a.width = a.cyclingCharWidth
267+ if newLabel != "" {
268+ a.width += labelGapWidth + a.labelWidth
269+ }
270+
271+ // Re-render the label
272+ a.renderLabel(newLabel)
273+}
274+
275+// renderLabel renders the label with the current label color.
276+func (a *Anim) renderLabel(label string) {
277+ if a.labelWidth > 0 {
278+ // Pre-render the label.
279+ labelRunes := []rune(label)
280+ a.label = csync.NewSlice[string]()
281+ for i := range labelRunes {
282+ rendered := lipgloss.NewStyle().
283+ Foreground(a.labelColor).
284+ Render(string(labelRunes[i]))
285+ a.label.Append(rendered)
286+ }
287+
288+ // Pre-render the ellipsis frames which come after the label.
289+ a.ellipsisFrames = csync.NewSlice[string]()
290+ for _, frame := range ellipsisFrames {
291+ rendered := lipgloss.NewStyle().
292+ Foreground(a.labelColor).
293+ Render(frame)
294+ a.ellipsisFrames.Append(rendered)
295+ }
296+ } else {
297+ a.label = csync.NewSlice[string]()
298+ a.ellipsisFrames = csync.NewSlice[string]()
299+ }
300+}
301+
302+// Width returns the total width of the animation.
303+func (a *Anim) Width() (w int) {
304+ w = a.width
305+ if a.labelWidth > 0 {
306+ w += labelGapWidth + a.labelWidth
307+
308+ var widestEllipsisFrame int
309+ for _, f := range ellipsisFrames {
310+ fw := lipgloss.Width(f)
311+ if fw > widestEllipsisFrame {
312+ widestEllipsisFrame = fw
313+ }
314+ }
315+ w += widestEllipsisFrame
316+ }
317+ return w
318+}
319+
320+// Start starts the animation.
321+func (a *Anim) Start() tea.Cmd {
322+ return a.Step()
323+}
324+
325+// Animate advances the animation to the next step.
326+func (a *Anim) Animate(msg StepMsg) tea.Cmd {
327+ if msg.ID != a.id {
328+ return nil
329+ }
330+
331+ step := a.step.Add(1)
332+ if int(step) >= len(a.cyclingFrames) {
333+ a.step.Store(0)
334+ }
335+
336+ if a.initialized.Load() && a.labelWidth > 0 {
337+ // Manage the ellipsis animation.
338+ ellipsisStep := a.ellipsisStep.Add(1)
339+ if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
340+ a.ellipsisStep.Store(0)
341+ }
342+ } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
343+ a.initialized.Store(true)
344+ }
345+ return a.Step()
346+}
347+
348+// Render renders the current state of the animation.
349+func (a *Anim) Render() string {
350+ var b strings.Builder
351+ step := int(a.step.Load())
352+ for i := range a.width {
353+ switch {
354+ case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
355+ // Birth offset not reached: render initial character.
356+ b.WriteString(a.initialFrames[step][i])
357+ case i < a.cyclingCharWidth:
358+ // Render a cycling character.
359+ b.WriteString(a.cyclingFrames[step][i])
360+ case i == a.cyclingCharWidth:
361+ // Render label gap.
362+ b.WriteString(labelGap)
363+ case i > a.cyclingCharWidth:
364+ // Label.
365+ if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
366+ b.WriteString(labelChar)
367+ }
368+ }
369+ }
370+ // Render animated ellipsis at the end of the label if all characters
371+ // have been initialized.
372+ if a.initialized.Load() && a.labelWidth > 0 {
373+ ellipsisStep := int(a.ellipsisStep.Load())
374+ if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
375+ b.WriteString(ellipsisFrame)
376+ }
377+ }
378+
379+ return b.String()
380+}
381+
382+// Step is a command that triggers the next step in the animation.
383+func (a *Anim) Step() tea.Cmd {
384+ return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
385+ return StepMsg{ID: a.id}
386+ })
387+}
388+
389+// makeGradientRamp() returns a slice of colors blended between the given keys.
390+// Blending is done as Hcl to stay in gamut.
391+func makeGradientRamp(size int, stops ...color.Color) []color.Color {
392+ if len(stops) < 2 {
393+ return nil
394+ }
395+
396+ points := make([]colorful.Color, len(stops))
397+ for i, k := range stops {
398+ points[i], _ = colorful.MakeColor(k)
399+ }
400+
401+ numSegments := len(stops) - 1
402+ if numSegments == 0 {
403+ return nil
404+ }
405+ blended := make([]color.Color, 0, size)
406+
407+ // Calculate how many colors each segment should have.
408+ segmentSizes := make([]int, numSegments)
409+ baseSize := size / numSegments
410+ remainder := size % numSegments
411+
412+ // Distribute the remainder across segments.
413+ for i := range numSegments {
414+ segmentSizes[i] = baseSize
415+ if i < remainder {
416+ segmentSizes[i]++
417+ }
418+ }
419+
420+ // Generate colors for each segment.
421+ for i := range numSegments {
422+ c1 := points[i]
423+ c2 := points[i+1]
424+ segmentSize := segmentSizes[i]
425+
426+ for j := range segmentSize {
427+ if segmentSize == 0 {
428+ continue
429+ }
430+ t := float64(j) / float64(segmentSize)
431+ c := c1.BlendHcl(c2, t)
432+ blended = append(blended, c)
433+ }
434+ }
435+
436+ return blended
437+}
438+
439+func colorIsUnset(c color.Color) bool {
440+ if c == nil {
441+ return true
442+ }
443+ _, _, _, a := c.RGBA()
444+ return a == 0
445+}
internal/ui/attachments/attachments.gonew file+135
@@ -0,0 +1,135 @@
1+package attachments
2+
3+import (
4+ "fmt"
5+ "math"
6+ "path/filepath"
7+ "slices"
8+ "strings"
9+
10+ "charm.land/bubbles/v2/key"
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/lipgloss/v2"
13+ "github.com/charmbracelet/crush/internal/message"
14+ "github.com/charmbracelet/x/ansi"
15+)
16+
17+const maxFilename = 15
18+
19+type Keymap struct {
20+ DeleteMode,
21+ DeleteAll,
22+ Escape key.Binding
23+}
24+
25+func New(renderer *Renderer, keyMap Keymap) *Attachments {
26+ return &Attachments{
27+ keyMap: keyMap,
28+ renderer: renderer,
29+ }
30+}
31+
32+type Attachments struct {
33+ renderer *Renderer
34+ keyMap Keymap
35+ list []message.Attachment
36+ deleting bool
37+}
38+
39+func (m *Attachments) List() []message.Attachment { return m.list }
40+func (m *Attachments) Reset() { m.list = nil }
41+
42+func (m *Attachments) Update(msg tea.Msg) bool {
43+ switch msg := msg.(type) {
44+ case message.Attachment:
45+ m.list = append(m.list, msg)
46+ return true
47+ case tea.KeyPressMsg:
48+ switch {
49+ case key.Matches(msg, m.keyMap.DeleteMode):
50+ if len(m.list) > 0 {
51+ m.deleting = true
52+ }
53+ return true
54+ case m.deleting && key.Matches(msg, m.keyMap.Escape):
55+ m.deleting = false
56+ return true
57+ case m.deleting && key.Matches(msg, m.keyMap.DeleteAll):
58+ m.deleting = false
59+ m.list = nil
60+ return true
61+ case m.deleting:
62+ // Handle digit keys for individual attachment deletion.
63+ r := msg.Code
64+ if r >= '0' && r <= '9' {
65+ num := int(r - '0')
66+ if num < len(m.list) {
67+ m.list = slices.Delete(m.list, num, num+1)
68+ }
69+ m.deleting = false
70+ }
71+ return true
72+ }
73+ }
74+ return false
75+}
76+
77+func (m *Attachments) Render(width int) string {
78+ return m.renderer.Render(m.list, m.deleting, width)
79+}
80+
81+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
82+ return &Renderer{
83+ normalStyle: normalStyle,
84+ textStyle: textStyle,
85+ imageStyle: imageStyle,
86+ deletingStyle: deletingStyle,
87+ }
88+}
89+
90+type Renderer struct {
91+ normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
92+}
93+
94+func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
95+ var chips []string
96+
97+ maxItemWidth := lipgloss.Width(r.imageStyle.String() + r.normalStyle.Render(strings.Repeat("x", maxFilename)))
98+ fits := int(math.Floor(float64(width)/float64(maxItemWidth))) - 1
99+
100+ for i, att := range attachments {
101+ filename := filepath.Base(att.FileName)
102+ // Truncate if needed.
103+ if ansi.StringWidth(filename) > maxFilename {
104+ filename = ansi.Truncate(filename, maxFilename, "…")
105+ }
106+
107+ if deleting {
108+ chips = append(
109+ chips,
110+ r.deletingStyle.Render(fmt.Sprintf("%d", i)),
111+ r.normalStyle.Render(filename),
112+ )
113+ } else {
114+ chips = append(
115+ chips,
116+ r.icon(att).String(),
117+ r.normalStyle.Render(filename),
118+ )
119+ }
120+
121+ if i == fits && len(attachments) > i {
122+ chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits)))
123+ break
124+ }
125+ }
126+
127+ return lipgloss.JoinHorizontal(lipgloss.Left, chips...)
128+}
129+
130+func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
131+ if a.IsImage() {
132+ return r.imageStyle
133+ }
134+ return r.textStyle
135+}
internal/ui/chat/agent.gonew file+302
@@ -0,0 +1,302 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+ "charm.land/lipgloss/v2/tree"
10+ "github.com/charmbracelet/crush/internal/agent"
11+ "github.com/charmbracelet/crush/internal/message"
12+ "github.com/charmbracelet/crush/internal/ui/anim"
13+ "github.com/charmbracelet/crush/internal/ui/styles"
14+)
15+
16+// -----------------------------------------------------------------------------
17+// Agent Tool
18+// -----------------------------------------------------------------------------
19+
20+// NestedToolContainer is an interface for tool items that can contain nested tool calls.
21+type NestedToolContainer interface {
22+ NestedTools() []ToolMessageItem
23+ SetNestedTools(tools []ToolMessageItem)
24+ AddNestedTool(tool ToolMessageItem)
25+}
26+
27+// AgentToolMessageItem is a message item that represents an agent tool call.
28+type AgentToolMessageItem struct {
29+ *baseToolMessageItem
30+
31+ nestedTools []ToolMessageItem
32+}
33+
34+var (
35+ _ ToolMessageItem = (*AgentToolMessageItem)(nil)
36+ _ NestedToolContainer = (*AgentToolMessageItem)(nil)
37+)
38+
39+// NewAgentToolMessageItem creates a new [AgentToolMessageItem].
40+func NewAgentToolMessageItem(
41+ sty *styles.Styles,
42+ toolCall message.ToolCall,
43+ result *message.ToolResult,
44+ canceled bool,
45+) *AgentToolMessageItem {
46+ t := &AgentToolMessageItem{}
47+ t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgentToolRenderContext{agent: t}, canceled)
48+ // For the agent tool we keep spinning until the tool call is finished.
49+ t.spinningFunc = func(state SpinningState) bool {
50+ return !state.HasResult() && !state.IsCanceled()
51+ }
52+ return t
53+}
54+
55+// Animate progresses the message animation if it should be spinning.
56+func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
57+ if a.result != nil || a.Status() == ToolStatusCanceled {
58+ return nil
59+ }
60+ if msg.ID == a.ID() {
61+ return a.anim.Animate(msg)
62+ }
63+ for _, nestedTool := range a.nestedTools {
64+ if msg.ID != nestedTool.ID() {
65+ continue
66+ }
67+ if s, ok := nestedTool.(Animatable); ok {
68+ return s.Animate(msg)
69+ }
70+ }
71+ return nil
72+}
73+
74+// NestedTools returns the nested tools.
75+func (a *AgentToolMessageItem) NestedTools() []ToolMessageItem {
76+ return a.nestedTools
77+}
78+
79+// SetNestedTools sets the nested tools.
80+func (a *AgentToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
81+ a.nestedTools = tools
82+ a.clearCache()
83+}
84+
85+// AddNestedTool adds a nested tool.
86+func (a *AgentToolMessageItem) AddNestedTool(tool ToolMessageItem) {
87+ // Mark nested tools as simple (compact) rendering.
88+ if s, ok := tool.(Compactable); ok {
89+ s.SetCompact(true)
90+ }
91+ a.nestedTools = append(a.nestedTools, tool)
92+ a.clearCache()
93+}
94+
95+// AgentToolRenderContext renders agent tool messages.
96+type AgentToolRenderContext struct {
97+ agent *AgentToolMessageItem
98+}
99+
100+// RenderTool implements the [ToolRenderer] interface.
101+func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
102+ cappedWidth := cappedMessageWidth(width)
103+ if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 {
104+ return pendingTool(sty, "Agent", opts.Anim)
105+ }
106+
107+ var params agent.AgentParams
108+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
109+
110+ prompt := params.Prompt
111+ prompt = strings.ReplaceAll(prompt, "\n", " ")
112+
113+ header := toolHeader(sty, opts.Status, "Agent", cappedWidth, opts.Compact)
114+ if opts.Compact {
115+ return header
116+ }
117+
118+ // Build the task tag and prompt.
119+ taskTag := sty.Tool.AgentTaskTag.Render("Task")
120+ taskTagWidth := lipgloss.Width(taskTag)
121+
122+ // Calculate remaining width for prompt.
123+ remainingWidth := min(cappedWidth-taskTagWidth-3, maxTextWidth-taskTagWidth-3) // -3 for spacing
124+
125+ promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
126+
127+ header = lipgloss.JoinVertical(
128+ lipgloss.Left,
129+ header,
130+ "",
131+ lipgloss.JoinHorizontal(
132+ lipgloss.Left,
133+ taskTag,
134+ " ",
135+ promptText,
136+ ),
137+ )
138+
139+ // Build tree with nested tool calls.
140+ childTools := tree.Root(header)
141+
142+ for _, nestedTool := range r.agent.nestedTools {
143+ childView := nestedTool.Render(remainingWidth)
144+ childTools.Child(childView)
145+ }
146+
147+ // Build parts.
148+ var parts []string
149+ parts = append(parts, childTools.Enumerator(roundedEnumerator(2, taskTagWidth-5)).String())
150+
151+ // Show animation if still running.
152+ if !opts.HasResult() && !opts.IsCanceled() {
153+ parts = append(parts, "", opts.Anim.Render())
154+ }
155+
156+ result := lipgloss.JoinVertical(lipgloss.Left, parts...)
157+
158+ // Add body content when completed.
159+ if opts.HasResult() && opts.Result.Content != "" {
160+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
161+ return joinToolParts(result, body)
162+ }
163+
164+ return result
165+}
166+
167+// -----------------------------------------------------------------------------
168+// Agentic Fetch Tool
169+// -----------------------------------------------------------------------------
170+
171+// AgenticFetchToolMessageItem is a message item that represents an agentic fetch tool call.
172+type AgenticFetchToolMessageItem struct {
173+ *baseToolMessageItem
174+
175+ nestedTools []ToolMessageItem
176+}
177+
178+var (
179+ _ ToolMessageItem = (*AgenticFetchToolMessageItem)(nil)
180+ _ NestedToolContainer = (*AgenticFetchToolMessageItem)(nil)
181+)
182+
183+// NewAgenticFetchToolMessageItem creates a new [AgenticFetchToolMessageItem].
184+func NewAgenticFetchToolMessageItem(
185+ sty *styles.Styles,
186+ toolCall message.ToolCall,
187+ result *message.ToolResult,
188+ canceled bool,
189+) *AgenticFetchToolMessageItem {
190+ t := &AgenticFetchToolMessageItem{}
191+ t.baseToolMessageItem = newBaseToolMessageItem(sty, toolCall, result, &AgenticFetchToolRenderContext{fetch: t}, canceled)
192+ // For the agentic fetch tool we keep spinning until the tool call is finished.
193+ t.spinningFunc = func(state SpinningState) bool {
194+ return !state.HasResult() && !state.IsCanceled()
195+ }
196+ return t
197+}
198+
199+// NestedTools returns the nested tools.
200+func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
201+ return a.nestedTools
202+}
203+
204+// SetNestedTools sets the nested tools.
205+func (a *AgenticFetchToolMessageItem) SetNestedTools(tools []ToolMessageItem) {
206+ a.nestedTools = tools
207+ a.clearCache()
208+}
209+
210+// AddNestedTool adds a nested tool.
211+func (a *AgenticFetchToolMessageItem) AddNestedTool(tool ToolMessageItem) {
212+ // Mark nested tools as simple (compact) rendering.
213+ if s, ok := tool.(Compactable); ok {
214+ s.SetCompact(true)
215+ }
216+ a.nestedTools = append(a.nestedTools, tool)
217+ a.clearCache()
218+}
219+
220+// AgenticFetchToolRenderContext renders agentic fetch tool messages.
221+type AgenticFetchToolRenderContext struct {
222+ fetch *AgenticFetchToolMessageItem
223+}
224+
225+// agenticFetchParams matches tools.AgenticFetchParams.
226+type agenticFetchParams struct {
227+ URL string `json:"url,omitempty"`
228+ Prompt string `json:"prompt"`
229+}
230+
231+// RenderTool implements the [ToolRenderer] interface.
232+func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
233+ cappedWidth := cappedMessageWidth(width)
234+ if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 {
235+ return pendingTool(sty, "Agentic Fetch", opts.Anim)
236+ }
237+
238+ var params agenticFetchParams
239+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
240+
241+ prompt := params.Prompt
242+ prompt = strings.ReplaceAll(prompt, "\n", " ")
243+
244+ // Build header with optional URL param.
245+ toolParams := []string{}
246+ if params.URL != "" {
247+ toolParams = append(toolParams, params.URL)
248+ }
249+
250+ header := toolHeader(sty, opts.Status, "Agentic Fetch", cappedWidth, opts.Compact, toolParams...)
251+ if opts.Compact {
252+ return header
253+ }
254+
255+ // Build the prompt tag.
256+ promptTag := sty.Tool.AgenticFetchPromptTag.Render("Prompt")
257+ promptTagWidth := lipgloss.Width(promptTag)
258+
259+ // Calculate remaining width for prompt text.
260+ remainingWidth := min(cappedWidth-promptTagWidth-3, maxTextWidth-promptTagWidth-3) // -3 for spacing
261+
262+ promptText := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
263+
264+ header = lipgloss.JoinVertical(
265+ lipgloss.Left,
266+ header,
267+ "",
268+ lipgloss.JoinHorizontal(
269+ lipgloss.Left,
270+ promptTag,
271+ " ",
272+ promptText,
273+ ),
274+ )
275+
276+ // Build tree with nested tool calls.
277+ childTools := tree.Root(header)
278+
279+ for _, nestedTool := range r.fetch.nestedTools {
280+ childView := nestedTool.Render(remainingWidth)
281+ childTools.Child(childView)
282+ }
283+
284+ // Build parts.
285+ var parts []string
286+ parts = append(parts, childTools.Enumerator(roundedEnumerator(2, promptTagWidth-5)).String())
287+
288+ // Show animation if still running.
289+ if !opts.HasResult() && !opts.IsCanceled() {
290+ parts = append(parts, "", opts.Anim.Render())
291+ }
292+
293+ result := lipgloss.JoinVertical(lipgloss.Left, parts...)
294+
295+ // Add body content when completed.
296+ if opts.HasResult() && opts.Result.Content != "" {
297+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth-toolBodyLeftPaddingTotal, opts.ExpandedContent)
298+ return joinToolParts(result, body)
299+ }
300+
301+ return result
302+}
internal/ui/chat/assistant.gonew file+257
@@ -0,0 +1,257 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+ "github.com/charmbracelet/crush/internal/message"
10+ "github.com/charmbracelet/crush/internal/ui/anim"
11+ "github.com/charmbracelet/crush/internal/ui/common"
12+ "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/x/ansi"
14+)
15+
16+// assistantMessageTruncateFormat is the text shown when an assistant message is
17+// truncated.
18+const assistantMessageTruncateFormat = "… (%d lines hidden) [click or space to expand]"
19+
20+// maxCollapsedThinkingHeight defines the maximum height of the thinking
21+const maxCollapsedThinkingHeight = 10
22+
23+// AssistantMessageItem represents an assistant message in the chat UI.
24+//
25+// This item includes thinking, and the content but does not include the tool calls.
26+type AssistantMessageItem struct {
27+ *highlightableMessageItem
28+ *cachedMessageItem
29+ *focusableMessageItem
30+
31+ message *message.Message
32+ sty *styles.Styles
33+ anim *anim.Anim
34+ thinkingExpanded bool
35+ thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
36+}
37+
38+// NewAssistantMessageItem creates a new AssistantMessageItem.
39+func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
40+ a := &AssistantMessageItem{
41+ highlightableMessageItem: defaultHighlighter(sty),
42+ cachedMessageItem: &cachedMessageItem{},
43+ focusableMessageItem: &focusableMessageItem{},
44+ message: message,
45+ sty: sty,
46+ }
47+
48+ a.anim = anim.New(anim.Settings{
49+ ID: a.ID(),
50+ Size: 15,
51+ GradColorA: sty.Primary,
52+ GradColorB: sty.Secondary,
53+ LabelColor: sty.FgBase,
54+ CycleColors: true,
55+ })
56+ return a
57+}
58+
59+// StartAnimation starts the assistant message animation if it should be spinning.
60+func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
61+ if !a.isSpinning() {
62+ return nil
63+ }
64+ return a.anim.Start()
65+}
66+
67+// Animate progresses the assistant message animation if it should be spinning.
68+func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
69+ if !a.isSpinning() {
70+ return nil
71+ }
72+ return a.anim.Animate(msg)
73+}
74+
75+// ID implements MessageItem.
76+func (a *AssistantMessageItem) ID() string {
77+ return a.message.ID
78+}
79+
80+// RawRender implements [MessageItem].
81+func (a *AssistantMessageItem) RawRender(width int) string {
82+ cappedWidth := cappedMessageWidth(width)
83+
84+ var spinner string
85+ if a.isSpinning() {
86+ spinner = a.renderSpinning()
87+ }
88+
89+ content, height, ok := a.getCachedRender(cappedWidth)
90+ if !ok {
91+ content = a.renderMessageContent(cappedWidth)
92+ height = lipgloss.Height(content)
93+ // cache the rendered content
94+ a.setCachedRender(content, cappedWidth, height)
95+ }
96+
97+ highlightedContent := a.renderHighlighted(content, cappedWidth, height)
98+ if spinner != "" {
99+ if highlightedContent != "" {
100+ highlightedContent += "\n\n"
101+ }
102+ return highlightedContent + spinner
103+ }
104+
105+ return highlightedContent
106+}
107+
108+// Render implements MessageItem.
109+func (a *AssistantMessageItem) Render(width int) string {
110+ style := a.sty.Chat.Message.AssistantBlurred
111+ if a.focused {
112+ style = a.sty.Chat.Message.AssistantFocused
113+ }
114+ return style.Render(a.RawRender(width))
115+}
116+
117+// renderMessageContent renders the message content including thinking, main content, and finish reason.
118+func (a *AssistantMessageItem) renderMessageContent(width int) string {
119+ var messageParts []string
120+ thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
121+ content := strings.TrimSpace(a.message.Content().Text)
122+ // if the massage has reasoning content add that first
123+ if thinking != "" {
124+ messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
125+ }
126+
127+ // then add the main content
128+ if content != "" {
129+ // add a spacer between thinking and content
130+ if thinking != "" {
131+ messageParts = append(messageParts, "")
132+ }
133+ messageParts = append(messageParts, a.renderMarkdown(content, width))
134+ }
135+
136+ // finally add any finish reason info
137+ if a.message.IsFinished() {
138+ switch a.message.FinishReason() {
139+ case message.FinishReasonCanceled:
140+ messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
141+ case message.FinishReasonError:
142+ messageParts = append(messageParts, a.renderError(width))
143+ }
144+ }
145+
146+ return strings.Join(messageParts, "\n")
147+}
148+
149+// renderThinking renders the thinking/reasoning content with footer.
150+func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
151+ renderer := common.PlainMarkdownRenderer(a.sty, width)
152+ rendered, err := renderer.Render(thinking)
153+ if err != nil {
154+ rendered = thinking
155+ }
156+ rendered = strings.TrimSpace(rendered)
157+
158+ lines := strings.Split(rendered, "\n")
159+ totalLines := len(lines)
160+
161+ isTruncated := totalLines > maxCollapsedThinkingHeight
162+ if !a.thinkingExpanded && isTruncated {
163+ lines = lines[totalLines-maxCollapsedThinkingHeight:]
164+ hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
165+ fmt.Sprintf(assistantMessageTruncateFormat, totalLines-maxCollapsedThinkingHeight),
166+ )
167+ lines = append([]string{hint, ""}, lines...)
168+ }
169+
170+ thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
171+ result := thinkingStyle.Render(strings.Join(lines, "\n"))
172+ a.thinkingBoxHeight = lipgloss.Height(result)
173+
174+ var footer string
175+ // if thinking is done add the thought for footer
176+ if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
177+ duration := a.message.ThinkingDuration()
178+ if duration.String() != "0s" {
179+ footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
180+ a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
181+ }
182+ }
183+
184+ if footer != "" {
185+ result += "\n\n" + footer
186+ }
187+
188+ return result
189+}
190+
191+// renderMarkdown renders content as markdown.
192+func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
193+ renderer := common.MarkdownRenderer(a.sty, width)
194+ result, err := renderer.Render(content)
195+ if err != nil {
196+ return content
197+ }
198+ return strings.TrimSuffix(result, "\n")
199+}
200+
201+func (a *AssistantMessageItem) renderSpinning() string {
202+ if a.message.IsThinking() {
203+ a.anim.SetLabel("Thinking")
204+ } else if a.message.IsSummaryMessage {
205+ a.anim.SetLabel("Summarizing")
206+ }
207+ return a.anim.Render()
208+}
209+
210+// renderError renders an error message.
211+func (a *AssistantMessageItem) renderError(width int) string {
212+ finishPart := a.message.FinishPart()
213+ errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
214+ truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
215+ title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
216+ details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
217+ return fmt.Sprintf("%s\n\n%s", title, details)
218+}
219+
220+// isSpinning returns true if the assistant message is still generating.
221+func (a *AssistantMessageItem) isSpinning() bool {
222+ isThinking := a.message.IsThinking()
223+ isFinished := a.message.IsFinished()
224+ hasContent := strings.TrimSpace(a.message.Content().Text) != ""
225+ hasToolCalls := len(a.message.ToolCalls()) > 0
226+ return (isThinking || !isFinished) && !hasContent && !hasToolCalls
227+}
228+
229+// SetMessage is used to update the underlying message.
230+func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
231+ wasSpinning := a.isSpinning()
232+ a.message = message
233+ a.clearCache()
234+ if !wasSpinning && a.isSpinning() {
235+ return a.StartAnimation()
236+ }
237+ return nil
238+}
239+
240+// ToggleExpanded toggles the expanded state of the thinking box.
241+func (a *AssistantMessageItem) ToggleExpanded() {
242+ a.thinkingExpanded = !a.thinkingExpanded
243+ a.clearCache()
244+}
245+
246+// HandleMouseClick implements MouseClickable.
247+func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
248+ if btn != ansi.MouseLeft {
249+ return false
250+ }
251+ // check if the click is within the thinking box
252+ if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight {
253+ a.ToggleExpanded()
254+ return true
255+ }
256+ return false
257+}
internal/ui/chat/bash.gonew file+248
@@ -0,0 +1,248 @@
1+package chat
2+
3+import (
4+ "cmp"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "charm.land/lipgloss/v2"
10+ "github.com/charmbracelet/crush/internal/agent/tools"
11+ "github.com/charmbracelet/crush/internal/message"
12+ "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/x/ansi"
14+)
15+
16+// -----------------------------------------------------------------------------
17+// Bash Tool
18+// -----------------------------------------------------------------------------
19+
20+// BashToolMessageItem is a message item that represents a bash tool call.
21+type BashToolMessageItem struct {
22+ *baseToolMessageItem
23+}
24+
25+var _ ToolMessageItem = (*BashToolMessageItem)(nil)
26+
27+// NewBashToolMessageItem creates a new [BashToolMessageItem].
28+func NewBashToolMessageItem(
29+ sty *styles.Styles,
30+ toolCall message.ToolCall,
31+ result *message.ToolResult,
32+ canceled bool,
33+) ToolMessageItem {
34+ return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
35+}
36+
37+// BashToolRenderContext renders bash tool messages.
38+type BashToolRenderContext struct{}
39+
40+// RenderTool implements the [ToolRenderer] interface.
41+func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
42+ cappedWidth := cappedMessageWidth(width)
43+ if opts.IsPending() {
44+ return pendingTool(sty, "Bash", opts.Anim)
45+ }
46+
47+ var params tools.BashParams
48+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
49+ params.Command = "failed to parse command"
50+ }
51+
52+ // Check if this is a background job.
53+ var meta tools.BashResponseMetadata
54+ if opts.HasResult() {
55+ _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
56+ }
57+
58+ if meta.Background {
59+ description := cmp.Or(meta.Description, params.Command)
60+ content := "Command: " + params.Command + "\n" + opts.Result.Content
61+ return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content)
62+ }
63+
64+ // Regular bash command.
65+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
66+ cmd = strings.ReplaceAll(cmd, "\t", " ")
67+ toolParams := []string{cmd}
68+ if params.RunInBackground {
69+ toolParams = append(toolParams, "background", "true")
70+ }
71+
72+ header := toolHeader(sty, opts.Status, "Bash", cappedWidth, opts.Compact, toolParams...)
73+ if opts.Compact {
74+ return header
75+ }
76+
77+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
78+ return joinToolParts(header, earlyState)
79+ }
80+
81+ if !opts.HasResult() {
82+ return header
83+ }
84+
85+ output := meta.Output
86+ if output == "" && opts.Result.Content != tools.BashNoOutput {
87+ output = opts.Result.Content
88+ }
89+ if output == "" {
90+ return header
91+ }
92+
93+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
94+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.ExpandedContent))
95+ return joinToolParts(header, body)
96+}
97+
98+// -----------------------------------------------------------------------------
99+// Job Output Tool
100+// -----------------------------------------------------------------------------
101+
102+// JobOutputToolMessageItem is a message item for job_output tool calls.
103+type JobOutputToolMessageItem struct {
104+ *baseToolMessageItem
105+}
106+
107+var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil)
108+
109+// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem].
110+func NewJobOutputToolMessageItem(
111+ sty *styles.Styles,
112+ toolCall message.ToolCall,
113+ result *message.ToolResult,
114+ canceled bool,
115+) ToolMessageItem {
116+ return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
117+}
118+
119+// JobOutputToolRenderContext renders job_output tool messages.
120+type JobOutputToolRenderContext struct{}
121+
122+// RenderTool implements the [ToolRenderer] interface.
123+func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
124+ cappedWidth := cappedMessageWidth(width)
125+ if opts.IsPending() {
126+ return pendingTool(sty, "Job", opts.Anim)
127+ }
128+
129+ var params tools.JobOutputParams
130+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
131+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
132+ }
133+
134+ var description string
135+ if opts.HasResult() && opts.Result.Metadata != "" {
136+ var meta tools.JobOutputResponseMetadata
137+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
138+ description = cmp.Or(meta.Description, meta.Command)
139+ }
140+ }
141+
142+ content := ""
143+ if opts.HasResult() {
144+ content = opts.Result.Content
145+ }
146+ return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content)
147+}
148+
149+// -----------------------------------------------------------------------------
150+// Job Kill Tool
151+// -----------------------------------------------------------------------------
152+
153+// JobKillToolMessageItem is a message item for job_kill tool calls.
154+type JobKillToolMessageItem struct {
155+ *baseToolMessageItem
156+}
157+
158+var _ ToolMessageItem = (*JobKillToolMessageItem)(nil)
159+
160+// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem].
161+func NewJobKillToolMessageItem(
162+ sty *styles.Styles,
163+ toolCall message.ToolCall,
164+ result *message.ToolResult,
165+ canceled bool,
166+) ToolMessageItem {
167+ return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
168+}
169+
170+// JobKillToolRenderContext renders job_kill tool messages.
171+type JobKillToolRenderContext struct{}
172+
173+// RenderTool implements the [ToolRenderer] interface.
174+func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
175+ cappedWidth := cappedMessageWidth(width)
176+ if opts.IsPending() {
177+ return pendingTool(sty, "Job", opts.Anim)
178+ }
179+
180+ var params tools.JobKillParams
181+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
182+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
183+ }
184+
185+ var description string
186+ if opts.HasResult() && opts.Result.Metadata != "" {
187+ var meta tools.JobKillResponseMetadata
188+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
189+ description = cmp.Or(meta.Description, meta.Command)
190+ }
191+ }
192+
193+ content := ""
194+ if opts.HasResult() {
195+ content = opts.Result.Content
196+ }
197+ return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content)
198+}
199+
200+// renderJobTool renders a job-related tool with the common pattern:
201+// header → nested check → early state → body.
202+func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
203+ header := jobHeader(sty, opts.Status, action, shellID, description, width)
204+ if opts.Compact {
205+ return header
206+ }
207+
208+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
209+ return joinToolParts(header, earlyState)
210+ }
211+
212+ if content == "" {
213+ return header
214+ }
215+
216+ bodyWidth := width - toolBodyLeftPaddingTotal
217+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.ExpandedContent))
218+ return joinToolParts(header, body)
219+}
220+
221+// jobHeader builds a header for job-related tools.
222+// Format: "● Job (Action) PID shellID description..."
223+func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string {
224+ icon := toolIcon(sty, status)
225+ jobPart := sty.Tool.JobToolName.Render("Job")
226+ actionPart := sty.Tool.JobAction.Render("(" + action + ")")
227+ pidPart := sty.Tool.JobPID.Render("PID " + shellID)
228+
229+ prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
230+
231+ if description == "" {
232+ return prefix
233+ }
234+
235+ prefixWidth := lipgloss.Width(prefix)
236+ availableWidth := width - prefixWidth - 1
237+ if availableWidth < 10 {
238+ return prefix
239+ }
240+
241+ truncatedDesc := ansi.Truncate(description, availableWidth, "…")
242+ return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc)
243+}
244+
245+// joinToolParts joins header and body with a blank line separator.
246+func joinToolParts(header, body string) string {
247+ return strings.Join([]string{header, "", body}, "\n")
248+}
internal/ui/chat/diagnostics.gonew file+68
@@ -0,0 +1,68 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+
6+ "github.com/charmbracelet/crush/internal/agent/tools"
7+ "github.com/charmbracelet/crush/internal/fsext"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+)
11+
12+// -----------------------------------------------------------------------------
13+// Diagnostics Tool
14+// -----------------------------------------------------------------------------
15+
16+// DiagnosticsToolMessageItem is a message item that represents a diagnostics tool call.
17+type DiagnosticsToolMessageItem struct {
18+ *baseToolMessageItem
19+}
20+
21+var _ ToolMessageItem = (*DiagnosticsToolMessageItem)(nil)
22+
23+// NewDiagnosticsToolMessageItem creates a new [DiagnosticsToolMessageItem].
24+func NewDiagnosticsToolMessageItem(
25+ sty *styles.Styles,
26+ toolCall message.ToolCall,
27+ result *message.ToolResult,
28+ canceled bool,
29+) ToolMessageItem {
30+ return newBaseToolMessageItem(sty, toolCall, result, &DiagnosticsToolRenderContext{}, canceled)
31+}
32+
33+// DiagnosticsToolRenderContext renders diagnostics tool messages.
34+type DiagnosticsToolRenderContext struct{}
35+
36+// RenderTool implements the [ToolRenderer] interface.
37+func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
38+ cappedWidth := cappedMessageWidth(width)
39+ if opts.IsPending() {
40+ return pendingTool(sty, "Diagnostics", opts.Anim)
41+ }
42+
43+ var params tools.DiagnosticsParams
44+ _ = json.Unmarshal([]byte(opts.ToolCall.Input), &params)
45+
46+ // Show "project" if no file path, otherwise show the file path.
47+ mainParam := "project"
48+ if params.FilePath != "" {
49+ mainParam = fsext.PrettyPath(params.FilePath)
50+ }
51+
52+ header := toolHeader(sty, opts.Status, "Diagnostics", cappedWidth, opts.Compact, mainParam)
53+ if opts.Compact {
54+ return header
55+ }
56+
57+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
58+ return joinToolParts(header, earlyState)
59+ }
60+
61+ if opts.HasEmptyResult() {
62+ return header
63+ }
64+
65+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
66+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
67+ return joinToolParts(header, body)
68+}
internal/ui/chat/fetch.gonew file+192
@@ -0,0 +1,192 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+
6+ "github.com/charmbracelet/crush/internal/agent/tools"
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+)
10+
11+// -----------------------------------------------------------------------------
12+// Fetch Tool
13+// -----------------------------------------------------------------------------
14+
15+// FetchToolMessageItem is a message item that represents a fetch tool call.
16+type FetchToolMessageItem struct {
17+ *baseToolMessageItem
18+}
19+
20+var _ ToolMessageItem = (*FetchToolMessageItem)(nil)
21+
22+// NewFetchToolMessageItem creates a new [FetchToolMessageItem].
23+func NewFetchToolMessageItem(
24+ sty *styles.Styles,
25+ toolCall message.ToolCall,
26+ result *message.ToolResult,
27+ canceled bool,
28+) ToolMessageItem {
29+ return newBaseToolMessageItem(sty, toolCall, result, &FetchToolRenderContext{}, canceled)
30+}
31+
32+// FetchToolRenderContext renders fetch tool messages.
33+type FetchToolRenderContext struct{}
34+
35+// RenderTool implements the [ToolRenderer] interface.
36+func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
37+ cappedWidth := cappedMessageWidth(width)
38+ if opts.IsPending() {
39+ return pendingTool(sty, "Fetch", opts.Anim)
40+ }
41+
42+ var params tools.FetchParams
43+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
44+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
45+ }
46+
47+ toolParams := []string{params.URL}
48+ if params.Format != "" {
49+ toolParams = append(toolParams, "format", params.Format)
50+ }
51+ if params.Timeout != 0 {
52+ toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
53+ }
54+
55+ header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...)
56+ if opts.Compact {
57+ return header
58+ }
59+
60+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
61+ return joinToolParts(header, earlyState)
62+ }
63+
64+ if opts.HasEmptyResult() {
65+ return header
66+ }
67+
68+ // Determine file extension for syntax highlighting based on format.
69+ file := getFileExtensionForFormat(params.Format)
70+ body := toolOutputCodeContent(sty, file, opts.Result.Content, 0, cappedWidth, opts.ExpandedContent)
71+ return joinToolParts(header, body)
72+}
73+
74+// getFileExtensionForFormat returns a filename with appropriate extension for syntax highlighting.
75+func getFileExtensionForFormat(format string) string {
76+ switch format {
77+ case "text":
78+ return "fetch.txt"
79+ case "html":
80+ return "fetch.html"
81+ default:
82+ return "fetch.md"
83+ }
84+}
85+
86+// -----------------------------------------------------------------------------
87+// WebFetch Tool
88+// -----------------------------------------------------------------------------
89+
90+// WebFetchToolMessageItem is a message item that represents a web_fetch tool call.
91+type WebFetchToolMessageItem struct {
92+ *baseToolMessageItem
93+}
94+
95+var _ ToolMessageItem = (*WebFetchToolMessageItem)(nil)
96+
97+// NewWebFetchToolMessageItem creates a new [WebFetchToolMessageItem].
98+func NewWebFetchToolMessageItem(
99+ sty *styles.Styles,
100+ toolCall message.ToolCall,
101+ result *message.ToolResult,
102+ canceled bool,
103+) ToolMessageItem {
104+ return newBaseToolMessageItem(sty, toolCall, result, &WebFetchToolRenderContext{}, canceled)
105+}
106+
107+// WebFetchToolRenderContext renders web_fetch tool messages.
108+type WebFetchToolRenderContext struct{}
109+
110+// RenderTool implements the [ToolRenderer] interface.
111+func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
112+ cappedWidth := cappedMessageWidth(width)
113+ if opts.IsPending() {
114+ return pendingTool(sty, "Fetch", opts.Anim)
115+ }
116+
117+ var params tools.WebFetchParams
118+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
119+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
120+ }
121+
122+ toolParams := []string{params.URL}
123+ header := toolHeader(sty, opts.Status, "Fetch", cappedWidth, opts.Compact, toolParams...)
124+ if opts.Compact {
125+ return header
126+ }
127+
128+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
129+ return joinToolParts(header, earlyState)
130+ }
131+
132+ if opts.HasEmptyResult() {
133+ return header
134+ }
135+
136+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent)
137+ return joinToolParts(header, body)
138+}
139+
140+// -----------------------------------------------------------------------------
141+// WebSearch Tool
142+// -----------------------------------------------------------------------------
143+
144+// WebSearchToolMessageItem is a message item that represents a web_search tool call.
145+type WebSearchToolMessageItem struct {
146+ *baseToolMessageItem
147+}
148+
149+var _ ToolMessageItem = (*WebSearchToolMessageItem)(nil)
150+
151+// NewWebSearchToolMessageItem creates a new [WebSearchToolMessageItem].
152+func NewWebSearchToolMessageItem(
153+ sty *styles.Styles,
154+ toolCall message.ToolCall,
155+ result *message.ToolResult,
156+ canceled bool,
157+) ToolMessageItem {
158+ return newBaseToolMessageItem(sty, toolCall, result, &WebSearchToolRenderContext{}, canceled)
159+}
160+
161+// WebSearchToolRenderContext renders web_search tool messages.
162+type WebSearchToolRenderContext struct{}
163+
164+// RenderTool implements the [ToolRenderer] interface.
165+func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
166+ cappedWidth := cappedMessageWidth(width)
167+ if opts.IsPending() {
168+ return pendingTool(sty, "Search", opts.Anim)
169+ }
170+
171+ var params tools.WebSearchParams
172+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
173+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
174+ }
175+
176+ toolParams := []string{params.Query}
177+ header := toolHeader(sty, opts.Status, "Search", cappedWidth, opts.Compact, toolParams...)
178+ if opts.Compact {
179+ return header
180+ }
181+
182+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
183+ return joinToolParts(header, earlyState)
184+ }
185+
186+ if opts.HasEmptyResult() {
187+ return header
188+ }
189+
190+ body := toolOutputMarkdownContent(sty, opts.Result.Content, cappedWidth, opts.ExpandedContent)
191+ return joinToolParts(header, body)
192+}
internal/ui/chat/file.gonew file+340
@@ -0,0 +1,340 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/charmbracelet/crush/internal/agent/tools"
9+ "github.com/charmbracelet/crush/internal/fsext"
10+ "github.com/charmbracelet/crush/internal/message"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+)
13+
14+// -----------------------------------------------------------------------------
15+// View Tool
16+// -----------------------------------------------------------------------------
17+
18+// ViewToolMessageItem is a message item that represents a view tool call.
19+type ViewToolMessageItem struct {
20+ *baseToolMessageItem
21+}
22+
23+var _ ToolMessageItem = (*ViewToolMessageItem)(nil)
24+
25+// NewViewToolMessageItem creates a new [ViewToolMessageItem].
26+func NewViewToolMessageItem(
27+ sty *styles.Styles,
28+ toolCall message.ToolCall,
29+ result *message.ToolResult,
30+ canceled bool,
31+) ToolMessageItem {
32+ return newBaseToolMessageItem(sty, toolCall, result, &ViewToolRenderContext{}, canceled)
33+}
34+
35+// ViewToolRenderContext renders view tool messages.
36+type ViewToolRenderContext struct{}
37+
38+// RenderTool implements the [ToolRenderer] interface.
39+func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
40+ cappedWidth := cappedMessageWidth(width)
41+ if opts.IsPending() {
42+ return pendingTool(sty, "View", opts.Anim)
43+ }
44+
45+ var params tools.ViewParams
46+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
47+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
48+ }
49+
50+ file := fsext.PrettyPath(params.FilePath)
51+ toolParams := []string{file}
52+ if params.Limit != 0 {
53+ toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
54+ }
55+ if params.Offset != 0 {
56+ toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
57+ }
58+
59+ header := toolHeader(sty, opts.Status, "View", cappedWidth, opts.Compact, toolParams...)
60+ if opts.Compact {
61+ return header
62+ }
63+
64+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
65+ return joinToolParts(header, earlyState)
66+ }
67+
68+ if !opts.HasResult() {
69+ return header
70+ }
71+
72+ // Handle image content.
73+ if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
74+ body := toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType)
75+ return joinToolParts(header, body)
76+ }
77+
78+ // Try to get content from metadata first (contains actual file content).
79+ var meta tools.ViewResponseMetadata
80+ content := opts.Result.Content
81+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil && meta.Content != "" {
82+ content = meta.Content
83+ }
84+
85+ if content == "" {
86+ return header
87+ }
88+
89+ // Render code content with syntax highlighting.
90+ body := toolOutputCodeContent(sty, params.FilePath, content, params.Offset, cappedWidth, opts.ExpandedContent)
91+ return joinToolParts(header, body)
92+}
93+
94+// -----------------------------------------------------------------------------
95+// Write Tool
96+// -----------------------------------------------------------------------------
97+
98+// WriteToolMessageItem is a message item that represents a write tool call.
99+type WriteToolMessageItem struct {
100+ *baseToolMessageItem
101+}
102+
103+var _ ToolMessageItem = (*WriteToolMessageItem)(nil)
104+
105+// NewWriteToolMessageItem creates a new [WriteToolMessageItem].
106+func NewWriteToolMessageItem(
107+ sty *styles.Styles,
108+ toolCall message.ToolCall,
109+ result *message.ToolResult,
110+ canceled bool,
111+) ToolMessageItem {
112+ return newBaseToolMessageItem(sty, toolCall, result, &WriteToolRenderContext{}, canceled)
113+}
114+
115+// WriteToolRenderContext renders write tool messages.
116+type WriteToolRenderContext struct{}
117+
118+// RenderTool implements the [ToolRenderer] interface.
119+func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
120+ cappedWidth := cappedMessageWidth(width)
121+ if opts.IsPending() {
122+ return pendingTool(sty, "Write", opts.Anim)
123+ }
124+
125+ var params tools.WriteParams
126+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
127+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
128+ }
129+
130+ file := fsext.PrettyPath(params.FilePath)
131+ header := toolHeader(sty, opts.Status, "Write", cappedWidth, opts.Compact, file)
132+ if opts.Compact {
133+ return header
134+ }
135+
136+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
137+ return joinToolParts(header, earlyState)
138+ }
139+
140+ if params.Content == "" {
141+ return header
142+ }
143+
144+ // Render code content with syntax highlighting.
145+ body := toolOutputCodeContent(sty, params.FilePath, params.Content, 0, cappedWidth, opts.ExpandedContent)
146+ return joinToolParts(header, body)
147+}
148+
149+// -----------------------------------------------------------------------------
150+// Edit Tool
151+// -----------------------------------------------------------------------------
152+
153+// EditToolMessageItem is a message item that represents an edit tool call.
154+type EditToolMessageItem struct {
155+ *baseToolMessageItem
156+}
157+
158+var _ ToolMessageItem = (*EditToolMessageItem)(nil)
159+
160+// NewEditToolMessageItem creates a new [EditToolMessageItem].
161+func NewEditToolMessageItem(
162+ sty *styles.Styles,
163+ toolCall message.ToolCall,
164+ result *message.ToolResult,
165+ canceled bool,
166+) ToolMessageItem {
167+ return newBaseToolMessageItem(sty, toolCall, result, &EditToolRenderContext{}, canceled)
168+}
169+
170+// EditToolRenderContext renders edit tool messages.
171+type EditToolRenderContext struct{}
172+
173+// RenderTool implements the [ToolRenderer] interface.
174+func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
175+ // Edit tool uses full width for diffs.
176+ if opts.IsPending() {
177+ return pendingTool(sty, "Edit", opts.Anim)
178+ }
179+
180+ var params tools.EditParams
181+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
182+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
183+ }
184+
185+ file := fsext.PrettyPath(params.FilePath)
186+ header := toolHeader(sty, opts.Status, "Edit", width, opts.Compact, file)
187+ if opts.Compact {
188+ return header
189+ }
190+
191+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
192+ return joinToolParts(header, earlyState)
193+ }
194+
195+ if !opts.HasResult() {
196+ return header
197+ }
198+
199+ // Get diff content from metadata.
200+ var meta tools.EditResponseMetadata
201+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
202+ bodyWidth := width - toolBodyLeftPaddingTotal
203+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
204+ return joinToolParts(header, body)
205+ }
206+
207+ // Render diff.
208+ body := toolOutputDiffContent(sty, file, meta.OldContent, meta.NewContent, width, opts.ExpandedContent)
209+ return joinToolParts(header, body)
210+}
211+
212+// -----------------------------------------------------------------------------
213+// MultiEdit Tool
214+// -----------------------------------------------------------------------------
215+
216+// MultiEditToolMessageItem is a message item that represents a multi-edit tool call.
217+type MultiEditToolMessageItem struct {
218+ *baseToolMessageItem
219+}
220+
221+var _ ToolMessageItem = (*MultiEditToolMessageItem)(nil)
222+
223+// NewMultiEditToolMessageItem creates a new [MultiEditToolMessageItem].
224+func NewMultiEditToolMessageItem(
225+ sty *styles.Styles,
226+ toolCall message.ToolCall,
227+ result *message.ToolResult,
228+ canceled bool,
229+) ToolMessageItem {
230+ return newBaseToolMessageItem(sty, toolCall, result, &MultiEditToolRenderContext{}, canceled)
231+}
232+
233+// MultiEditToolRenderContext renders multi-edit tool messages.
234+type MultiEditToolRenderContext struct{}
235+
236+// RenderTool implements the [ToolRenderer] interface.
237+func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
238+ // MultiEdit tool uses full width for diffs.
239+ if opts.IsPending() {
240+ return pendingTool(sty, "Multi-Edit", opts.Anim)
241+ }
242+
243+ var params tools.MultiEditParams
244+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
245+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, width)
246+ }
247+
248+ file := fsext.PrettyPath(params.FilePath)
249+ toolParams := []string{file}
250+ if len(params.Edits) > 0 {
251+ toolParams = append(toolParams, "edits", fmt.Sprintf("%d", len(params.Edits)))
252+ }
253+
254+ header := toolHeader(sty, opts.Status, "Multi-Edit", width, opts.Compact, toolParams...)
255+ if opts.Compact {
256+ return header
257+ }
258+
259+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
260+ return joinToolParts(header, earlyState)
261+ }
262+
263+ if !opts.HasResult() {
264+ return header
265+ }
266+
267+ // Get diff content from metadata.
268+ var meta tools.MultiEditResponseMetadata
269+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err != nil {
270+ bodyWidth := width - toolBodyLeftPaddingTotal
271+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
272+ return joinToolParts(header, body)
273+ }
274+
275+ // Render diff with optional failed edits note.
276+ body := toolOutputMultiEditDiffContent(sty, file, meta, len(params.Edits), width, opts.ExpandedContent)
277+ return joinToolParts(header, body)
278+}
279+
280+// -----------------------------------------------------------------------------
281+// Download Tool
282+// -----------------------------------------------------------------------------
283+
284+// DownloadToolMessageItem is a message item that represents a download tool call.
285+type DownloadToolMessageItem struct {
286+ *baseToolMessageItem
287+}
288+
289+var _ ToolMessageItem = (*DownloadToolMessageItem)(nil)
290+
291+// NewDownloadToolMessageItem creates a new [DownloadToolMessageItem].
292+func NewDownloadToolMessageItem(
293+ sty *styles.Styles,
294+ toolCall message.ToolCall,
295+ result *message.ToolResult,
296+ canceled bool,
297+) ToolMessageItem {
298+ return newBaseToolMessageItem(sty, toolCall, result, &DownloadToolRenderContext{}, canceled)
299+}
300+
301+// DownloadToolRenderContext renders download tool messages.
302+type DownloadToolRenderContext struct{}
303+
304+// RenderTool implements the [ToolRenderer] interface.
305+func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
306+ cappedWidth := cappedMessageWidth(width)
307+ if opts.IsPending() {
308+ return pendingTool(sty, "Download", opts.Anim)
309+ }
310+
311+ var params tools.DownloadParams
312+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
313+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
314+ }
315+
316+ toolParams := []string{params.URL}
317+ if params.FilePath != "" {
318+ toolParams = append(toolParams, "file_path", fsext.PrettyPath(params.FilePath))
319+ }
320+ if params.Timeout != 0 {
321+ toolParams = append(toolParams, "timeout", formatTimeout(params.Timeout))
322+ }
323+
324+ header := toolHeader(sty, opts.Status, "Download", cappedWidth, opts.Compact, toolParams...)
325+ if opts.Compact {
326+ return header
327+ }
328+
329+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
330+ return joinToolParts(header, earlyState)
331+ }
332+
333+ if opts.HasEmptyResult() {
334+ return header
335+ }
336+
337+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
338+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
339+ return joinToolParts(header, body)
340+}
internal/ui/chat/mcp.gonew file+121
@@ -0,0 +1,121 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/stringext"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+)
12+
13+// MCPToolMessageItem is a message item that represents a bash tool call.
14+type MCPToolMessageItem struct {
15+ *baseToolMessageItem
16+}
17+
18+var _ ToolMessageItem = (*MCPToolMessageItem)(nil)
19+
20+// NewMCPToolMessageItem creates a new [MCPToolMessageItem].
21+func NewMCPToolMessageItem(
22+ sty *styles.Styles,
23+ toolCall message.ToolCall,
24+ result *message.ToolResult,
25+ canceled bool,
26+) ToolMessageItem {
27+ return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled)
28+}
29+
30+// MCPToolRenderContext renders bash tool messages.
31+type MCPToolRenderContext struct{}
32+
33+// RenderTool implements the [ToolRenderer] interface.
34+func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
35+ cappedWidth := cappedMessageWidth(width)
36+ toolNameParts := strings.SplitN(opts.ToolCall.Name, "_", 3)
37+ if len(toolNameParts) != 3 {
38+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth)
39+ }
40+ mcpName := prettyName(toolNameParts[1])
41+ toolName := prettyName(toolNameParts[2])
42+
43+ mcpName = sty.Tool.MCPName.Render(mcpName)
44+ toolName = sty.Tool.MCPToolName.Render(toolName)
45+
46+ name := fmt.Sprintf("%s %s %s", mcpName, sty.Tool.MCPArrow.String(), toolName)
47+
48+ if opts.IsPending() {
49+ return pendingTool(sty, name, opts.Anim)
50+ }
51+
52+ var params map[string]any
53+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
54+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
55+ }
56+
57+ var toolParams []string
58+ if len(params) > 0 {
59+ parsed, _ := json.Marshal(params)
60+ toolParams = append(toolParams, string(parsed))
61+ }
62+
63+ header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...)
64+ if opts.Compact {
65+ return header
66+ }
67+
68+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
69+ return joinToolParts(header, earlyState)
70+ }
71+
72+ if !opts.HasResult() || opts.Result.Content == "" {
73+ return header
74+ }
75+
76+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
77+ // see if the result is json
78+ var result json.RawMessage
79+ var body string
80+ if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
81+ prettyResult, err := json.MarshalIndent(result, "", " ")
82+ if err == nil {
83+ body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
84+ } else {
85+ body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
86+ }
87+ } else if looksLikeMarkdown(opts.Result.Content) {
88+ body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
89+ } else {
90+ body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
91+ }
92+ return joinToolParts(header, body)
93+}
94+
95+func prettyName(name string) string {
96+ name = strings.ReplaceAll(name, "_", " ")
97+ name = strings.ReplaceAll(name, "-", " ")
98+ return stringext.Capitalize(name)
99+}
100+
101+// looksLikeMarkdown checks if content appears to be markdown by looking for
102+// common markdown patterns.
103+func looksLikeMarkdown(content string) bool {
104+ patterns := []string{
105+ "# ", // headers
106+ "## ", // headers
107+ "**", // bold
108+ "```", // code fence
109+ "- ", // unordered list
110+ "1. ", // ordered list
111+ "> ", // blockquote
112+ "---", // horizontal rule
113+ "***", // horizontal rule
114+ }
115+ for _, p := range patterns {
116+ if strings.Contains(content, p) {
117+ return true
118+ }
119+ }
120+ return false
121+}
internal/ui/chat/messages.gonew file+312
@@ -0,0 +1,312 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "image"
6+ "strings"
7+ "time"
8+
9+ tea "charm.land/bubbletea/v2"
10+ "charm.land/lipgloss/v2"
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/message"
14+ "github.com/charmbracelet/crush/internal/ui/anim"
15+ "github.com/charmbracelet/crush/internal/ui/attachments"
16+ "github.com/charmbracelet/crush/internal/ui/common"
17+ "github.com/charmbracelet/crush/internal/ui/list"
18+ "github.com/charmbracelet/crush/internal/ui/styles"
19+)
20+
21+// this is the total width that is taken up by the border + padding
22+// we also cap the width so text is readable to the maxTextWidth(120)
23+const messageLeftPaddingTotal = 2
24+
25+// maxTextWidth is the maximum width text messages can be
26+const maxTextWidth = 120
27+
28+// Identifiable is an interface for items that can provide a unique identifier.
29+type Identifiable interface {
30+ ID() string
31+}
32+
33+// Animatable is an interface for items that support animation.
34+type Animatable interface {
35+ StartAnimation() tea.Cmd
36+ Animate(msg anim.StepMsg) tea.Cmd
37+}
38+
39+// Expandable is an interface for items that can be expanded or collapsed.
40+type Expandable interface {
41+ ToggleExpanded()
42+}
43+
44+// MessageItem represents a [message.Message] item that can be displayed in the
45+// UI and be part of a [list.List] identifiable by a unique ID.
46+type MessageItem interface {
47+ list.Item
48+ list.RawRenderable
49+ Identifiable
50+}
51+
52+// HighlightableMessageItem is a message item that supports highlighting.
53+type HighlightableMessageItem interface {
54+ MessageItem
55+ list.Highlightable
56+}
57+
58+// FocusableMessageItem is a message item that supports focus.
59+type FocusableMessageItem interface {
60+ MessageItem
61+ list.Focusable
62+}
63+
64+// SendMsg represents a message to send a chat message.
65+type SendMsg struct {
66+ Text string
67+ Attachments []message.Attachment
68+}
69+
70+type highlightableMessageItem struct {
71+ startLine int
72+ startCol int
73+ endLine int
74+ endCol int
75+ highlighter list.Highlighter
76+}
77+
78+var _ list.Highlightable = (*highlightableMessageItem)(nil)
79+
80+// isHighlighted returns true if the item has a highlight range set.
81+func (h *highlightableMessageItem) isHighlighted() bool {
82+ return h.startLine != -1 || h.endLine != -1
83+}
84+
85+// renderHighlighted highlights the content if necessary.
86+func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string {
87+ if !h.isHighlighted() {
88+ return content
89+ }
90+ area := image.Rect(0, 0, width, height)
91+ return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
92+}
93+
94+// SetHighlight implements list.Highlightable.
95+func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
96+ // Adjust columns for the style's left inset (border + padding) since we
97+ // highlight the content only.
98+ offset := messageLeftPaddingTotal
99+ h.startLine = startLine
100+ h.startCol = max(0, startCol-offset)
101+ h.endLine = endLine
102+ if endCol >= 0 {
103+ h.endCol = max(0, endCol-offset)
104+ } else {
105+ h.endCol = endCol
106+ }
107+}
108+
109+// Highlight implements list.Highlightable.
110+func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) {
111+ return h.startLine, h.startCol, h.endLine, h.endCol
112+}
113+
114+func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
115+ return &highlightableMessageItem{
116+ startLine: -1,
117+ startCol: -1,
118+ endLine: -1,
119+ endCol: -1,
120+ highlighter: list.ToHighlighter(sty.TextSelection),
121+ }
122+}
123+
124+// cachedMessageItem caches rendered message content to avoid re-rendering.
125+//
126+// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
127+//
128+// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
129+// the issue with that could be memory usage
130+type cachedMessageItem struct {
131+ // rendered is the cached rendered string
132+ rendered string
133+ // width and height are the dimensions of the cached render
134+ width int
135+ height int
136+}
137+
138+// getCachedRender returns the cached render if it exists for the given width.
139+func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) {
140+ if c.width == width && c.rendered != "" {
141+ return c.rendered, c.height, true
142+ }
143+ return "", 0, false
144+}
145+
146+// setCachedRender sets the cached render.
147+func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) {
148+ c.rendered = rendered
149+ c.width = width
150+ c.height = height
151+}
152+
153+// clearCache clears the cached render.
154+func (c *cachedMessageItem) clearCache() {
155+ c.rendered = ""
156+ c.width = 0
157+ c.height = 0
158+}
159+
160+// focusableMessageItem is a base struct for message items that can be focused.
161+type focusableMessageItem struct {
162+ focused bool
163+}
164+
165+// SetFocused implements MessageItem.
166+func (f *focusableMessageItem) SetFocused(focused bool) {
167+ f.focused = focused
168+}
169+
170+// AssistantInfoID returns a stable ID for assistant info items.
171+func AssistantInfoID(messageID string) string {
172+ return fmt.Sprintf("%s:assistant-info", messageID)
173+}
174+
175+// AssistantInfoItem renders model info and response time after assistant completes.
176+type AssistantInfoItem struct {
177+ *cachedMessageItem
178+
179+ id string
180+ message *message.Message
181+ sty *styles.Styles
182+ lastUserMessageTime time.Time
183+}
184+
185+// NewAssistantInfoItem creates a new AssistantInfoItem.
186+func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem {
187+ return &AssistantInfoItem{
188+ cachedMessageItem: &cachedMessageItem{},
189+ id: AssistantInfoID(message.ID),
190+ message: message,
191+ sty: sty,
192+ lastUserMessageTime: lastUserMessageTime,
193+ }
194+}
195+
196+// ID implements MessageItem.
197+func (a *AssistantInfoItem) ID() string {
198+ return a.id
199+}
200+
201+// RawRender implements MessageItem.
202+func (a *AssistantInfoItem) RawRender(width int) string {
203+ innerWidth := max(0, width-messageLeftPaddingTotal)
204+ content, _, ok := a.getCachedRender(innerWidth)
205+ if !ok {
206+ content = a.renderContent(innerWidth)
207+ height := lipgloss.Height(content)
208+ a.setCachedRender(content, innerWidth, height)
209+ }
210+ return content
211+}
212+
213+// Render implements MessageItem.
214+func (a *AssistantInfoItem) Render(width int) string {
215+ return a.sty.Chat.Message.SectionHeader.Render(a.RawRender(width))
216+}
217+
218+func (a *AssistantInfoItem) renderContent(width int) string {
219+ finishData := a.message.FinishPart()
220+ if finishData == nil {
221+ return ""
222+ }
223+ finishTime := time.Unix(finishData.Time, 0)
224+ duration := finishTime.Sub(a.lastUserMessageTime)
225+ infoMsg := a.sty.Chat.Message.AssistantInfoDuration.Render(duration.String())
226+ icon := a.sty.Chat.Message.AssistantInfoIcon.Render(styles.ModelIcon)
227+ model := config.Get().GetModel(a.message.Provider, a.message.Model)
228+ if model == nil {
229+ model = &catwalk.Model{Name: "Unknown Model"}
230+ }
231+ modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name)
232+ providerName := a.message.Provider
233+ if providerConfig, ok := config.Get().Providers.Get(a.message.Provider); ok {
234+ providerName = providerConfig.Name
235+ }
236+ provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName))
237+ assistant := fmt.Sprintf("%s %s %s %s", icon, modelFormatted, provider, infoMsg)
238+ return common.Section(a.sty, assistant, width)
239+}
240+
241+// cappedMessageWidth returns the maximum width for message content for readability.
242+func cappedMessageWidth(availableWidth int) int {
243+ return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
244+}
245+
246+// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
247+// returns all parts of the message as [MessageItem]s.
248+//
249+// For assistant messages with tool calls, pass a toolResults map to link results.
250+// Use BuildToolResultMap to create this map from all messages in a session.
251+func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
252+ switch msg.Role {
253+ case message.User:
254+ r := attachments.NewRenderer(
255+ sty.Attachments.Normal,
256+ sty.Attachments.Deleting,
257+ sty.Attachments.Image,
258+ sty.Attachments.Text,
259+ )
260+ return []MessageItem{NewUserMessageItem(sty, msg, r)}
261+ case message.Assistant:
262+ var items []MessageItem
263+ if ShouldRenderAssistantMessage(msg) {
264+ items = append(items, NewAssistantMessageItem(sty, msg))
265+ }
266+ for _, tc := range msg.ToolCalls() {
267+ var result *message.ToolResult
268+ if tr, ok := toolResults[tc.ID]; ok {
269+ result = &tr
270+ }
271+ items = append(items, NewToolMessageItem(
272+ sty,
273+ msg.ID,
274+ tc,
275+ result,
276+ msg.FinishReason() == message.FinishReasonCanceled,
277+ ))
278+ }
279+ return items
280+ }
281+ return []MessageItem{}
282+}
283+
284+// ShouldRenderAssistantMessage determines if an assistant message should be rendered
285+//
286+// In some cases the assistant message only has tools so we do not want to render an
287+// empty message.
288+func ShouldRenderAssistantMessage(msg *message.Message) bool {
289+ content := strings.TrimSpace(msg.Content().Text)
290+ thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
291+ isError := msg.FinishReason() == message.FinishReasonError
292+ isCancelled := msg.FinishReason() == message.FinishReasonCanceled
293+ hasToolCalls := len(msg.ToolCalls()) > 0
294+ return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
295+}
296+
297+// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
298+// Tool result messages (role == message.Tool) contain the results that should be linked
299+// to tool calls in assistant messages.
300+func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
301+ resultMap := make(map[string]message.ToolResult)
302+ for _, msg := range messages {
303+ if msg.Role == message.Tool {
304+ for _, result := range msg.ToolResults() {
305+ if result.ToolCallID != "" {
306+ resultMap[result.ToolCallID] = result
307+ }
308+ }
309+ }
310+ }
311+ return resultMap
312+}
internal/ui/chat/search.gonew file+256
@@ -0,0 +1,256 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+
6+ "github.com/charmbracelet/crush/internal/agent/tools"
7+ "github.com/charmbracelet/crush/internal/fsext"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+)
11+
12+// -----------------------------------------------------------------------------
13+// Glob Tool
14+// -----------------------------------------------------------------------------
15+
16+// GlobToolMessageItem is a message item that represents a glob tool call.
17+type GlobToolMessageItem struct {
18+ *baseToolMessageItem
19+}
20+
21+var _ ToolMessageItem = (*GlobToolMessageItem)(nil)
22+
23+// NewGlobToolMessageItem creates a new [GlobToolMessageItem].
24+func NewGlobToolMessageItem(
25+ sty *styles.Styles,
26+ toolCall message.ToolCall,
27+ result *message.ToolResult,
28+ canceled bool,
29+) ToolMessageItem {
30+ return newBaseToolMessageItem(sty, toolCall, result, &GlobToolRenderContext{}, canceled)
31+}
32+
33+// GlobToolRenderContext renders glob tool messages.
34+type GlobToolRenderContext struct{}
35+
36+// RenderTool implements the [ToolRenderer] interface.
37+func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
38+ cappedWidth := cappedMessageWidth(width)
39+ if opts.IsPending() {
40+ return pendingTool(sty, "Glob", opts.Anim)
41+ }
42+
43+ var params tools.GlobParams
44+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
45+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
46+ }
47+
48+ toolParams := []string{params.Pattern}
49+ if params.Path != "" {
50+ toolParams = append(toolParams, "path", params.Path)
51+ }
52+
53+ header := toolHeader(sty, opts.Status, "Glob", cappedWidth, opts.Compact, toolParams...)
54+ if opts.Compact {
55+ return header
56+ }
57+
58+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
59+ return joinToolParts(header, earlyState)
60+ }
61+
62+ if !opts.HasResult() || opts.Result.Content == "" {
63+ return header
64+ }
65+
66+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
67+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
68+ return joinToolParts(header, body)
69+}
70+
71+// -----------------------------------------------------------------------------
72+// Grep Tool
73+// -----------------------------------------------------------------------------
74+
75+// GrepToolMessageItem is a message item that represents a grep tool call.
76+type GrepToolMessageItem struct {
77+ *baseToolMessageItem
78+}
79+
80+var _ ToolMessageItem = (*GrepToolMessageItem)(nil)
81+
82+// NewGrepToolMessageItem creates a new [GrepToolMessageItem].
83+func NewGrepToolMessageItem(
84+ sty *styles.Styles,
85+ toolCall message.ToolCall,
86+ result *message.ToolResult,
87+ canceled bool,
88+) ToolMessageItem {
89+ return newBaseToolMessageItem(sty, toolCall, result, &GrepToolRenderContext{}, canceled)
90+}
91+
92+// GrepToolRenderContext renders grep tool messages.
93+type GrepToolRenderContext struct{}
94+
95+// RenderTool implements the [ToolRenderer] interface.
96+func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
97+ cappedWidth := cappedMessageWidth(width)
98+ if opts.IsPending() {
99+ return pendingTool(sty, "Grep", opts.Anim)
100+ }
101+
102+ var params tools.GrepParams
103+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
104+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
105+ }
106+
107+ toolParams := []string{params.Pattern}
108+ if params.Path != "" {
109+ toolParams = append(toolParams, "path", params.Path)
110+ }
111+ if params.Include != "" {
112+ toolParams = append(toolParams, "include", params.Include)
113+ }
114+ if params.LiteralText {
115+ toolParams = append(toolParams, "literal", "true")
116+ }
117+
118+ header := toolHeader(sty, opts.Status, "Grep", cappedWidth, opts.Compact, toolParams...)
119+ if opts.Compact {
120+ return header
121+ }
122+
123+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
124+ return joinToolParts(header, earlyState)
125+ }
126+
127+ if opts.HasEmptyResult() {
128+ return header
129+ }
130+
131+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
132+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
133+ return joinToolParts(header, body)
134+}
135+
136+// -----------------------------------------------------------------------------
137+// LS Tool
138+// -----------------------------------------------------------------------------
139+
140+// LSToolMessageItem is a message item that represents an ls tool call.
141+type LSToolMessageItem struct {
142+ *baseToolMessageItem
143+}
144+
145+var _ ToolMessageItem = (*LSToolMessageItem)(nil)
146+
147+// NewLSToolMessageItem creates a new [LSToolMessageItem].
148+func NewLSToolMessageItem(
149+ sty *styles.Styles,
150+ toolCall message.ToolCall,
151+ result *message.ToolResult,
152+ canceled bool,
153+) ToolMessageItem {
154+ return newBaseToolMessageItem(sty, toolCall, result, &LSToolRenderContext{}, canceled)
155+}
156+
157+// LSToolRenderContext renders ls tool messages.
158+type LSToolRenderContext struct{}
159+
160+// RenderTool implements the [ToolRenderer] interface.
161+func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
162+ cappedWidth := cappedMessageWidth(width)
163+ if opts.IsPending() {
164+ return pendingTool(sty, "List", opts.Anim)
165+ }
166+
167+ var params tools.LSParams
168+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
169+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
170+ }
171+
172+ path := params.Path
173+ if path == "" {
174+ path = "."
175+ }
176+ path = fsext.PrettyPath(path)
177+
178+ header := toolHeader(sty, opts.Status, "List", cappedWidth, opts.Compact, path)
179+ if opts.Compact {
180+ return header
181+ }
182+
183+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
184+ return joinToolParts(header, earlyState)
185+ }
186+
187+ if opts.HasEmptyResult() {
188+ return header
189+ }
190+
191+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
192+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
193+ return joinToolParts(header, body)
194+}
195+
196+// -----------------------------------------------------------------------------
197+// Sourcegraph Tool
198+// -----------------------------------------------------------------------------
199+
200+// SourcegraphToolMessageItem is a message item that represents a sourcegraph tool call.
201+type SourcegraphToolMessageItem struct {
202+ *baseToolMessageItem
203+}
204+
205+var _ ToolMessageItem = (*SourcegraphToolMessageItem)(nil)
206+
207+// NewSourcegraphToolMessageItem creates a new [SourcegraphToolMessageItem].
208+func NewSourcegraphToolMessageItem(
209+ sty *styles.Styles,
210+ toolCall message.ToolCall,
211+ result *message.ToolResult,
212+ canceled bool,
213+) ToolMessageItem {
214+ return newBaseToolMessageItem(sty, toolCall, result, &SourcegraphToolRenderContext{}, canceled)
215+}
216+
217+// SourcegraphToolRenderContext renders sourcegraph tool messages.
218+type SourcegraphToolRenderContext struct{}
219+
220+// RenderTool implements the [ToolRenderer] interface.
221+func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
222+ cappedWidth := cappedMessageWidth(width)
223+ if opts.IsPending() {
224+ return pendingTool(sty, "Sourcegraph", opts.Anim)
225+ }
226+
227+ var params tools.SourcegraphParams
228+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
229+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
230+ }
231+
232+ toolParams := []string{params.Query}
233+ if params.Count != 0 {
234+ toolParams = append(toolParams, "count", formatNonZero(params.Count))
235+ }
236+ if params.ContextWindow != 0 {
237+ toolParams = append(toolParams, "context", formatNonZero(params.ContextWindow))
238+ }
239+
240+ header := toolHeader(sty, opts.Status, "Sourcegraph", cappedWidth, opts.Compact, toolParams...)
241+ if opts.Compact {
242+ return header
243+ }
244+
245+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
246+ return joinToolParts(header, earlyState)
247+ }
248+
249+ if opts.HasEmptyResult() {
250+ return header
251+ }
252+
253+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
254+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
255+ return joinToolParts(header, body)
256+}
internal/ui/chat/todos.gonew file+192
@@ -0,0 +1,192 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "slices"
7+ "strings"
8+
9+ "github.com/charmbracelet/crush/internal/agent/tools"
10+ "github.com/charmbracelet/crush/internal/message"
11+ "github.com/charmbracelet/crush/internal/session"
12+ "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/x/ansi"
14+)
15+
16+// -----------------------------------------------------------------------------
17+// Todos Tool
18+// -----------------------------------------------------------------------------
19+
20+// TodosToolMessageItem is a message item that represents a todos tool call.
21+type TodosToolMessageItem struct {
22+ *baseToolMessageItem
23+}
24+
25+var _ ToolMessageItem = (*TodosToolMessageItem)(nil)
26+
27+// NewTodosToolMessageItem creates a new [TodosToolMessageItem].
28+func NewTodosToolMessageItem(
29+ sty *styles.Styles,
30+ toolCall message.ToolCall,
31+ result *message.ToolResult,
32+ canceled bool,
33+) ToolMessageItem {
34+ return newBaseToolMessageItem(sty, toolCall, result, &TodosToolRenderContext{}, canceled)
35+}
36+
37+// TodosToolRenderContext renders todos tool messages.
38+type TodosToolRenderContext struct{}
39+
40+// RenderTool implements the [ToolRenderer] interface.
41+func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
42+ cappedWidth := cappedMessageWidth(width)
43+ if opts.IsPending() {
44+ return pendingTool(sty, "To-Do", opts.Anim)
45+ }
46+
47+ var params tools.TodosParams
48+ var meta tools.TodosResponseMetadata
49+ var headerText string
50+ var body string
51+
52+ // Parse params for pending state (before result is available).
53+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err == nil {
54+ completedCount := 0
55+ inProgressTask := ""
56+ for _, todo := range params.Todos {
57+ if todo.Status == "completed" {
58+ completedCount++
59+ }
60+ if todo.Status == "in_progress" {
61+ if todo.ActiveForm != "" {
62+ inProgressTask = todo.ActiveForm
63+ } else {
64+ inProgressTask = todo.Content
65+ }
66+ }
67+ }
68+
69+ // Default display from params (used when pending or no metadata).
70+ ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
71+ headerText = ratio
72+ if inProgressTask != "" {
73+ headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
74+ }
75+
76+ // If we have metadata, use it for richer display.
77+ if opts.HasResult() && opts.Result.Metadata != "" {
78+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
79+ if meta.IsNew {
80+ if meta.JustStarted != "" {
81+ headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
82+ } else {
83+ headerText = fmt.Sprintf("created %d todos", meta.Total)
84+ }
85+ body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
86+ } else {
87+ // Build header based on what changed.
88+ hasCompleted := len(meta.JustCompleted) > 0
89+ hasStarted := meta.JustStarted != ""
90+ allCompleted := meta.Completed == meta.Total
91+
92+ ratio := sty.Tool.TodoRatio.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
93+ if hasCompleted && hasStarted {
94+ text := sty.Subtle.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
95+ headerText = fmt.Sprintf("%s%s", ratio, text)
96+ } else if hasCompleted {
97+ text := sty.Subtle.Render(fmt.Sprintf(" · completed %d", len(meta.JustCompleted)))
98+ if allCompleted {
99+ text = sty.Subtle.Render(" · completed all")
100+ }
101+ headerText = fmt.Sprintf("%s%s", ratio, text)
102+ } else if hasStarted {
103+ headerText = fmt.Sprintf("%s%s", ratio, sty.Subtle.Render(" · starting task"))
104+ } else {
105+ headerText = ratio
106+ }
107+
108+ // Build body with details.
109+ if allCompleted {
110+ // Show all todos when all are completed, like when created.
111+ body = FormatTodosList(sty, meta.Todos, styles.ArrowRightIcon, cappedWidth)
112+ } else if meta.JustStarted != "" {
113+ body = sty.Tool.TodoInProgressIcon.Render(styles.ArrowRightIcon+" ") +
114+ sty.Base.Render(meta.JustStarted)
115+ }
116+ }
117+ }
118+ }
119+ }
120+
121+ toolParams := []string{headerText}
122+ header := toolHeader(sty, opts.Status, "To-Do", cappedWidth, opts.Compact, toolParams...)
123+ if opts.Compact {
124+ return header
125+ }
126+
127+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
128+ return joinToolParts(header, earlyState)
129+ }
130+
131+ if body == "" {
132+ return header
133+ }
134+
135+ return joinToolParts(header, sty.Tool.Body.Render(body))
136+}
137+
138+// FormatTodosList formats a list of todos for display.
139+func FormatTodosList(sty *styles.Styles, todos []session.Todo, inProgressIcon string, width int) string {
140+ if len(todos) == 0 {
141+ return ""
142+ }
143+
144+ sorted := make([]session.Todo, len(todos))
145+ copy(sorted, todos)
146+ sortTodos(sorted)
147+
148+ var lines []string
149+ for _, todo := range sorted {
150+ var prefix string
151+ textStyle := sty.Base
152+
153+ switch todo.Status {
154+ case session.TodoStatusCompleted:
155+ prefix = sty.Tool.TodoCompletedIcon.Render(styles.TodoCompletedIcon) + " "
156+ case session.TodoStatusInProgress:
157+ prefix = sty.Tool.TodoInProgressIcon.Render(inProgressIcon + " ")
158+ default:
159+ prefix = sty.Tool.TodoPendingIcon.Render(styles.TodoPendingIcon) + " "
160+ }
161+
162+ text := todo.Content
163+ if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
164+ text = todo.ActiveForm
165+ }
166+ line := prefix + textStyle.Render(text)
167+ line = ansi.Truncate(line, width, "…")
168+
169+ lines = append(lines, line)
170+ }
171+
172+ return strings.Join(lines, "\n")
173+}
174+
175+// sortTodos sorts todos by status: completed, in_progress, pending.
176+func sortTodos(todos []session.Todo) {
177+ slices.SortStableFunc(todos, func(a, b session.Todo) int {
178+ return statusOrder(a.Status) - statusOrder(b.Status)
179+ })
180+}
181+
182+// statusOrder returns the sort order for a todo status.
183+func statusOrder(s session.TodoStatus) int {
184+ switch s {
185+ case session.TodoStatusCompleted:
186+ return 0
187+ case session.TodoStatusInProgress:
188+ return 1
189+ default:
190+ return 2
191+ }
192+}
internal/ui/chat/tools.gonew file+805
@@ -0,0 +1,805 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+ "charm.land/lipgloss/v2/tree"
10+ "github.com/charmbracelet/crush/internal/agent"
11+ "github.com/charmbracelet/crush/internal/agent/tools"
12+ "github.com/charmbracelet/crush/internal/message"
13+ "github.com/charmbracelet/crush/internal/ui/anim"
14+ "github.com/charmbracelet/crush/internal/ui/common"
15+ "github.com/charmbracelet/crush/internal/ui/styles"
16+ "github.com/charmbracelet/x/ansi"
17+)
18+
19+// responseContextHeight limits the number of lines displayed in tool output.
20+const responseContextHeight = 10
21+
22+// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
23+const toolBodyLeftPaddingTotal = 2
24+
25+// ToolStatus represents the current state of a tool call.
26+type ToolStatus int
27+
28+const (
29+ ToolStatusAwaitingPermission ToolStatus = iota
30+ ToolStatusRunning
31+ ToolStatusSuccess
32+ ToolStatusError
33+ ToolStatusCanceled
34+)
35+
36+// ToolMessageItem represents a tool call message in the chat UI.
37+type ToolMessageItem interface {
38+ MessageItem
39+
40+ ToolCall() message.ToolCall
41+ SetToolCall(tc message.ToolCall)
42+ SetResult(res *message.ToolResult)
43+ MessageID() string
44+ SetMessageID(id string)
45+ SetStatus(status ToolStatus)
46+ Status() ToolStatus
47+}
48+
49+// Compactable is an interface for tool items that can render in a compacted mode.
50+// When compact mode is enabled, tools render as a compact single-line header.
51+type Compactable interface {
52+ SetCompact(compact bool)
53+}
54+
55+// SpinningState contains the state passed to SpinningFunc for custom spinning logic.
56+type SpinningState struct {
57+ ToolCall message.ToolCall
58+ Result *message.ToolResult
59+ Status ToolStatus
60+}
61+
62+// IsCanceled returns true if the tool status is canceled.
63+func (s *SpinningState) IsCanceled() bool {
64+ return s.Status == ToolStatusCanceled
65+}
66+
67+// HasResult returns true if the result is not nil.
68+func (s *SpinningState) HasResult() bool {
69+ return s.Result != nil
70+}
71+
72+// SpinningFunc is a function type for custom spinning logic.
73+// Returns true if the tool should show the spinning animation.
74+type SpinningFunc func(state SpinningState) bool
75+
76+// DefaultToolRenderContext implements the default [ToolRenderer] interface.
77+type DefaultToolRenderContext struct{}
78+
79+// RenderTool implements the [ToolRenderer] interface.
80+func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
81+ return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
82+}
83+
84+// ToolRenderOpts contains the data needed to render a tool call.
85+type ToolRenderOpts struct {
86+ ToolCall message.ToolCall
87+ Result *message.ToolResult
88+ Anim *anim.Anim
89+ ExpandedContent bool
90+ Compact bool
91+ IsSpinning bool
92+ Status ToolStatus
93+}
94+
95+// IsPending returns true if the tool call is still pending (not finished and
96+// not canceled).
97+func (o *ToolRenderOpts) IsPending() bool {
98+ return !o.ToolCall.Finished && !o.IsCanceled()
99+}
100+
101+// IsCanceled returns true if the tool status is canceled.
102+func (o *ToolRenderOpts) IsCanceled() bool {
103+ return o.Status == ToolStatusCanceled
104+}
105+
106+// HasResult returns true if the result is not nil.
107+func (o *ToolRenderOpts) HasResult() bool {
108+ return o.Result != nil
109+}
110+
111+// HasEmptyResult returns true if the result is nil or has empty content.
112+func (o *ToolRenderOpts) HasEmptyResult() bool {
113+ return o.Result == nil || o.Result.Content == ""
114+}
115+
116+// ToolRenderer represents an interface for rendering tool calls.
117+type ToolRenderer interface {
118+ RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
119+}
120+
121+// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
122+type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
123+
124+// RenderTool implements the ToolRenderer interface.
125+func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
126+ return f(sty, width, opts)
127+}
128+
129+// baseToolMessageItem represents a tool call message that can be displayed in the UI.
130+type baseToolMessageItem struct {
131+ *highlightableMessageItem
132+ *cachedMessageItem
133+ *focusableMessageItem
134+
135+ toolRenderer ToolRenderer
136+ toolCall message.ToolCall
137+ result *message.ToolResult
138+ messageID string
139+ status ToolStatus
140+ // we use this so we can efficiently cache
141+ // tools that have a capped width (e.x bash.. and others)
142+ hasCappedWidth bool
143+ // isCompact indicates this tool should render in compact mode.
144+ isCompact bool
145+ // spinningFunc allows tools to override the default spinning logic.
146+ // If nil, uses the default: !toolCall.Finished && !canceled.
147+ spinningFunc SpinningFunc
148+
149+ sty *styles.Styles
150+ anim *anim.Anim
151+ expandedContent bool
152+}
153+
154+// newBaseToolMessageItem is the internal constructor for base tool message items.
155+func newBaseToolMessageItem(
156+ sty *styles.Styles,
157+ toolCall message.ToolCall,
158+ result *message.ToolResult,
159+ toolRenderer ToolRenderer,
160+ canceled bool,
161+) *baseToolMessageItem {
162+ // we only do full width for diffs (as far as I know)
163+ hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
164+
165+ status := ToolStatusRunning
166+ if canceled {
167+ status = ToolStatusCanceled
168+ }
169+
170+ t := &baseToolMessageItem{
171+ highlightableMessageItem: defaultHighlighter(sty),
172+ cachedMessageItem: &cachedMessageItem{},
173+ focusableMessageItem: &focusableMessageItem{},
174+ sty: sty,
175+ toolRenderer: toolRenderer,
176+ toolCall: toolCall,
177+ result: result,
178+ status: status,
179+ hasCappedWidth: hasCappedWidth,
180+ }
181+ t.anim = anim.New(anim.Settings{
182+ ID: toolCall.ID,
183+ Size: 15,
184+ GradColorA: sty.Primary,
185+ GradColorB: sty.Secondary,
186+ LabelColor: sty.FgBase,
187+ CycleColors: true,
188+ })
189+
190+ return t
191+}
192+
193+// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
194+//
195+// It returns a specific tool message item type if implemented, otherwise it
196+// returns a generic tool message item. The messageID is the ID of the assistant
197+// message containing this tool call.
198+func NewToolMessageItem(
199+ sty *styles.Styles,
200+ messageID string,
201+ toolCall message.ToolCall,
202+ result *message.ToolResult,
203+ canceled bool,
204+) ToolMessageItem {
205+ var item ToolMessageItem
206+ switch toolCall.Name {
207+ case tools.BashToolName:
208+ item = NewBashToolMessageItem(sty, toolCall, result, canceled)
209+ case tools.JobOutputToolName:
210+ item = NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
211+ case tools.JobKillToolName:
212+ item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
213+ case tools.ViewToolName:
214+ item = NewViewToolMessageItem(sty, toolCall, result, canceled)
215+ case tools.WriteToolName:
216+ item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
217+ case tools.EditToolName:
218+ item = NewEditToolMessageItem(sty, toolCall, result, canceled)
219+ case tools.MultiEditToolName:
220+ item = NewMultiEditToolMessageItem(sty, toolCall, result, canceled)
221+ case tools.GlobToolName:
222+ item = NewGlobToolMessageItem(sty, toolCall, result, canceled)
223+ case tools.GrepToolName:
224+ item = NewGrepToolMessageItem(sty, toolCall, result, canceled)
225+ case tools.LSToolName:
226+ item = NewLSToolMessageItem(sty, toolCall, result, canceled)
227+ case tools.DownloadToolName:
228+ item = NewDownloadToolMessageItem(sty, toolCall, result, canceled)
229+ case tools.FetchToolName:
230+ item = NewFetchToolMessageItem(sty, toolCall, result, canceled)
231+ case tools.SourcegraphToolName:
232+ item = NewSourcegraphToolMessageItem(sty, toolCall, result, canceled)
233+ case tools.DiagnosticsToolName:
234+ item = NewDiagnosticsToolMessageItem(sty, toolCall, result, canceled)
235+ case agent.AgentToolName:
236+ item = NewAgentToolMessageItem(sty, toolCall, result, canceled)
237+ case tools.AgenticFetchToolName:
238+ item = NewAgenticFetchToolMessageItem(sty, toolCall, result, canceled)
239+ case tools.WebFetchToolName:
240+ item = NewWebFetchToolMessageItem(sty, toolCall, result, canceled)
241+ case tools.WebSearchToolName:
242+ item = NewWebSearchToolMessageItem(sty, toolCall, result, canceled)
243+ case tools.TodosToolName:
244+ item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
245+ default:
246+ if strings.HasPrefix(toolCall.Name, "mcp_") {
247+ item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
248+ } else {
249+ // TODO: Implement other tool items
250+ item = newBaseToolMessageItem(
251+ sty,
252+ toolCall,
253+ result,
254+ &DefaultToolRenderContext{},
255+ canceled,
256+ )
257+ }
258+ }
259+ item.SetMessageID(messageID)
260+ return item
261+}
262+
263+// SetCompact implements the Compactable interface.
264+func (t *baseToolMessageItem) SetCompact(compact bool) {
265+ t.isCompact = compact
266+ t.clearCache()
267+}
268+
269+// ID returns the unique identifier for this tool message item.
270+func (t *baseToolMessageItem) ID() string {
271+ return t.toolCall.ID
272+}
273+
274+// StartAnimation starts the assistant message animation if it should be spinning.
275+func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
276+ if !t.isSpinning() {
277+ return nil
278+ }
279+ return t.anim.Start()
280+}
281+
282+// Animate progresses the assistant message animation if it should be spinning.
283+func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
284+ if !t.isSpinning() {
285+ return nil
286+ }
287+ return t.anim.Animate(msg)
288+}
289+
290+// RawRender implements [MessageItem].
291+func (t *baseToolMessageItem) RawRender(width int) string {
292+ toolItemWidth := width - messageLeftPaddingTotal
293+ if t.hasCappedWidth {
294+ toolItemWidth = cappedMessageWidth(width)
295+ }
296+
297+ content, height, ok := t.getCachedRender(toolItemWidth)
298+ // if we are spinning or there is no cache rerender
299+ if !ok || t.isSpinning() {
300+ content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
301+ ToolCall: t.toolCall,
302+ Result: t.result,
303+ Anim: t.anim,
304+ ExpandedContent: t.expandedContent,
305+ Compact: t.isCompact,
306+ IsSpinning: t.isSpinning(),
307+ Status: t.computeStatus(),
308+ })
309+ height = lipgloss.Height(content)
310+ // cache the rendered content
311+ t.setCachedRender(content, toolItemWidth, height)
312+ }
313+
314+ return t.renderHighlighted(content, toolItemWidth, height)
315+}
316+
317+// Render renders the tool message item at the given width.
318+func (t *baseToolMessageItem) Render(width int) string {
319+ style := t.sty.Chat.Message.ToolCallBlurred
320+ if t.focused {
321+ style = t.sty.Chat.Message.ToolCallFocused
322+ }
323+
324+ if t.isCompact {
325+ style = t.sty.Chat.Message.ToolCallCompact
326+ }
327+
328+ return style.Render(t.RawRender(width))
329+}
330+
331+// ToolCall returns the tool call associated with this message item.
332+func (t *baseToolMessageItem) ToolCall() message.ToolCall {
333+ return t.toolCall
334+}
335+
336+// SetToolCall sets the tool call associated with this message item.
337+func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
338+ t.toolCall = tc
339+ t.clearCache()
340+}
341+
342+// SetResult sets the tool result associated with this message item.
343+func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
344+ t.result = res
345+ t.clearCache()
346+}
347+
348+// MessageID returns the ID of the message containing this tool call.
349+func (t *baseToolMessageItem) MessageID() string {
350+ return t.messageID
351+}
352+
353+// SetMessageID sets the ID of the message containing this tool call.
354+func (t *baseToolMessageItem) SetMessageID(id string) {
355+ t.messageID = id
356+}
357+
358+// SetStatus sets the tool status.
359+func (t *baseToolMessageItem) SetStatus(status ToolStatus) {
360+ t.status = status
361+ t.clearCache()
362+}
363+
364+// Status returns the current tool status.
365+func (t *baseToolMessageItem) Status() ToolStatus {
366+ return t.status
367+}
368+
369+// computeStatus computes the effective status considering the result.
370+func (t *baseToolMessageItem) computeStatus() ToolStatus {
371+ if t.result != nil {
372+ if t.result.IsError {
373+ return ToolStatusError
374+ }
375+ return ToolStatusSuccess
376+ }
377+ return t.status
378+}
379+
380+// isSpinning returns true if the tool should show animation.
381+func (t *baseToolMessageItem) isSpinning() bool {
382+ if t.spinningFunc != nil {
383+ return t.spinningFunc(SpinningState{
384+ ToolCall: t.toolCall,
385+ Result: t.result,
386+ Status: t.status,
387+ })
388+ }
389+ return !t.toolCall.Finished && t.status != ToolStatusCanceled
390+}
391+
392+// SetSpinningFunc sets a custom function to determine if the tool should spin.
393+func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) {
394+ t.spinningFunc = fn
395+}
396+
397+// ToggleExpanded toggles the expanded state of the thinking box.
398+func (t *baseToolMessageItem) ToggleExpanded() {
399+ t.expandedContent = !t.expandedContent
400+ t.clearCache()
401+}
402+
403+// HandleMouseClick implements MouseClickable.
404+func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
405+ if btn != ansi.MouseLeft {
406+ return false
407+ }
408+ t.ToggleExpanded()
409+ return true
410+}
411+
412+// pendingTool renders a tool that is still in progress with an animation.
413+func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
414+ icon := sty.Tool.IconPending.Render()
415+ toolName := sty.Tool.NameNormal.Render(name)
416+
417+ var animView string
418+ if anim != nil {
419+ animView = anim.Render()
420+ }
421+
422+ return fmt.Sprintf("%s %s %s", icon, toolName, animView)
423+}
424+
425+// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
426+// Returns the rendered output and true if early state was handled.
427+func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
428+ var msg string
429+ switch opts.Status {
430+ case ToolStatusError:
431+ msg = toolErrorContent(sty, opts.Result, width)
432+ case ToolStatusCanceled:
433+ msg = sty.Tool.StateCancelled.Render("Canceled.")
434+ case ToolStatusAwaitingPermission:
435+ msg = sty.Tool.StateWaiting.Render("Requesting permission...")
436+ case ToolStatusRunning:
437+ msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
438+ default:
439+ return "", false
440+ }
441+ return msg, true
442+}
443+
444+// toolErrorContent formats an error message with ERROR tag.
445+func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
446+ if result == nil {
447+ return ""
448+ }
449+ errContent := strings.ReplaceAll(result.Content, "\n", " ")
450+ errTag := sty.Tool.ErrorTag.Render("ERROR")
451+ tagWidth := lipgloss.Width(errTag)
452+ errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
453+ return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
454+}
455+
456+// toolIcon returns the status icon for a tool call.
457+// toolIcon returns the status icon for a tool call based on its status.
458+func toolIcon(sty *styles.Styles, status ToolStatus) string {
459+ switch status {
460+ case ToolStatusSuccess:
461+ return sty.Tool.IconSuccess.String()
462+ case ToolStatusError:
463+ return sty.Tool.IconError.String()
464+ case ToolStatusCanceled:
465+ return sty.Tool.IconCancelled.String()
466+ default:
467+ return sty.Tool.IconPending.String()
468+ }
469+}
470+
471+// toolParamList formats parameters as "main (key=value, ...)" with truncation.
472+// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
473+func toolParamList(sty *styles.Styles, params []string, width int) string {
474+ // minSpaceForMainParam is the min space required for the main param
475+ // if this is less that the value set we will only show the main param nothing else
476+ const minSpaceForMainParam = 30
477+ if len(params) == 0 {
478+ return ""
479+ }
480+
481+ mainParam := params[0]
482+
483+ // Build key=value pairs from remaining params (consecutive key, value pairs).
484+ var kvPairs []string
485+ for i := 1; i+1 < len(params); i += 2 {
486+ if params[i+1] != "" {
487+ kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
488+ }
489+ }
490+
491+ // Try to include key=value pairs if there's enough space.
492+ output := mainParam
493+ if len(kvPairs) > 0 {
494+ partsStr := strings.Join(kvPairs, ", ")
495+ if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
496+ output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
497+ }
498+ }
499+
500+ if width >= 0 {
501+ output = ansi.Truncate(output, width, "…")
502+ }
503+ return sty.Tool.ParamMain.Render(output)
504+}
505+
506+// toolHeader builds the tool header line: "● ToolName params..."
507+func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, nested bool, params ...string) string {
508+ icon := toolIcon(sty, status)
509+ nameStyle := sty.Tool.NameNormal
510+ if nested {
511+ nameStyle = sty.Tool.NameNested
512+ }
513+ toolName := nameStyle.Render(name)
514+ prefix := fmt.Sprintf("%s %s ", icon, toolName)
515+ prefixWidth := lipgloss.Width(prefix)
516+ remainingWidth := width - prefixWidth
517+ paramsStr := toolParamList(sty, params, remainingWidth)
518+ return prefix + paramsStr
519+}
520+
521+// toolOutputPlainContent renders plain text with optional expansion support.
522+func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
523+ content = strings.ReplaceAll(content, "\r\n", "\n")
524+ content = strings.ReplaceAll(content, "\t", " ")
525+ content = strings.TrimSpace(content)
526+ lines := strings.Split(content, "\n")
527+
528+ maxLines := responseContextHeight
529+ if expanded {
530+ maxLines = len(lines) // Show all
531+ }
532+
533+ var out []string
534+ for i, ln := range lines {
535+ if i >= maxLines {
536+ break
537+ }
538+ ln = " " + ln
539+ if lipgloss.Width(ln) > width {
540+ ln = ansi.Truncate(ln, width, "…")
541+ }
542+ out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
543+ }
544+
545+ wasTruncated := len(lines) > responseContextHeight
546+
547+ if !expanded && wasTruncated {
548+ out = append(out, sty.Tool.ContentTruncation.
549+ Width(width).
550+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-responseContextHeight)))
551+ }
552+
553+ return strings.Join(out, "\n")
554+}
555+
556+// toolOutputCodeContent renders code with syntax highlighting and line numbers.
557+func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string {
558+ content = strings.ReplaceAll(content, "\r\n", "\n")
559+ content = strings.ReplaceAll(content, "\t", " ")
560+
561+ lines := strings.Split(content, "\n")
562+ maxLines := responseContextHeight
563+ if expanded {
564+ maxLines = len(lines)
565+ }
566+
567+ // Truncate if needed.
568+ displayLines := lines
569+ if len(lines) > maxLines {
570+ displayLines = lines[:maxLines]
571+ }
572+
573+ bg := sty.Tool.ContentCodeBg
574+ highlighted, _ := common.SyntaxHighlight(sty, strings.Join(displayLines, "\n"), path, bg)
575+ highlightedLines := strings.Split(highlighted, "\n")
576+
577+ // Calculate line number width.
578+ maxLineNumber := len(displayLines) + offset
579+ maxDigits := getDigits(maxLineNumber)
580+ numFmt := fmt.Sprintf("%%%dd", maxDigits)
581+
582+ bodyWidth := width - toolBodyLeftPaddingTotal
583+ codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding
584+
585+ var out []string
586+ for i, ln := range highlightedLines {
587+ lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset))
588+
589+ if lipgloss.Width(ln) > codeWidth {
590+ ln = ansi.Truncate(ln, codeWidth, "…")
591+ }
592+
593+ codeLine := sty.Tool.ContentCodeLine.
594+ Width(codeWidth).
595+ PaddingLeft(2).
596+ Render(ln)
597+
598+ out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
599+ }
600+
601+ // Add truncation message if needed.
602+ if len(lines) > maxLines && !expanded {
603+ out = append(out, sty.Tool.ContentCodeTruncation.
604+ Width(bodyWidth).
605+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
606+ )
607+ }
608+
609+ return sty.Tool.Body.Render(strings.Join(out, "\n"))
610+}
611+
612+// toolOutputImageContent renders image data with size info.
613+func toolOutputImageContent(sty *styles.Styles, data, mediaType string) string {
614+ dataSize := len(data) * 3 / 4
615+ sizeStr := formatSize(dataSize)
616+
617+ loaded := sty.Base.Foreground(sty.Green).Render("Loaded")
618+ arrow := sty.Base.Foreground(sty.GreenDark).Render("→")
619+ typeStyled := sty.Base.Render(mediaType)
620+ sizeStyled := sty.Subtle.Render(sizeStr)
621+
622+ return sty.Tool.Body.Render(fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled))
623+}
624+
625+// getDigits returns the number of digits in a number.
626+func getDigits(n int) int {
627+ if n == 0 {
628+ return 1
629+ }
630+ if n < 0 {
631+ n = -n
632+ }
633+ digits := 0
634+ for n > 0 {
635+ n /= 10
636+ digits++
637+ }
638+ return digits
639+}
640+
641+// formatSize formats byte size into human readable format.
642+func formatSize(bytes int) string {
643+ const (
644+ kb = 1024
645+ mb = kb * 1024
646+ )
647+ switch {
648+ case bytes >= mb:
649+ return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
650+ case bytes >= kb:
651+ return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
652+ default:
653+ return fmt.Sprintf("%d B", bytes)
654+ }
655+}
656+
657+// toolOutputDiffContent renders a diff between old and new content.
658+func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent string, width int, expanded bool) string {
659+ bodyWidth := width - toolBodyLeftPaddingTotal
660+
661+ formatter := common.DiffFormatter(sty).
662+ Before(file, oldContent).
663+ After(file, newContent).
664+ Width(bodyWidth)
665+
666+ // Use split view for wide terminals.
667+ if width > maxTextWidth {
668+ formatter = formatter.Split()
669+ }
670+
671+ formatted := formatter.String()
672+ lines := strings.Split(formatted, "\n")
673+
674+ // Truncate if needed.
675+ maxLines := responseContextHeight
676+ if expanded {
677+ maxLines = len(lines)
678+ }
679+
680+ if len(lines) > maxLines && !expanded {
681+ truncMsg := sty.Tool.DiffTruncation.
682+ Width(bodyWidth).
683+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
684+ formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
685+ }
686+
687+ return sty.Tool.Body.Render(formatted)
688+}
689+
690+// formatTimeout converts timeout seconds to a duration string (e.g., "30s").
691+// Returns empty string if timeout is 0.
692+func formatTimeout(timeout int) string {
693+ if timeout == 0 {
694+ return ""
695+ }
696+ return fmt.Sprintf("%ds", timeout)
697+}
698+
699+// formatNonZero returns string representation of non-zero integers, empty string for zero.
700+func formatNonZero(value int) string {
701+ if value == 0 {
702+ return ""
703+ }
704+ return fmt.Sprintf("%d", value)
705+}
706+
707+// toolOutputMultiEditDiffContent renders a diff with optional failed edits note.
708+func toolOutputMultiEditDiffContent(sty *styles.Styles, file string, meta tools.MultiEditResponseMetadata, totalEdits, width int, expanded bool) string {
709+ bodyWidth := width - toolBodyLeftPaddingTotal
710+
711+ formatter := common.DiffFormatter(sty).
712+ Before(file, meta.OldContent).
713+ After(file, meta.NewContent).
714+ Width(bodyWidth)
715+
716+ // Use split view for wide terminals.
717+ if width > maxTextWidth {
718+ formatter = formatter.Split()
719+ }
720+
721+ formatted := formatter.String()
722+ lines := strings.Split(formatted, "\n")
723+
724+ // Truncate if needed.
725+ maxLines := responseContextHeight
726+ if expanded {
727+ maxLines = len(lines)
728+ }
729+
730+ if len(lines) > maxLines && !expanded {
731+ truncMsg := sty.Tool.DiffTruncation.
732+ Width(bodyWidth).
733+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines))
734+ formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n")
735+ }
736+
737+ // Add failed edits note if any exist.
738+ if len(meta.EditsFailed) > 0 {
739+ noteTag := sty.Tool.NoteTag.Render("Note")
740+ noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, totalEdits)
741+ note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
742+ formatted = formatted + "\n\n" + note
743+ }
744+
745+ return sty.Tool.Body.Render(formatted)
746+}
747+
748+// roundedEnumerator creates a tree enumerator with rounded corners.
749+func roundedEnumerator(lPadding, width int) tree.Enumerator {
750+ if width == 0 {
751+ width = 2
752+ }
753+ if lPadding == 0 {
754+ lPadding = 1
755+ }
756+ return func(children tree.Children, index int) string {
757+ line := strings.Repeat("─", width)
758+ padding := strings.Repeat(" ", lPadding)
759+ if children.Length()-1 == index {
760+ return padding + "╰" + line
761+ }
762+ return padding + "├" + line
763+ }
764+}
765+
766+// toolOutputMarkdownContent renders markdown content with optional truncation.
767+func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string {
768+ content = strings.ReplaceAll(content, "\r\n", "\n")
769+ content = strings.ReplaceAll(content, "\t", " ")
770+ content = strings.TrimSpace(content)
771+
772+ // Cap width for readability.
773+ if width > maxTextWidth {
774+ width = maxTextWidth
775+ }
776+
777+ renderer := common.PlainMarkdownRenderer(sty, width)
778+ rendered, err := renderer.Render(content)
779+ if err != nil {
780+ return toolOutputPlainContent(sty, content, width, expanded)
781+ }
782+
783+ lines := strings.Split(rendered, "\n")
784+ maxLines := responseContextHeight
785+ if expanded {
786+ maxLines = len(lines)
787+ }
788+
789+ var out []string
790+ for i, ln := range lines {
791+ if i >= maxLines {
792+ break
793+ }
794+ out = append(out, ln)
795+ }
796+
797+ if len(lines) > maxLines && !expanded {
798+ out = append(out, sty.Tool.ContentTruncation.
799+ Width(width).
800+ Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)),
801+ )
802+ }
803+
804+ return sty.Tool.Body.Render(strings.Join(out, "\n"))
805+}
internal/ui/chat/user.gonew file+94
@@ -0,0 +1,94 @@
1+package chat
2+
3+import (
4+ "strings"
5+
6+ "charm.land/lipgloss/v2"
7+ "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/ui/attachments"
9+ "github.com/charmbracelet/crush/internal/ui/common"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+)
12+
13+// UserMessageItem represents a user message in the chat UI.
14+type UserMessageItem struct {
15+ *highlightableMessageItem
16+ *cachedMessageItem
17+ *focusableMessageItem
18+
19+ attachments *attachments.Renderer
20+ message *message.Message
21+ sty *styles.Styles
22+}
23+
24+// NewUserMessageItem creates a new UserMessageItem.
25+func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem {
26+ return &UserMessageItem{
27+ highlightableMessageItem: defaultHighlighter(sty),
28+ cachedMessageItem: &cachedMessageItem{},
29+ focusableMessageItem: &focusableMessageItem{},
30+ attachments: attachments,
31+ message: message,
32+ sty: sty,
33+ }
34+}
35+
36+// RawRender implements [MessageItem].
37+func (m *UserMessageItem) RawRender(width int) string {
38+ cappedWidth := cappedMessageWidth(width)
39+
40+ content, height, ok := m.getCachedRender(cappedWidth)
41+ // cache hit
42+ if ok {
43+ return m.renderHighlighted(content, cappedWidth, height)
44+ }
45+
46+ renderer := common.MarkdownRenderer(m.sty, cappedWidth)
47+
48+ msgContent := strings.TrimSpace(m.message.Content().Text)
49+ result, err := renderer.Render(msgContent)
50+ if err != nil {
51+ content = msgContent
52+ } else {
53+ content = strings.TrimSuffix(result, "\n")
54+ }
55+
56+ if len(m.message.BinaryContent()) > 0 {
57+ attachmentsStr := m.renderAttachments(cappedWidth)
58+ if content == "" {
59+ content = attachmentsStr
60+ } else {
61+ content = strings.Join([]string{content, "", attachmentsStr}, "\n")
62+ }
63+ }
64+
65+ height = lipgloss.Height(content)
66+ m.setCachedRender(content, cappedWidth, height)
67+ return m.renderHighlighted(content, cappedWidth, height)
68+}
69+
70+// Render implements MessageItem.
71+func (m *UserMessageItem) Render(width int) string {
72+ style := m.sty.Chat.Message.UserBlurred
73+ if m.focused {
74+ style = m.sty.Chat.Message.UserFocused
75+ }
76+ return style.Render(m.RawRender(width))
77+}
78+
79+// ID implements MessageItem.
80+func (m *UserMessageItem) ID() string {
81+ return m.message.ID
82+}
83+
84+// renderAttachments renders attachments.
85+func (m *UserMessageItem) renderAttachments(width int) string {
86+ var attachments []message.Attachment
87+ for _, at := range m.message.BinaryContent() {
88+ attachments = append(attachments, message.Attachment{
89+ FileName: at.Path,
90+ MimeType: at.MIMEType,
91+ })
92+ }
93+ return m.attachments.Render(attachments, false, width)
94+}
internal/ui/common/button.gonew file+69
@@ -0,0 +1,69 @@
1+package common
2+
3+import (
4+ "strings"
5+
6+ "charm.land/lipgloss/v2"
7+ "github.com/charmbracelet/crush/internal/ui/styles"
8+)
9+
10+// ButtonOpts defines the configuration for a single button
11+type ButtonOpts struct {
12+ // Text is the button label
13+ Text string
14+ // UnderlineIndex is the 0-based index of the character to underline (-1 for none)
15+ UnderlineIndex int
16+ // Selected indicates whether this button is currently selected
17+ Selected bool
18+ // Padding inner horizontal padding defaults to 2 if this is 0
19+ Padding int
20+}
21+
22+// Button creates a button with an underlined character and selection state
23+func Button(t *styles.Styles, opts ButtonOpts) string {
24+ // Select style based on selection state
25+ style := t.ButtonBlur
26+ if opts.Selected {
27+ style = t.ButtonFocus
28+ }
29+
30+ text := opts.Text
31+ if opts.Padding == 0 {
32+ opts.Padding = 2
33+ }
34+
35+ // the index is out of bound
36+ if opts.UnderlineIndex > -1 && opts.UnderlineIndex > len(text)-1 {
37+ opts.UnderlineIndex = -1
38+ }
39+
40+ text = style.Padding(0, opts.Padding).Render(text)
41+
42+ if opts.UnderlineIndex != -1 {
43+ text = lipgloss.StyleRanges(text, lipgloss.NewRange(opts.Padding+opts.UnderlineIndex, opts.Padding+opts.UnderlineIndex+1, style.Underline(true)))
44+ }
45+
46+ return text
47+}
48+
49+// ButtonGroup creates a row of selectable buttons
50+// Spacing is the separator between buttons
51+// Use " " or similar for horizontal layout
52+// Use "\n" for vertical layout
53+// Defaults to " " (horizontal)
54+func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string {
55+ if len(buttons) == 0 {
56+ return ""
57+ }
58+
59+ if spacing == "" {
60+ spacing = " "
61+ }
62+
63+ parts := make([]string, len(buttons))
64+ for i, button := range buttons {
65+ parts[i] = Button(t, button)
66+ }
67+
68+ return strings.Join(parts, spacing)
69+}
internal/ui/common/common.gonew file+65
@@ -0,0 +1,65 @@
1+package common
2+
3+import (
4+ "fmt"
5+ "image"
6+ "os"
7+
8+ "github.com/charmbracelet/crush/internal/app"
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+ uv "github.com/charmbracelet/ultraviolet"
12+)
13+
14+// MaxAttachmentSize defines the maximum allowed size for file attachments (5 MB).
15+const MaxAttachmentSize = int64(5 * 1024 * 1024)
16+
17+// AllowedImageTypes defines the permitted image file types.
18+var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
19+
20+// Common defines common UI options and configurations.
21+type Common struct {
22+ App *app.App
23+ Styles *styles.Styles
24+}
25+
26+// Config returns the configuration associated with this [Common] instance.
27+func (c *Common) Config() *config.Config {
28+ return c.App.Config()
29+}
30+
31+// DefaultCommon returns the default common UI configurations.
32+func DefaultCommon(app *app.App) *Common {
33+ s := styles.DefaultStyles()
34+ return &Common{
35+ App: app,
36+ Styles: &s,
37+ }
38+}
39+
40+// CenterRect returns a new [Rectangle] centered within the given area with the
41+// specified width and height.
42+func CenterRect(area uv.Rectangle, width, height int) uv.Rectangle {
43+ centerX := area.Min.X + area.Dx()/2
44+ centerY := area.Min.Y + area.Dy()/2
45+ minX := centerX - width/2
46+ minY := centerY - height/2
47+ maxX := minX + width
48+ maxY := minY + height
49+ return image.Rect(minX, minY, maxX, maxY)
50+}
51+
52+// IsFileTooBig checks if the file at the given path exceeds the specified size
53+// limit.
54+func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
55+ fileInfo, err := os.Stat(filePath)
56+ if err != nil {
57+ return false, fmt.Errorf("error getting file info: %w", err)
58+ }
59+
60+ if fileInfo.Size() > sizeLimit {
61+ return true, nil
62+ }
63+
64+ return false, nil
65+}
internal/ui/common/diff.gonew file+16
@@ -0,0 +1,16 @@
1+package common
2+
3+import (
4+ "github.com/alecthomas/chroma/v2"
5+ "github.com/charmbracelet/crush/internal/tui/exp/diffview"
6+ "github.com/charmbracelet/crush/internal/ui/styles"
7+)
8+
9+// DiffFormatter returns a diff formatter with the given styles that can be
10+// used to format diff outputs.
11+func DiffFormatter(s *styles.Styles) *diffview.DiffView {
12+ formatDiff := diffview.New()
13+ style := chroma.MustNewStyle("crush", s.ChromaTheme())
14+ diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4)
15+ return diff
16+}
internal/ui/common/elements.gonew file+190
@@ -0,0 +1,190 @@
1+package common
2+
3+import (
4+ "cmp"
5+ "fmt"
6+ "image/color"
7+ "strings"
8+
9+ "charm.land/lipgloss/v2"
10+ "github.com/charmbracelet/crush/internal/home"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+ "github.com/charmbracelet/x/ansi"
13+)
14+
15+// PrettyPath formats a file path with home directory shortening and applies
16+// muted styling.
17+func PrettyPath(t *styles.Styles, path string, width int) string {
18+ formatted := home.Short(path)
19+ return t.Muted.Width(width).Render(formatted)
20+}
21+
22+// ModelContextInfo contains token usage and cost information for a model.
23+type ModelContextInfo struct {
24+ ContextUsed int64
25+ ModelContext int64
26+ Cost float64
27+}
28+
29+// ModelInfo renders model information including name, provider, reasoning
30+// settings, and optional context usage/cost.
31+func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int) string {
32+ modelIcon := t.Subtle.Render(styles.ModelIcon)
33+ modelName = t.Base.Render(modelName)
34+
35+ // Build first line with model name and optionally provider on the same line
36+ var firstLine string
37+ if providerName != "" {
38+ providerInfo := t.Muted.Render(fmt.Sprintf("via %s", providerName))
39+ modelWithProvider := fmt.Sprintf("%s %s %s", modelIcon, modelName, providerInfo)
40+
41+ // Check if it fits on one line
42+ if lipgloss.Width(modelWithProvider) <= width {
43+ firstLine = modelWithProvider
44+ } else {
45+ // If it doesn't fit, put provider on next line
46+ firstLine = fmt.Sprintf("%s %s", modelIcon, modelName)
47+ }
48+ } else {
49+ firstLine = fmt.Sprintf("%s %s", modelIcon, modelName)
50+ }
51+
52+ parts := []string{firstLine}
53+
54+ // If provider didn't fit on first line, add it as second line
55+ if providerName != "" && !strings.Contains(firstLine, "via") {
56+ providerInfo := fmt.Sprintf("via %s", providerName)
57+ parts = append(parts, t.Muted.PaddingLeft(2).Render(providerInfo))
58+ }
59+
60+ if reasoningInfo != "" {
61+ parts = append(parts, t.Subtle.PaddingLeft(2).Render(reasoningInfo))
62+ }
63+
64+ if context != nil {
65+ formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)
66+ parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
67+ }
68+
69+ return lipgloss.NewStyle().Width(width).Render(
70+ lipgloss.JoinVertical(lipgloss.Left, parts...),
71+ )
72+}
73+
74+// formatTokensAndCost formats token usage and cost with appropriate units
75+// (K/M) and percentage of context window.
76+func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
77+ var formattedTokens string
78+ switch {
79+ case tokens >= 1_000_000:
80+ formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
81+ case tokens >= 1_000:
82+ formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
83+ default:
84+ formattedTokens = fmt.Sprintf("%d", tokens)
85+ }
86+
87+ if strings.HasSuffix(formattedTokens, ".0K") {
88+ formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
89+ }
90+ if strings.HasSuffix(formattedTokens, ".0M") {
91+ formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
92+ }
93+
94+ percentage := (float64(tokens) / float64(contextWindow)) * 100
95+
96+ formattedCost := t.Muted.Render(fmt.Sprintf("$%.2f", cost))
97+
98+ formattedTokens = t.Subtle.Render(fmt.Sprintf("(%s)", formattedTokens))
99+ formattedPercentage := t.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
100+ formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
101+ if percentage > 80 {
102+ formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
103+ }
104+
105+ return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
106+}
107+
108+// StatusOpts defines options for rendering a status line with icon, title,
109+// description, and optional extra content.
110+type StatusOpts struct {
111+ Icon string // if empty no icon will be shown
112+ Title string
113+ TitleColor color.Color
114+ Description string
115+ DescriptionColor color.Color
116+ ExtraContent string // additional content to append after the description
117+}
118+
119+// Status renders a status line with icon, title, description, and extra
120+// content. The description is truncated if it exceeds the available width.
121+func Status(t *styles.Styles, opts StatusOpts, width int) string {
122+ icon := opts.Icon
123+ title := opts.Title
124+ description := opts.Description
125+
126+ titleColor := cmp.Or(opts.TitleColor, t.Muted.GetForeground())
127+ descriptionColor := cmp.Or(opts.DescriptionColor, t.Subtle.GetForeground())
128+
129+ title = t.Base.Foreground(titleColor).Render(title)
130+
131+ if description != "" {
132+ extraContentWidth := lipgloss.Width(opts.ExtraContent)
133+ if extraContentWidth > 0 {
134+ extraContentWidth += 1
135+ }
136+ description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
137+ description = t.Base.Foreground(descriptionColor).Render(description)
138+ }
139+
140+ content := []string{}
141+ if icon != "" {
142+ content = append(content, icon)
143+ }
144+ content = append(content, title)
145+ if description != "" {
146+ content = append(content, description)
147+ }
148+ if opts.ExtraContent != "" {
149+ content = append(content, opts.ExtraContent)
150+ }
151+
152+ return strings.Join(content, " ")
153+}
154+
155+// Section renders a section header with a title and a horizontal line filling
156+// the remaining width.
157+func Section(t *styles.Styles, text string, width int, info ...string) string {
158+ char := styles.SectionSeparator
159+ length := lipgloss.Width(text) + 1
160+ remainingWidth := width - length
161+
162+ var infoText string
163+ if len(info) > 0 {
164+ infoText = strings.Join(info, " ")
165+ if len(infoText) > 0 {
166+ infoText = " " + infoText
167+ remainingWidth -= lipgloss.Width(infoText)
168+ }
169+ }
170+
171+ text = t.Section.Title.Render(text)
172+ if remainingWidth > 0 {
173+ text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + infoText
174+ }
175+ return text
176+}
177+
178+// DialogTitle renders a dialog title with a decorative line filling the
179+// remaining width.
180+func DialogTitle(t *styles.Styles, title string, width int) string {
181+ char := "╱"
182+ length := lipgloss.Width(title) + 1
183+ remainingWidth := width - length
184+ if remainingWidth > 0 {
185+ lines := strings.Repeat(char, remainingWidth)
186+ lines = styles.ApplyForegroundGrad(t, lines, t.Primary, t.Secondary)
187+ title = title + " " + lines
188+ }
189+ return title
190+}
internal/ui/common/highlight.gonew file+57
@@ -0,0 +1,57 @@
1+package common
2+
3+import (
4+ "bytes"
5+ "image/color"
6+
7+ "github.com/alecthomas/chroma/v2"
8+ "github.com/alecthomas/chroma/v2/formatters"
9+ "github.com/alecthomas/chroma/v2/lexers"
10+ chromastyles "github.com/alecthomas/chroma/v2/styles"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+)
13+
14+// SyntaxHighlight applies syntax highlighting to the given source code based
15+// on the file name and background color. It returns the highlighted code as a
16+// string.
17+func SyntaxHighlight(st *styles.Styles, source, fileName string, bg color.Color) (string, error) {
18+ // Determine the language lexer to use
19+ l := lexers.Match(fileName)
20+ if l == nil {
21+ l = lexers.Analyse(source)
22+ }
23+ if l == nil {
24+ l = lexers.Fallback
25+ }
26+ l = chroma.Coalesce(l)
27+
28+ // Get the formatter
29+ f := formatters.Get("terminal16m")
30+ if f == nil {
31+ f = formatters.Fallback
32+ }
33+
34+ style := chroma.MustNewStyle("crush", st.ChromaTheme())
35+
36+ // Modify the style to use the provided background
37+ s, err := style.Builder().Transform(
38+ func(t chroma.StyleEntry) chroma.StyleEntry {
39+ r, g, b, _ := bg.RGBA()
40+ t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
41+ return t
42+ },
43+ ).Build()
44+ if err != nil {
45+ s = chromastyles.Fallback
46+ }
47+
48+ // Tokenize and format
49+ it, err := l.Tokenise(nil, source)
50+ if err != nil {
51+ return "", err
52+ }
53+
54+ var buf bytes.Buffer
55+ err = f.Format(&buf, s, it)
56+ return buf.String(), err
57+}
internal/ui/common/interface.gonew file+11
@@ -0,0 +1,11 @@
1+package common
2+
3+import (
4+ tea "charm.land/bubbletea/v2"
5+)
6+
7+// Model represents a common interface for UI components.
8+type Model[T any] interface {
9+ Update(msg tea.Msg) (T, tea.Cmd)
10+ View() string
11+}
internal/ui/common/markdown.gonew file+26
@@ -0,0 +1,26 @@
1+package common
2+
3+import (
4+ "charm.land/glamour/v2"
5+ "github.com/charmbracelet/crush/internal/ui/styles"
6+)
7+
8+// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
9+// the given styles and width.
10+func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
11+ r, _ := glamour.NewTermRenderer(
12+ glamour.WithStyles(sty.Markdown),
13+ glamour.WithWordWrap(width),
14+ )
15+ return r
16+}
17+
18+// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
19+// (plain text with structure) and the given width.
20+func PlainMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
21+ r, _ := glamour.NewTermRenderer(
22+ glamour.WithStyles(sty.PlainMarkdown),
23+ glamour.WithWordWrap(width),
24+ )
25+ return r
26+}
internal/ui/common/scrollbar.gonew file+46
@@ -0,0 +1,46 @@
1+package common
2+
3+import (
4+ "strings"
5+
6+ "github.com/charmbracelet/crush/internal/ui/styles"
7+)
8+
9+// Scrollbar renders a vertical scrollbar based on content and viewport size.
10+// Returns an empty string if content fits within viewport (no scrolling needed).
11+func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int) string {
12+ if height <= 0 || contentSize <= viewportSize {
13+ return ""
14+ }
15+
16+ // Calculate thumb size (minimum 1 character).
17+ thumbSize := max(1, height*viewportSize/contentSize)
18+
19+ // Calculate thumb position.
20+ maxOffset := contentSize - viewportSize
21+ if maxOffset <= 0 {
22+ return ""
23+ }
24+
25+ // Calculate where the thumb starts.
26+ trackSpace := height - thumbSize
27+ thumbPos := 0
28+ if trackSpace > 0 && maxOffset > 0 {
29+ thumbPos = min(trackSpace, offset*trackSpace/maxOffset)
30+ }
31+
32+ // Build the scrollbar.
33+ var sb strings.Builder
34+ for i := range height {
35+ if i > 0 {
36+ sb.WriteString("\n")
37+ }
38+ if i >= thumbPos && i < thumbPos+thumbSize {
39+ sb.WriteString(s.Dialog.ScrollbarThumb.Render(styles.ScrollbarThumb))
40+ } else {
41+ sb.WriteString(s.Dialog.ScrollbarTrack.Render(styles.ScrollbarTrack))
42+ }
43+ }
44+
45+ return sb.String()
46+}
internal/ui/completions/completions.gonew file+267
@@ -0,0 +1,267 @@
1+package completions
2+
3+import (
4+ "slices"
5+ "strings"
6+
7+ "charm.land/bubbles/v2/key"
8+ tea "charm.land/bubbletea/v2"
9+ "charm.land/lipgloss/v2"
10+ "github.com/charmbracelet/crush/internal/fsext"
11+ "github.com/charmbracelet/crush/internal/ui/list"
12+ "github.com/charmbracelet/x/ansi"
13+ "github.com/charmbracelet/x/exp/ordered"
14+)
15+
16+const (
17+ minHeight = 1
18+ maxHeight = 10
19+ minWidth = 10
20+ maxWidth = 100
21+)
22+
23+// SelectionMsg is sent when a completion is selected.
24+type SelectionMsg struct {
25+ Value any
26+ Insert bool // If true, insert without closing.
27+}
28+
29+// ClosedMsg is sent when the completions are closed.
30+type ClosedMsg struct{}
31+
32+// FilesLoadedMsg is sent when files have been loaded for completions.
33+type FilesLoadedMsg struct {
34+ Files []string
35+}
36+
37+// Completions represents the completions popup component.
38+type Completions struct {
39+ // Popup dimensions
40+ width int
41+ height int
42+
43+ // State
44+ open bool
45+ query string
46+
47+ // Key bindings
48+ keyMap KeyMap
49+
50+ // List component
51+ list *list.FilterableList
52+
53+ // Styling
54+ normalStyle lipgloss.Style
55+ focusedStyle lipgloss.Style
56+ matchStyle lipgloss.Style
57+}
58+
59+// New creates a new completions component.
60+func New(normalStyle, focusedStyle, matchStyle lipgloss.Style) *Completions {
61+ l := list.NewFilterableList()
62+ l.SetGap(0)
63+ l.SetReverse(true)
64+
65+ return &Completions{
66+ keyMap: DefaultKeyMap(),
67+ list: l,
68+ normalStyle: normalStyle,
69+ focusedStyle: focusedStyle,
70+ matchStyle: matchStyle,
71+ }
72+}
73+
74+// IsOpen returns whether the completions popup is open.
75+func (c *Completions) IsOpen() bool {
76+ return c.open
77+}
78+
79+// Query returns the current filter query.
80+func (c *Completions) Query() string {
81+ return c.query
82+}
83+
84+// Size returns the visible size of the popup.
85+func (c *Completions) Size() (width, height int) {
86+ visible := len(c.list.VisibleItems())
87+ return c.width, min(visible, c.height)
88+}
89+
90+// KeyMap returns the key bindings.
91+func (c *Completions) KeyMap() KeyMap {
92+ return c.keyMap
93+}
94+
95+// OpenWithFiles opens the completions with file items from the filesystem.
96+func (c *Completions) OpenWithFiles(depth, limit int) tea.Cmd {
97+ return func() tea.Msg {
98+ files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
99+ slices.Sort(files)
100+ return FilesLoadedMsg{Files: files}
101+ }
102+}
103+
104+// SetFiles sets the file items on the completions popup.
105+func (c *Completions) SetFiles(files []string) {
106+ items := make([]list.FilterableItem, 0, len(files))
107+ width := 0
108+ for _, file := range files {
109+ file = strings.TrimPrefix(file, "./")
110+ item := NewCompletionItem(
111+ file,
112+ FileCompletionValue{Path: file},
113+ c.normalStyle,
114+ c.focusedStyle,
115+ c.matchStyle,
116+ )
117+
118+ width = max(width, ansi.StringWidth(file))
119+ items = append(items, item)
120+ }
121+
122+ c.open = true
123+ c.query = ""
124+ c.list.SetItems(items...)
125+ c.list.SetFilter("") // Clear any previous filter.
126+ c.list.Focus()
127+
128+ c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
129+ c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
130+ c.list.SetSize(c.width, c.height)
131+ c.list.SelectFirst()
132+ c.list.ScrollToSelected()
133+}
134+
135+// Close closes the completions popup.
136+func (c *Completions) Close() {
137+ c.open = false
138+}
139+
140+// Filter filters the completions with the given query.
141+func (c *Completions) Filter(query string) {
142+ if !c.open {
143+ return
144+ }
145+
146+ if query == c.query {
147+ return
148+ }
149+
150+ c.query = query
151+ c.list.SetFilter(query)
152+
153+ items := c.list.VisibleItems()
154+ width := 0
155+ for _, item := range items {
156+ width = max(width, ansi.StringWidth(item.(interface{ Text() string }).Text()))
157+ }
158+ c.width = ordered.Clamp(width+2, int(minWidth), int(maxWidth))
159+ c.height = ordered.Clamp(len(items), int(minHeight), int(maxHeight))
160+ c.list.SetSize(c.width, c.height)
161+ c.list.SelectFirst()
162+ c.list.ScrollToSelected()
163+}
164+
165+// HasItems returns whether there are visible items.
166+func (c *Completions) HasItems() bool {
167+ return len(c.list.VisibleItems()) > 0
168+}
169+
170+// Update handles key events for the completions.
171+func (c *Completions) Update(msg tea.KeyPressMsg) (tea.Msg, bool) {
172+ if !c.open {
173+ return nil, false
174+ }
175+
176+ switch {
177+ case key.Matches(msg, c.keyMap.Up):
178+ c.selectPrev()
179+ return nil, true
180+
181+ case key.Matches(msg, c.keyMap.Down):
182+ c.selectNext()
183+ return nil, true
184+
185+ case key.Matches(msg, c.keyMap.UpInsert):
186+ c.selectPrev()
187+ return c.selectCurrent(true), true
188+
189+ case key.Matches(msg, c.keyMap.DownInsert):
190+ c.selectNext()
191+ return c.selectCurrent(true), true
192+
193+ case key.Matches(msg, c.keyMap.Select):
194+ return c.selectCurrent(false), true
195+
196+ case key.Matches(msg, c.keyMap.Cancel):
197+ c.Close()
198+ return ClosedMsg{}, true
199+ }
200+
201+ return nil, false
202+}
203+
204+// selectPrev selects the previous item with circular navigation.
205+func (c *Completions) selectPrev() {
206+ items := c.list.VisibleItems()
207+ if len(items) == 0 {
208+ return
209+ }
210+ if !c.list.SelectPrev() {
211+ c.list.WrapToEnd()
212+ }
213+ c.list.ScrollToSelected()
214+}
215+
216+// selectNext selects the next item with circular navigation.
217+func (c *Completions) selectNext() {
218+ items := c.list.VisibleItems()
219+ if len(items) == 0 {
220+ return
221+ }
222+ if !c.list.SelectNext() {
223+ c.list.WrapToStart()
224+ }
225+ c.list.ScrollToSelected()
226+}
227+
228+// selectCurrent returns a command with the currently selected item.
229+func (c *Completions) selectCurrent(insert bool) tea.Msg {
230+ items := c.list.VisibleItems()
231+ if len(items) == 0 {
232+ return nil
233+ }
234+
235+ selected := c.list.Selected()
236+ if selected < 0 || selected >= len(items) {
237+ return nil
238+ }
239+
240+ item, ok := items[selected].(*CompletionItem)
241+ if !ok {
242+ return nil
243+ }
244+
245+ if !insert {
246+ c.open = false
247+ }
248+
249+ return SelectionMsg{
250+ Value: item.Value(),
251+ Insert: insert,
252+ }
253+}
254+
255+// Render renders the completions popup.
256+func (c *Completions) Render() string {
257+ if !c.open {
258+ return ""
259+ }
260+
261+ items := c.list.VisibleItems()
262+ if len(items) == 0 {
263+ return ""
264+ }
265+
266+ return c.list.Render()
267+}
internal/ui/completions/item.gonew file+185
@@ -0,0 +1,185 @@
1+package completions
2+
3+import (
4+ "charm.land/lipgloss/v2"
5+ "github.com/charmbracelet/crush/internal/ui/list"
6+ "github.com/charmbracelet/x/ansi"
7+ "github.com/rivo/uniseg"
8+ "github.com/sahilm/fuzzy"
9+)
10+
11+// FileCompletionValue represents a file path completion value.
12+type FileCompletionValue struct {
13+ Path string
14+}
15+
16+// CompletionItem represents an item in the completions list.
17+type CompletionItem struct {
18+ text string
19+ value any
20+ match fuzzy.Match
21+ focused bool
22+ cache map[int]string
23+
24+ // Styles
25+ normalStyle lipgloss.Style
26+ focusedStyle lipgloss.Style
27+ matchStyle lipgloss.Style
28+}
29+
30+// NewCompletionItem creates a new completion item.
31+func NewCompletionItem(text string, value any, normalStyle, focusedStyle, matchStyle lipgloss.Style) *CompletionItem {
32+ return &CompletionItem{
33+ text: text,
34+ value: value,
35+ normalStyle: normalStyle,
36+ focusedStyle: focusedStyle,
37+ matchStyle: matchStyle,
38+ }
39+}
40+
41+// Text returns the display text of the item.
42+func (c *CompletionItem) Text() string {
43+ return c.text
44+}
45+
46+// Value returns the value of the item.
47+func (c *CompletionItem) Value() any {
48+ return c.value
49+}
50+
51+// Filter implements [list.FilterableItem].
52+func (c *CompletionItem) Filter() string {
53+ return c.text
54+}
55+
56+// SetMatch implements [list.MatchSettable].
57+func (c *CompletionItem) SetMatch(m fuzzy.Match) {
58+ c.cache = nil
59+ c.match = m
60+}
61+
62+// SetFocused implements [list.Focusable].
63+func (c *CompletionItem) SetFocused(focused bool) {
64+ if c.focused != focused {
65+ c.cache = nil
66+ }
67+ c.focused = focused
68+}
69+
70+// Render implements [list.Item].
71+func (c *CompletionItem) Render(width int) string {
72+ return renderItem(
73+ c.normalStyle,
74+ c.focusedStyle,
75+ c.matchStyle,
76+ c.text,
77+ c.focused,
78+ width,
79+ c.cache,
80+ &c.match,
81+ )
82+}
83+
84+func renderItem(
85+ normalStyle, focusedStyle, matchStyle lipgloss.Style,
86+ text string,
87+ focused bool,
88+ width int,
89+ cache map[int]string,
90+ match *fuzzy.Match,
91+) string {
92+ if cache == nil {
93+ cache = make(map[int]string)
94+ }
95+
96+ cached, ok := cache[width]
97+ if ok {
98+ return cached
99+ }
100+
101+ innerWidth := width - 2 // Account for padding
102+ // Truncate if needed.
103+ if ansi.StringWidth(text) > innerWidth {
104+ text = ansi.Truncate(text, innerWidth, "…")
105+ }
106+
107+ // Select base style.
108+ style := normalStyle
109+ matchStyle = matchStyle.Background(style.GetBackground())
110+ if focused {
111+ style = focusedStyle
112+ matchStyle = matchStyle.Background(style.GetBackground())
113+ }
114+
115+ // Render full-width text with background.
116+ content := style.Padding(0, 1).Width(width).Render(text)
117+
118+ // Apply match highlighting using StyleRanges.
119+ if len(match.MatchedIndexes) > 0 {
120+ var ranges []lipgloss.Range
121+ for _, rng := range matchedRanges(match.MatchedIndexes) {
122+ start, stop := bytePosToVisibleCharPos(text, rng)
123+ // Offset by 1 for the padding space.
124+ ranges = append(ranges, lipgloss.NewRange(start+1, stop+2, matchStyle))
125+ }
126+ content = lipgloss.StyleRanges(content, ranges...)
127+ }
128+
129+ cache[width] = content
130+ return content
131+}
132+
133+// matchedRanges converts a list of match indexes into contiguous ranges.
134+func matchedRanges(in []int) [][2]int {
135+ if len(in) == 0 {
136+ return [][2]int{}
137+ }
138+ current := [2]int{in[0], in[0]}
139+ if len(in) == 1 {
140+ return [][2]int{current}
141+ }
142+ var out [][2]int
143+ for i := 1; i < len(in); i++ {
144+ if in[i] == current[1]+1 {
145+ current[1] = in[i]
146+ } else {
147+ out = append(out, current)
148+ current = [2]int{in[i], in[i]}
149+ }
150+ }
151+ out = append(out, current)
152+ return out
153+}
154+
155+// bytePosToVisibleCharPos converts byte positions to visible character positions.
156+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
157+ bytePos, byteStart, byteStop := 0, rng[0], rng[1]
158+ pos, start, stop := 0, 0, 0
159+ gr := uniseg.NewGraphemes(str)
160+ for byteStart > bytePos {
161+ if !gr.Next() {
162+ break
163+ }
164+ bytePos += len(gr.Str())
165+ pos += max(1, gr.Width())
166+ }
167+ start = pos
168+ for byteStop > bytePos {
169+ if !gr.Next() {
170+ break
171+ }
172+ bytePos += len(gr.Str())
173+ pos += max(1, gr.Width())
174+ }
175+ stop = pos
176+ return start, stop
177+}
178+
179+// Ensure CompletionItem implements the required interfaces.
180+var (
181+ _ list.Item = (*CompletionItem)(nil)
182+ _ list.FilterableItem = (*CompletionItem)(nil)
183+ _ list.MatchSettable = (*CompletionItem)(nil)
184+ _ list.Focusable = (*CompletionItem)(nil)
185+)
internal/ui/completions/keys.gonew file+74
@@ -0,0 +1,74 @@
1+package completions
2+
3+import (
4+ "charm.land/bubbles/v2/key"
5+)
6+
7+// KeyMap defines the key bindings for the completions component.
8+type KeyMap struct {
9+ Down,
10+ Up,
11+ Select,
12+ Cancel key.Binding
13+ DownInsert,
14+ UpInsert key.Binding
15+}
16+
17+// DefaultKeyMap returns the default key bindings for completions.
18+func DefaultKeyMap() KeyMap {
19+ return KeyMap{
20+ Down: key.NewBinding(
21+ key.WithKeys("down"),
22+ key.WithHelp("down", "move down"),
23+ ),
24+ Up: key.NewBinding(
25+ key.WithKeys("up"),
26+ key.WithHelp("up", "move up"),
27+ ),
28+ Select: key.NewBinding(
29+ key.WithKeys("enter", "tab", "ctrl+y"),
30+ key.WithHelp("enter", "select"),
31+ ),
32+ Cancel: key.NewBinding(
33+ key.WithKeys("esc", "alt+esc"),
34+ key.WithHelp("esc", "cancel"),
35+ ),
36+ DownInsert: key.NewBinding(
37+ key.WithKeys("ctrl+n"),
38+ key.WithHelp("ctrl+n", "insert next"),
39+ ),
40+ UpInsert: key.NewBinding(
41+ key.WithKeys("ctrl+p"),
42+ key.WithHelp("ctrl+p", "insert previous"),
43+ ),
44+ }
45+}
46+
47+// KeyBindings returns all key bindings as a slice.
48+func (k KeyMap) KeyBindings() []key.Binding {
49+ return []key.Binding{
50+ k.Down,
51+ k.Up,
52+ k.Select,
53+ k.Cancel,
54+ }
55+}
56+
57+// FullHelp returns the full help for the key bindings.
58+func (k KeyMap) FullHelp() [][]key.Binding {
59+ m := [][]key.Binding{}
60+ slice := k.KeyBindings()
61+ for i := 0; i < len(slice); i += 4 {
62+ end := min(i+4, len(slice))
63+ m = append(m, slice[i:end])
64+ }
65+ return m
66+}
67+
68+// ShortHelp returns the short help for the key bindings.
69+func (k KeyMap) ShortHelp() []key.Binding {
70+ return []key.Binding{
71+ k.Up,
72+ k.Down,
73+ }
74+}
internal/ui/dialog/actions.gonew file+165
@@ -0,0 +1,165 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "net/http"
6+ "os"
7+ "path/filepath"
8+
9+ tea "charm.land/bubbletea/v2"
10+ "github.com/charmbracelet/catwalk/pkg/catwalk"
11+ "github.com/charmbracelet/crush/internal/commands"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/message"
14+ "github.com/charmbracelet/crush/internal/oauth"
15+ "github.com/charmbracelet/crush/internal/permission"
16+ "github.com/charmbracelet/crush/internal/session"
17+ "github.com/charmbracelet/crush/internal/ui/common"
18+ "github.com/charmbracelet/crush/internal/uiutil"
19+)
20+
21+// ActionClose is a message to close the current dialog.
22+type ActionClose struct{}
23+
24+// ActionQuit is a message to quit the application.
25+type ActionQuit = tea.QuitMsg
26+
27+// ActionOpenDialog is a message to open a dialog.
28+type ActionOpenDialog struct {
29+ DialogID string
30+}
31+
32+// ActionSelectSession is a message indicating a session has been selected.
33+type ActionSelectSession struct {
34+ Session session.Session
35+}
36+
37+// ActionSelectModel is a message indicating a model has been selected.
38+type ActionSelectModel struct {
39+ Provider catwalk.Provider
40+ Model config.SelectedModel
41+ ModelType config.SelectedModelType
42+}
43+
44+// Messages for commands
45+type (
46+ ActionNewSession struct{}
47+ ActionToggleHelp struct{}
48+ ActionToggleCompactMode struct{}
49+ ActionToggleThinking struct{}
50+ ActionExternalEditor struct{}
51+ ActionToggleYoloMode struct{}
52+ // ActionInitializeProject is a message to initialize a project.
53+ ActionInitializeProject struct{}
54+ ActionSummarize struct {
55+ SessionID string
56+ }
57+ // ActionSelectReasoningEffort is a message indicating a reasoning effort has been selected.
58+ ActionSelectReasoningEffort struct {
59+ Effort string
60+ }
61+ ActionPermissionResponse struct {
62+ Permission permission.PermissionRequest
63+ Action PermissionAction
64+ }
65+ // ActionRunCustomCommand is a message to run a custom command.
66+ ActionRunCustomCommand struct {
67+ Content string
68+ Arguments []commands.Argument
69+ Args map[string]string // Actual argument values
70+ }
71+ // ActionRunMCPPrompt is a message to run a custom command.
72+ ActionRunMCPPrompt struct {
73+ Title string
74+ Description string
75+ PromptID string
76+ ClientID string
77+ Arguments []commands.Argument
78+ Args map[string]string // Actual argument values
79+ }
80+)
81+
82+// Messages for API key input dialog.
83+type (
84+ ActionChangeAPIKeyState struct {
85+ State APIKeyInputState
86+ }
87+)
88+
89+// Messages for OAuth2 device flow dialog.
90+type (
91+ // ActionInitiateOAuth is sent when the device auth is initiated
92+ // successfully.
93+ ActionInitiateOAuth struct {
94+ DeviceCode string
95+ UserCode string
96+ ExpiresIn int
97+ VerificationURL string
98+ Interval int
99+ }
100+
101+ // ActionCompleteOAuth is sent when the device flow completes successfully.
102+ ActionCompleteOAuth struct {
103+ Token *oauth.Token
104+ }
105+
106+ // ActionOAuthErrored is sent when the device flow encounters an error.
107+ ActionOAuthErrored struct {
108+ Error error
109+ }
110+)
111+
112+// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
113+// Bubble Tea program loop.
114+type ActionCmd struct {
115+ Cmd tea.Cmd
116+}
117+
118+// ActionFilePickerSelected is a message indicating a file has been selected in
119+// the file picker dialog.
120+type ActionFilePickerSelected struct {
121+ Path string
122+}
123+
124+// Cmd returns a command that reads the file at path and sends a
125+// [message.Attachement] to the program.
126+func (a ActionFilePickerSelected) Cmd() tea.Cmd {
127+ path := a.Path
128+ if path == "" {
129+ return nil
130+ }
131+ return func() tea.Msg {
132+ isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize)
133+ if err != nil {
134+ return uiutil.InfoMsg{
135+ Type: uiutil.InfoTypeError,
136+ Msg: fmt.Sprintf("unable to read the image: %v", err),
137+ }
138+ }
139+ if isFileLarge {
140+ return uiutil.InfoMsg{
141+ Type: uiutil.InfoTypeError,
142+ Msg: "file too large, max 5MB",
143+ }
144+ }
145+
146+ content, err := os.ReadFile(path)
147+ if err != nil {
148+ return uiutil.InfoMsg{
149+ Type: uiutil.InfoTypeError,
150+ Msg: fmt.Sprintf("unable to read the image: %v", err),
151+ }
152+ }
153+
154+ mimeBufferSize := min(512, len(content))
155+ mimeType := http.DetectContentType(content[:mimeBufferSize])
156+ fileName := filepath.Base(path)
157+
158+ return message.Attachment{
159+ FilePath: path,
160+ FileName: fileName,
161+ MimeType: mimeType,
162+ Content: content,
163+ }
164+ }
165+}
internal/ui/dialog/api_key_input.gonew file+302
@@ -0,0 +1,302 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "charm.land/bubbles/v2/help"
9+ "charm.land/bubbles/v2/key"
10+ "charm.land/bubbles/v2/spinner"
11+ "charm.land/bubbles/v2/textinput"
12+ tea "charm.land/bubbletea/v2"
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/ui/common"
16+ "github.com/charmbracelet/crush/internal/ui/styles"
17+ "github.com/charmbracelet/crush/internal/uiutil"
18+ uv "github.com/charmbracelet/ultraviolet"
19+ "github.com/charmbracelet/x/exp/charmtone"
20+)
21+
22+type APIKeyInputState int
23+
24+const (
25+ APIKeyInputStateInitial APIKeyInputState = iota
26+ APIKeyInputStateVerifying
27+ APIKeyInputStateVerified
28+ APIKeyInputStateError
29+)
30+
31+// APIKeyInputID is the identifier for the model selection dialog.
32+const APIKeyInputID = "api_key_input"
33+
34+// APIKeyInput represents a model selection dialog.
35+type APIKeyInput struct {
36+ com *common.Common
37+
38+ provider catwalk.Provider
39+ model config.SelectedModel
40+ modelType config.SelectedModelType
41+
42+ width int
43+ state APIKeyInputState
44+
45+ keyMap struct {
46+ Submit key.Binding
47+ Close key.Binding
48+ }
49+ input textinput.Model
50+ spinner spinner.Model
51+ help help.Model
52+}
53+
54+var _ Dialog = (*APIKeyInput)(nil)
55+
56+// NewAPIKeyInput creates a new Models dialog.
57+func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, tea.Cmd) {
58+ t := com.Styles
59+
60+ m := APIKeyInput{}
61+ m.com = com
62+ m.provider = provider
63+ m.model = model
64+ m.modelType = modelType
65+ m.width = 60
66+
67+ innerWidth := m.width - t.Dialog.View.GetHorizontalFrameSize() - 2
68+
69+ m.input = textinput.New()
70+ m.input.SetVirtualCursor(false)
71+ m.input.Placeholder = "Enter you API key..."
72+ m.input.SetStyles(com.Styles.TextInput)
73+ m.input.Focus()
74+ m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
75+
76+ m.spinner = spinner.New(
77+ spinner.WithSpinner(spinner.Dot),
78+ spinner.WithStyle(t.Base.Foreground(t.Green)),
79+ )
80+
81+ m.help = help.New()
82+ m.help.Styles = t.DialogHelpStyles()
83+
84+ m.keyMap.Submit = key.NewBinding(
85+ key.WithKeys("enter", "ctrl+y"),
86+ key.WithHelp("enter", "submit"),
87+ )
88+ m.keyMap.Close = CloseKey
89+
90+ return &m, nil
91+}
92+
93+// ID implements Dialog.
94+func (m *APIKeyInput) ID() string {
95+ return APIKeyInputID
96+}
97+
98+// HandleMsg implements [Dialog].
99+func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
100+ switch msg := msg.(type) {
101+ case ActionChangeAPIKeyState:
102+ m.state = msg.State
103+ switch m.state {
104+ case APIKeyInputStateVerifying:
105+ cmd := tea.Batch(m.spinner.Tick, m.verifyAPIKey)
106+ return ActionCmd{cmd}
107+ }
108+ case spinner.TickMsg:
109+ switch m.state {
110+ case APIKeyInputStateVerifying:
111+ var cmd tea.Cmd
112+ m.spinner, cmd = m.spinner.Update(msg)
113+ if cmd != nil {
114+ return ActionCmd{cmd}
115+ }
116+ }
117+ case tea.KeyPressMsg:
118+ switch {
119+ case m.state == APIKeyInputStateVerifying:
120+ // do nothing
121+ case key.Matches(msg, m.keyMap.Close):
122+ switch m.state {
123+ case APIKeyInputStateVerified:
124+ return m.saveKeyAndContinue()
125+ default:
126+ return ActionClose{}
127+ }
128+ case key.Matches(msg, m.keyMap.Submit):
129+ switch m.state {
130+ case APIKeyInputStateInitial, APIKeyInputStateError:
131+ return ActionChangeAPIKeyState{State: APIKeyInputStateVerifying}
132+ case APIKeyInputStateVerified:
133+ return m.saveKeyAndContinue()
134+ }
135+ default:
136+ var cmd tea.Cmd
137+ m.input, cmd = m.input.Update(msg)
138+ if cmd != nil {
139+ return ActionCmd{cmd}
140+ }
141+ }
142+ case tea.PasteMsg:
143+ var cmd tea.Cmd
144+ m.input, cmd = m.input.Update(msg)
145+ if cmd != nil {
146+ return ActionCmd{cmd}
147+ }
148+ }
149+ return nil
150+}
151+
152+// Draw implements [Dialog].
153+func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
154+ t := m.com.Styles
155+
156+ textStyle := t.Dialog.SecondaryText
157+ helpStyle := t.Dialog.HelpView
158+ dialogStyle := t.Dialog.View.Width(m.width)
159+ inputStyle := t.Dialog.InputPrompt
160+ helpStyle = helpStyle.Width(m.width - dialogStyle.GetHorizontalFrameSize())
161+
162+ m.input.Prompt = m.spinner.View()
163+
164+ content := strings.Join([]string{
165+ m.headerView(),
166+ inputStyle.Render(m.inputView()),
167+ textStyle.Render("This will be written in your global configuration:"),
168+ textStyle.Render(config.GlobalConfigData()),
169+ "",
170+ helpStyle.Render(m.help.View(m)),
171+ }, "\n")
172+
173+ view := dialogStyle.Render(content)
174+
175+ cur := m.Cursor()
176+ DrawCenterCursor(scr, area, view, cur)
177+ return cur
178+}
179+
180+func (m *APIKeyInput) headerView() string {
181+ t := m.com.Styles
182+ titleStyle := t.Dialog.Title
183+ dialogStyle := t.Dialog.View.Width(m.width)
184+
185+ headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
186+ return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset)
187+}
188+
189+func (m *APIKeyInput) dialogTitle() string {
190+ t := m.com.Styles
191+ textStyle := t.Dialog.TitleText
192+ errorStyle := t.Dialog.TitleError
193+ accentStyle := t.Dialog.TitleAccent
194+
195+ switch m.state {
196+ case APIKeyInputStateInitial:
197+ return textStyle.Render("Enter your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(".")
198+ case APIKeyInputStateVerifying:
199+ return textStyle.Render("Verifying your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render("...")
200+ case APIKeyInputStateVerified:
201+ return accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(" validated.")
202+ case APIKeyInputStateError:
203+ return errorStyle.Render("Invalid ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + errorStyle.Render(". Try again?")
204+ }
205+ return ""
206+}
207+
208+func (m *APIKeyInput) inputView() string {
209+ t := m.com.Styles
210+
211+ switch m.state {
212+ case APIKeyInputStateInitial:
213+ m.input.Prompt = "> "
214+ m.input.SetStyles(t.TextInput)
215+ m.input.Focus()
216+ case APIKeyInputStateVerifying:
217+ ts := t.TextInput
218+ ts.Blurred.Prompt = ts.Focused.Prompt
219+
220+ m.input.Prompt = m.spinner.View()
221+ m.input.SetStyles(ts)
222+ m.input.Blur()
223+ case APIKeyInputStateVerified:
224+ ts := t.TextInput
225+ ts.Blurred.Prompt = ts.Focused.Prompt
226+
227+ m.input.Prompt = styles.CheckIcon + " "
228+ m.input.SetStyles(ts)
229+ m.input.Blur()
230+ case APIKeyInputStateError:
231+ ts := t.TextInput
232+ ts.Focused.Prompt = ts.Focused.Prompt.Foreground(charmtone.Cherry)
233+
234+ m.input.Prompt = styles.ErrorIcon + " "
235+ m.input.SetStyles(ts)
236+ m.input.Focus()
237+ }
238+ return m.input.View()
239+}
240+
241+// Cursor returns the cursor position relative to the dialog.
242+func (m *APIKeyInput) Cursor() *tea.Cursor {
243+ return InputCursor(m.com.Styles, m.input.Cursor())
244+}
245+
246+// FullHelp returns the full help view.
247+func (m *APIKeyInput) FullHelp() [][]key.Binding {
248+ return [][]key.Binding{
249+ {
250+ m.keyMap.Submit,
251+ m.keyMap.Close,
252+ },
253+ }
254+}
255+
256+// ShortHelp returns the full help view.
257+func (m *APIKeyInput) ShortHelp() []key.Binding {
258+ return []key.Binding{
259+ m.keyMap.Submit,
260+ m.keyMap.Close,
261+ }
262+}
263+
264+func (m *APIKeyInput) verifyAPIKey() tea.Msg {
265+ start := time.Now()
266+
267+ providerConfig := config.ProviderConfig{
268+ ID: string(m.provider.ID),
269+ Name: m.provider.Name,
270+ APIKey: m.input.Value(),
271+ Type: m.provider.Type,
272+ BaseURL: m.provider.APIEndpoint,
273+ }
274+ err := providerConfig.TestConnection(config.Get().Resolver())
275+
276+ // intentionally wait for at least 750ms to make sure the user sees the spinner
277+ elapsed := time.Since(start)
278+ minimum := 750 * time.Millisecond
279+ if elapsed < minimum {
280+ time.Sleep(minimum - elapsed)
281+ }
282+
283+ if err == nil {
284+ return ActionChangeAPIKeyState{APIKeyInputStateVerified}
285+ }
286+ return ActionChangeAPIKeyState{APIKeyInputStateError}
287+}
288+
289+func (m *APIKeyInput) saveKeyAndContinue() Action {
290+ cfg := m.com.Config()
291+
292+ err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value())
293+ if err != nil {
294+ return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
295+ }
296+
297+ return ActionSelectModel{
298+ Provider: m.provider,
299+ Model: m.model,
300+ ModelType: m.modelType,
301+ }
302+}
internal/ui/dialog/arguments.gonew file+399
@@ -0,0 +1,399 @@
1+package dialog
2+
3+import (
4+ "strings"
5+
6+ "charm.land/bubbles/v2/help"
7+ "charm.land/bubbles/v2/key"
8+ "charm.land/bubbles/v2/spinner"
9+ "charm.land/bubbles/v2/textinput"
10+ "charm.land/bubbles/v2/viewport"
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/lipgloss/v2"
13+ "golang.org/x/text/cases"
14+ "golang.org/x/text/language"
15+
16+ "github.com/charmbracelet/crush/internal/commands"
17+ "github.com/charmbracelet/crush/internal/ui/common"
18+ "github.com/charmbracelet/crush/internal/uiutil"
19+ uv "github.com/charmbracelet/ultraviolet"
20+)
21+
22+// ArgumentsID is the identifier for the arguments dialog.
23+const ArgumentsID = "arguments"
24+
25+// Dialog sizing for arguments.
26+const (
27+ maxInputWidth = 120
28+ minInputWidth = 30
29+ maxViewportHeight = 20
30+ argumentsFieldHeight = 3 // label + input + spacing per field
31+)
32+
33+// Arguments represents a dialog for collecting command arguments.
34+type Arguments struct {
35+ com *common.Common
36+ title string
37+ arguments []commands.Argument
38+ inputs []textinput.Model
39+ focused int
40+ spinner spinner.Model
41+ loading bool
42+
43+ description string
44+ resultAction Action
45+
46+ help help.Model
47+ keyMap struct {
48+ Confirm,
49+ Next,
50+ Previous,
51+ ScrollUp,
52+ ScrollDown,
53+ Close key.Binding
54+ }
55+
56+ viewport viewport.Model
57+}
58+
59+var _ Dialog = (*Arguments)(nil)
60+
61+// NewArguments creates a new arguments dialog.
62+func NewArguments(com *common.Common, title, description string, arguments []commands.Argument, resultAction Action) *Arguments {
63+ a := &Arguments{
64+ com: com,
65+ title: title,
66+ description: description,
67+ arguments: arguments,
68+ resultAction: resultAction,
69+ }
70+
71+ a.help = help.New()
72+ a.help.Styles = com.Styles.DialogHelpStyles()
73+
74+ a.keyMap.Confirm = key.NewBinding(
75+ key.WithKeys("enter"),
76+ key.WithHelp("enter", "confirm"),
77+ )
78+ a.keyMap.Next = key.NewBinding(
79+ key.WithKeys("down", "tab"),
80+ key.WithHelp("↓/tab", "next"),
81+ )
82+ a.keyMap.Previous = key.NewBinding(
83+ key.WithKeys("up", "shift+tab"),
84+ key.WithHelp("↑/shift+tab", "previous"),
85+ )
86+ a.keyMap.Close = CloseKey
87+
88+ // Create input fields for each argument.
89+ a.inputs = make([]textinput.Model, len(arguments))
90+ for i, arg := range arguments {
91+ input := textinput.New()
92+ input.SetVirtualCursor(false)
93+ input.SetStyles(com.Styles.TextInput)
94+ input.Prompt = "> "
95+ // Use description as placeholder if available, otherwise title
96+ if arg.Description != "" {
97+ input.Placeholder = arg.Description
98+ } else {
99+ input.Placeholder = arg.Title
100+ }
101+
102+ if i == 0 {
103+ input.Focus()
104+ } else {
105+ input.Blur()
106+ }
107+
108+ a.inputs[i] = input
109+ }
110+ s := spinner.New()
111+ s.Spinner = spinner.Dot
112+ s.Style = com.Styles.Dialog.Spinner
113+ a.spinner = s
114+
115+ return a
116+}
117+
118+// ID implements Dialog.
119+func (a *Arguments) ID() string {
120+ return ArgumentsID
121+}
122+
123+// focusInput changes focus to a new input by index with wrap-around.
124+func (a *Arguments) focusInput(newIndex int) {
125+ a.inputs[a.focused].Blur()
126+
127+ // Wrap around: Go's modulo can return negative, so add len first.
128+ n := len(a.inputs)
129+ a.focused = ((newIndex % n) + n) % n
130+
131+ a.inputs[a.focused].Focus()
132+
133+ // Ensure the newly focused field is visible in the viewport
134+ a.ensureFieldVisible(a.focused)
135+}
136+
137+// isFieldVisible checks if a field at the given index is visible in the viewport.
138+func (a *Arguments) isFieldVisible(fieldIndex int) bool {
139+ fieldStart := fieldIndex * argumentsFieldHeight
140+ fieldEnd := fieldStart + argumentsFieldHeight - 1
141+ viewportTop := a.viewport.YOffset()
142+ viewportBottom := viewportTop + a.viewport.Height() - 1
143+
144+ return fieldStart >= viewportTop && fieldEnd <= viewportBottom
145+}
146+
147+// ensureFieldVisible scrolls the viewport to make the field visible.
148+func (a *Arguments) ensureFieldVisible(fieldIndex int) {
149+ if a.isFieldVisible(fieldIndex) {
150+ return
151+ }
152+
153+ fieldStart := fieldIndex * argumentsFieldHeight
154+ fieldEnd := fieldStart + argumentsFieldHeight - 1
155+ viewportTop := a.viewport.YOffset()
156+ viewportHeight := a.viewport.Height()
157+
158+ // If field is above viewport, scroll up to show it at top
159+ if fieldStart < viewportTop {
160+ a.viewport.SetYOffset(fieldStart)
161+ return
162+ }
163+
164+ // If field is below viewport, scroll down to show it at bottom
165+ if fieldEnd > viewportTop+viewportHeight-1 {
166+ a.viewport.SetYOffset(fieldEnd - viewportHeight + 1)
167+ }
168+}
169+
170+// findVisibleFieldByOffset returns the field index closest to the given viewport offset.
171+func (a *Arguments) findVisibleFieldByOffset(fromTop bool) int {
172+ offset := a.viewport.YOffset()
173+ if !fromTop {
174+ offset += a.viewport.Height() - 1
175+ }
176+
177+ fieldIndex := offset / argumentsFieldHeight
178+ if fieldIndex >= len(a.inputs) {
179+ return len(a.inputs) - 1
180+ }
181+ return fieldIndex
182+}
183+
184+// HandleMsg implements Dialog.
185+func (a *Arguments) HandleMsg(msg tea.Msg) Action {
186+ switch msg := msg.(type) {
187+ case spinner.TickMsg:
188+ if a.loading {
189+ var cmd tea.Cmd
190+ a.spinner, cmd = a.spinner.Update(msg)
191+ return ActionCmd{Cmd: cmd}
192+ }
193+ case tea.KeyPressMsg:
194+ switch {
195+ case key.Matches(msg, a.keyMap.Close):
196+ return ActionClose{}
197+ case key.Matches(msg, a.keyMap.Confirm):
198+ // If we're on the last input or there's only one input, submit.
199+ if a.focused == len(a.inputs)-1 || len(a.inputs) == 1 {
200+ args := make(map[string]string)
201+ var warning tea.Cmd
202+ for i, arg := range a.arguments {
203+ args[arg.ID] = a.inputs[i].Value()
204+ if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" {
205+ warning = uiutil.ReportWarn("Required argument '" + arg.Title + "' is missing.")
206+ break
207+ }
208+ }
209+ if warning != nil {
210+ return ActionCmd{Cmd: warning}
211+ }
212+
213+ switch action := a.resultAction.(type) {
214+ case ActionRunCustomCommand:
215+ action.Args = args
216+ return action
217+ case ActionRunMCPPrompt:
218+ action.Args = args
219+ return action
220+ }
221+ }
222+ a.focusInput(a.focused + 1)
223+ case key.Matches(msg, a.keyMap.Next):
224+ a.focusInput(a.focused + 1)
225+ case key.Matches(msg, a.keyMap.Previous):
226+ a.focusInput(a.focused - 1)
227+ default:
228+ var cmd tea.Cmd
229+ a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
230+ return ActionCmd{Cmd: cmd}
231+ }
232+ case tea.MouseWheelMsg:
233+ a.viewport, _ = a.viewport.Update(msg)
234+ // If focused field scrolled out of view, focus the visible field
235+ if !a.isFieldVisible(a.focused) {
236+ a.focusInput(a.findVisibleFieldByOffset(msg.Button == tea.MouseWheelDown))
237+ }
238+ case tea.PasteMsg:
239+ var cmd tea.Cmd
240+ a.inputs[a.focused], cmd = a.inputs[a.focused].Update(msg)
241+ return ActionCmd{Cmd: cmd}
242+ }
243+ return nil
244+}
245+
246+// Cursor returns the cursor position relative to the dialog.
247+// we pass the description height to offset the cursor correctly.
248+func (a *Arguments) Cursor(descriptionHeight int) *tea.Cursor {
249+ cursor := InputCursor(a.com.Styles, a.inputs[a.focused].Cursor())
250+ if cursor == nil {
251+ return nil
252+ }
253+ cursor.Y += descriptionHeight + a.focused*argumentsFieldHeight - a.viewport.YOffset() + 1
254+ return cursor
255+}
256+
257+// Draw implements Dialog.
258+func (a *Arguments) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
259+ s := a.com.Styles
260+
261+ dialogContentStyle := s.Dialog.Arguments.Content
262+ possibleWidth := area.Dx() - s.Dialog.View.GetHorizontalFrameSize() - dialogContentStyle.GetHorizontalFrameSize()
263+ // Build fields with label and input.
264+ caser := cases.Title(language.English)
265+
266+ var fields []string
267+ for i, arg := range a.arguments {
268+ isFocused := i == a.focused
269+
270+ // Try to pretty up the title for the label.
271+ title := strings.ReplaceAll(arg.Title, "_", " ")
272+ title = strings.ReplaceAll(title, "-", " ")
273+ titleParts := strings.Fields(title)
274+ for i, part := range titleParts {
275+ titleParts[i] = caser.String(strings.ToLower(part))
276+ }
277+ labelText := strings.Join(titleParts, " ")
278+
279+ markRequiredStyle := s.Dialog.Arguments.InputRequiredMarkBlurred
280+
281+ labelStyle := s.Dialog.Arguments.InputLabelBlurred
282+ if isFocused {
283+ labelStyle = s.Dialog.Arguments.InputLabelFocused
284+ markRequiredStyle = s.Dialog.Arguments.InputRequiredMarkFocused
285+ }
286+ if arg.Required {
287+ labelText += markRequiredStyle.String()
288+ }
289+ label := labelStyle.Render(labelText)
290+
291+ labelWidth := lipgloss.Width(labelText)
292+ placeholderWidth := lipgloss.Width(a.inputs[i].Placeholder)
293+
294+ inputWidth := max(placeholderWidth, labelWidth, minInputWidth)
295+ inputWidth = min(inputWidth, min(possibleWidth, maxInputWidth))
296+ a.inputs[i].SetWidth(inputWidth)
297+
298+ inputLine := a.inputs[i].View()
299+
300+ field := lipgloss.JoinVertical(lipgloss.Left, label, inputLine, "")
301+ fields = append(fields, field)
302+ }
303+
304+ renderedFields := lipgloss.JoinVertical(lipgloss.Left, fields...)
305+
306+ // Anchor width to the longest field, capped at maxInputWidth.
307+ const scrollbarWidth = 1
308+ width := lipgloss.Width(renderedFields)
309+ height := lipgloss.Height(renderedFields)
310+
311+ // Use standard header
312+ titleStyle := s.Dialog.Title
313+
314+ titleText := a.title
315+ if titleText == "" {
316+ titleText = "Arguments"
317+ }
318+
319+ header := common.DialogTitle(s, titleText, width)
320+
321+ // Add description if available.
322+ var description string
323+ if a.description != "" {
324+ descStyle := s.Dialog.Arguments.Description.Width(width)
325+ description = descStyle.Render(a.description)
326+ }
327+
328+ helpView := s.Dialog.HelpView.Width(width).Render(a.help.View(a))
329+ if a.loading {
330+ helpView = s.Dialog.HelpView.Width(width).Render(a.spinner.View() + " Generating Prompt...")
331+ }
332+
333+ availableHeight := area.Dy() - s.Dialog.View.GetVerticalFrameSize() - dialogContentStyle.GetVerticalFrameSize() - lipgloss.Height(header) - lipgloss.Height(description) - lipgloss.Height(helpView) - 2 // extra spacing
334+ viewportHeight := min(height, maxViewportHeight, availableHeight)
335+
336+ a.viewport.SetWidth(width) // -1 for scrollbar
337+ a.viewport.SetHeight(viewportHeight)
338+ a.viewport.SetContent(renderedFields)
339+
340+ scrollbar := common.Scrollbar(s, viewportHeight, a.viewport.TotalLineCount(), viewportHeight, a.viewport.YOffset())
341+ content := a.viewport.View()
342+ if scrollbar != "" {
343+ content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
344+ }
345+ contentParts := []string{}
346+ if description != "" {
347+ contentParts = append(contentParts, description)
348+ }
349+ contentParts = append(contentParts, content)
350+
351+ view := lipgloss.JoinVertical(
352+ lipgloss.Left,
353+ titleStyle.Render(header),
354+ dialogContentStyle.Render(lipgloss.JoinVertical(lipgloss.Left, contentParts...)),
355+ helpView,
356+ )
357+
358+ dialog := s.Dialog.View.Render(view)
359+
360+ descriptionHeight := 0
361+ if a.description != "" {
362+ descriptionHeight = lipgloss.Height(description)
363+ }
364+ cur := a.Cursor(descriptionHeight)
365+
366+ DrawCenterCursor(scr, area, dialog, cur)
367+ return cur
368+}
369+
370+// StartLoading implements [LoadingDialog].
371+func (a *Arguments) StartLoading() tea.Cmd {
372+ if a.loading {
373+ return nil
374+ }
375+ a.loading = true
376+ return a.spinner.Tick
377+}
378+
379+// StopLoading implements [LoadingDialog].
380+func (a *Arguments) StopLoading() {
381+ a.loading = false
382+}
383+
384+// ShortHelp implements help.KeyMap.
385+func (a *Arguments) ShortHelp() []key.Binding {
386+ return []key.Binding{
387+ a.keyMap.Confirm,
388+ a.keyMap.Next,
389+ a.keyMap.Close,
390+ }
391+}
392+
393+// FullHelp implements help.KeyMap.
394+func (a *Arguments) FullHelp() [][]key.Binding {
395+ return [][]key.Binding{
396+ {a.keyMap.Confirm, a.keyMap.Next, a.keyMap.Previous},
397+ {a.keyMap.Close},
398+ }
399+}
internal/ui/dialog/commands.gonew file+477
@@ -0,0 +1,477 @@
1+package dialog
2+
3+import (
4+ "os"
5+ "strings"
6+
7+ "charm.land/bubbles/v2/help"
8+ "charm.land/bubbles/v2/key"
9+ "charm.land/bubbles/v2/spinner"
10+ "charm.land/bubbles/v2/textinput"
11+ tea "charm.land/bubbletea/v2"
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
13+ "github.com/charmbracelet/crush/internal/agent/hyper"
14+ "github.com/charmbracelet/crush/internal/commands"
15+ "github.com/charmbracelet/crush/internal/config"
16+ "github.com/charmbracelet/crush/internal/ui/common"
17+ "github.com/charmbracelet/crush/internal/ui/list"
18+ "github.com/charmbracelet/crush/internal/ui/styles"
19+ uv "github.com/charmbracelet/ultraviolet"
20+)
21+
22+// CommandsID is the identifier for the commands dialog.
23+const CommandsID = "commands"
24+
25+// CommandType represents the type of commands being displayed.
26+type CommandType uint
27+
28+// String returns the string representation of the CommandType.
29+func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
30+
31+const sidebarCompactModeBreakpoint = 120
32+
33+const (
34+ SystemCommands CommandType = iota
35+ UserCommands
36+ MCPPrompts
37+)
38+
39+// Commands represents a dialog that shows available commands.
40+type Commands struct {
41+ com *common.Common
42+ keyMap struct {
43+ Select,
44+ UpDown,
45+ Next,
46+ Previous,
47+ Tab,
48+ ShiftTab,
49+ Close key.Binding
50+ }
51+
52+ sessionID string // can be empty for non-session-specific commands
53+ selected CommandType
54+
55+ spinner spinner.Model
56+ loading bool
57+
58+ help help.Model
59+ input textinput.Model
60+ list *list.FilterableList
61+
62+ windowWidth int
63+
64+ customCommands []commands.CustomCommand
65+ mcpPrompts []commands.MCPPrompt
66+}
67+
68+var _ Dialog = (*Commands)(nil)
69+
70+// NewCommands creates a new commands dialog.
71+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
72+ c := &Commands{
73+ com: com,
74+ selected: SystemCommands,
75+ sessionID: sessionID,
76+ customCommands: customCommands,
77+ mcpPrompts: mcpPrompts,
78+ }
79+
80+ help := help.New()
81+ help.Styles = com.Styles.DialogHelpStyles()
82+
83+ c.help = help
84+
85+ c.list = list.NewFilterableList()
86+ c.list.Focus()
87+ c.list.SetSelected(0)
88+
89+ c.input = textinput.New()
90+ c.input.SetVirtualCursor(false)
91+ c.input.Placeholder = "Type to filter"
92+ c.input.SetStyles(com.Styles.TextInput)
93+ c.input.Focus()
94+
95+ c.keyMap.Select = key.NewBinding(
96+ key.WithKeys("enter", "ctrl+y"),
97+ key.WithHelp("enter", "confirm"),
98+ )
99+ c.keyMap.UpDown = key.NewBinding(
100+ key.WithKeys("up", "down"),
101+ key.WithHelp("↑/↓", "choose"),
102+ )
103+ c.keyMap.Next = key.NewBinding(
104+ key.WithKeys("down"),
105+ key.WithHelp("↓", "next item"),
106+ )
107+ c.keyMap.Previous = key.NewBinding(
108+ key.WithKeys("up", "ctrl+p"),
109+ key.WithHelp("↑", "previous item"),
110+ )
111+ c.keyMap.Tab = key.NewBinding(
112+ key.WithKeys("tab"),
113+ key.WithHelp("tab", "switch selection"),
114+ )
115+ c.keyMap.ShiftTab = key.NewBinding(
116+ key.WithKeys("shift+tab"),
117+ key.WithHelp("shift+tab", "switch selection prev"),
118+ )
119+ closeKey := CloseKey
120+ closeKey.SetHelp("esc", "cancel")
121+ c.keyMap.Close = closeKey
122+
123+ // Set initial commands
124+ c.setCommandItems(c.selected)
125+
126+ s := spinner.New()
127+ s.Spinner = spinner.Dot
128+ s.Style = com.Styles.Dialog.Spinner
129+ c.spinner = s
130+
131+ return c, nil
132+}
133+
134+// ID implements Dialog.
135+func (c *Commands) ID() string {
136+ return CommandsID
137+}
138+
139+// HandleMsg implements [Dialog].
140+func (c *Commands) HandleMsg(msg tea.Msg) Action {
141+ switch msg := msg.(type) {
142+ case spinner.TickMsg:
143+ if c.loading {
144+ var cmd tea.Cmd
145+ c.spinner, cmd = c.spinner.Update(msg)
146+ return ActionCmd{Cmd: cmd}
147+ }
148+ case tea.KeyPressMsg:
149+ switch {
150+ case key.Matches(msg, c.keyMap.Close):
151+ return ActionClose{}
152+ case key.Matches(msg, c.keyMap.Previous):
153+ c.list.Focus()
154+ if c.list.IsSelectedFirst() {
155+ c.list.SelectLast()
156+ c.list.ScrollToBottom()
157+ break
158+ }
159+ c.list.SelectPrev()
160+ c.list.ScrollToSelected()
161+ case key.Matches(msg, c.keyMap.Next):
162+ c.list.Focus()
163+ if c.list.IsSelectedLast() {
164+ c.list.SelectFirst()
165+ c.list.ScrollToTop()
166+ break
167+ }
168+ c.list.SelectNext()
169+ c.list.ScrollToSelected()
170+ case key.Matches(msg, c.keyMap.Select):
171+ if selectedItem := c.list.SelectedItem(); selectedItem != nil {
172+ if item, ok := selectedItem.(*CommandItem); ok && item != nil {
173+ return item.Action()
174+ }
175+ }
176+ case key.Matches(msg, c.keyMap.Tab):
177+ if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
178+ c.selected = c.nextCommandType()
179+ c.setCommandItems(c.selected)
180+ }
181+ case key.Matches(msg, c.keyMap.ShiftTab):
182+ if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
183+ c.selected = c.previousCommandType()
184+ c.setCommandItems(c.selected)
185+ }
186+ default:
187+ var cmd tea.Cmd
188+ for _, item := range c.list.VisibleItems() {
189+ if item, ok := item.(*CommandItem); ok && item != nil {
190+ if msg.String() == item.Shortcut() {
191+ return item.Action()
192+ }
193+ }
194+ }
195+ c.input, cmd = c.input.Update(msg)
196+ value := c.input.Value()
197+ c.list.SetFilter(value)
198+ c.list.ScrollToTop()
199+ c.list.SetSelected(0)
200+ return ActionCmd{cmd}
201+ }
202+ }
203+ return nil
204+}
205+
206+// Cursor returns the cursor position relative to the dialog.
207+func (c *Commands) Cursor() *tea.Cursor {
208+ return InputCursor(c.com.Styles, c.input.Cursor())
209+}
210+
211+// commandsRadioView generates the command type selector radio buttons.
212+func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
213+ if !hasUserCmds && !hasMCPPrompts {
214+ return ""
215+ }
216+
217+ selectedFn := func(t CommandType) string {
218+ if t == selected {
219+ return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
220+ }
221+ return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
222+ }
223+
224+ parts := []string{
225+ selectedFn(SystemCommands),
226+ }
227+
228+ if hasUserCmds {
229+ parts = append(parts, selectedFn(UserCommands))
230+ }
231+ if hasMCPPrompts {
232+ parts = append(parts, selectedFn(MCPPrompts))
233+ }
234+
235+ return strings.Join(parts, " ")
236+}
237+
238+// Draw implements [Dialog].
239+func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
240+ t := c.com.Styles
241+ width := max(0, min(defaultDialogMaxWidth, area.Dx()))
242+ height := max(0, min(defaultDialogHeight, area.Dy()))
243+ if area.Dx() != c.windowWidth && c.selected == SystemCommands {
244+ c.windowWidth = area.Dx()
245+ // since some items in the list depend on width (e.g. toggle sidebar command),
246+ // we need to reset the command items when width changes
247+ c.setCommandItems(c.selected)
248+ }
249+
250+ innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
251+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
252+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
253+ t.Dialog.HelpView.GetVerticalFrameSize() +
254+ t.Dialog.View.GetVerticalFrameSize()
255+
256+ c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
257+ c.list.SetSize(innerWidth, height-heightOffset)
258+ c.help.SetWidth(innerWidth)
259+
260+ rc := NewRenderContext(t, width)
261+ rc.Title = "Commands"
262+ rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
263+ inputView := t.Dialog.InputPrompt.Render(c.input.View())
264+ rc.AddPart(inputView)
265+ listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
266+ rc.AddPart(listView)
267+ rc.Help = c.help.View(c)
268+
269+ if c.loading {
270+ rc.Help = c.spinner.View() + " Generating Prompt..."
271+ }
272+
273+ view := rc.Render()
274+
275+ cur := c.Cursor()
276+ DrawCenterCursor(scr, area, view, cur)
277+ return cur
278+}
279+
280+// ShortHelp implements [help.KeyMap].
281+func (c *Commands) ShortHelp() []key.Binding {
282+ return []key.Binding{
283+ c.keyMap.Tab,
284+ c.keyMap.UpDown,
285+ c.keyMap.Select,
286+ c.keyMap.Close,
287+ }
288+}
289+
290+// FullHelp implements [help.KeyMap].
291+func (c *Commands) FullHelp() [][]key.Binding {
292+ return [][]key.Binding{
293+ {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
294+ {c.keyMap.Close},
295+ }
296+}
297+
298+// nextCommandType returns the next command type in the cycle.
299+func (c *Commands) nextCommandType() CommandType {
300+ switch c.selected {
301+ case SystemCommands:
302+ if len(c.customCommands) > 0 {
303+ return UserCommands
304+ }
305+ if len(c.mcpPrompts) > 0 {
306+ return MCPPrompts
307+ }
308+ fallthrough
309+ case UserCommands:
310+ if len(c.mcpPrompts) > 0 {
311+ return MCPPrompts
312+ }
313+ fallthrough
314+ case MCPPrompts:
315+ return SystemCommands
316+ default:
317+ return SystemCommands
318+ }
319+}
320+
321+// previousCommandType returns the previous command type in the cycle.
322+func (c *Commands) previousCommandType() CommandType {
323+ switch c.selected {
324+ case SystemCommands:
325+ if len(c.mcpPrompts) > 0 {
326+ return MCPPrompts
327+ }
328+ if len(c.customCommands) > 0 {
329+ return UserCommands
330+ }
331+ return SystemCommands
332+ case UserCommands:
333+ return SystemCommands
334+ case MCPPrompts:
335+ if len(c.customCommands) > 0 {
336+ return UserCommands
337+ }
338+ return SystemCommands
339+ default:
340+ return SystemCommands
341+ }
342+}
343+
344+// setCommandItems sets the command items based on the specified command type.
345+func (c *Commands) setCommandItems(commandType CommandType) {
346+ c.selected = commandType
347+
348+ commandItems := []list.FilterableItem{}
349+ switch c.selected {
350+ case SystemCommands:
351+ for _, cmd := range c.defaultCommands() {
352+ commandItems = append(commandItems, cmd)
353+ }
354+ case UserCommands:
355+ for _, cmd := range c.customCommands {
356+ action := ActionRunCustomCommand{
357+ Content: cmd.Content,
358+ Arguments: cmd.Arguments,
359+ }
360+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
361+ }
362+ case MCPPrompts:
363+ for _, cmd := range c.mcpPrompts {
364+ action := ActionRunMCPPrompt{
365+ Title: cmd.Title,
366+ Description: cmd.Description,
367+ PromptID: cmd.PromptID,
368+ ClientID: cmd.ClientID,
369+ Arguments: cmd.Arguments,
370+ }
371+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
372+ }
373+ }
374+
375+ c.list.SetItems(commandItems...)
376+ c.list.SetFilter("")
377+ c.list.ScrollToTop()
378+ c.list.SetSelected(0)
379+ c.input.SetValue("")
380+}
381+
382+// defaultCommands returns the list of default system commands.
383+func (c *Commands) defaultCommands() []*CommandItem {
384+ commands := []*CommandItem{
385+ NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
386+ NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
387+ NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
388+ }
389+
390+ // Only show compact command if there's an active session
391+ if c.sessionID != "" {
392+ commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
393+ }
394+
395+ // Add reasoning toggle for models that support it
396+ cfg := c.com.Config()
397+ if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
398+ providerCfg := cfg.GetProviderForModel(agentCfg.Model)
399+ model := cfg.GetModelByType(agentCfg.Model)
400+ if providerCfg != nil && model != nil && model.CanReason {
401+ selectedModel := cfg.Models[agentCfg.Model]
402+
403+ // Anthropic models: thinking toggle
404+ if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) {
405+ status := "Enable"
406+ if selectedModel.Think {
407+ status = "Disable"
408+ }
409+ commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
410+ }
411+
412+ // OpenAI models: reasoning effort dialog
413+ if len(model.ReasoningLevels) > 0 {
414+ commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
415+ DialogID: ReasoningID,
416+ }))
417+ }
418+ }
419+ }
420+ // Only show toggle compact mode command if window width is larger than compact breakpoint (120)
421+ if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
422+ commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
423+ }
424+ if c.sessionID != "" {
425+ cfg := c.com.Config()
426+ agentCfg := cfg.Agents[config.AgentCoder]
427+ model := cfg.GetModelByType(agentCfg.Model)
428+ if model != nil && model.SupportsImages {
429+ commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
430+ // TODO: Pass in the file picker dialog id
431+ }))
432+ }
433+ }
434+
435+ // Add external editor command if $EDITOR is available
436+ // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
437+ if os.Getenv("EDITOR") != "" {
438+ commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
439+ }
440+
441+ return append(commands,
442+ NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
443+ NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
444+ NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
445+ NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
446+ )
447+}
448+
449+// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
450+func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
451+ c.customCommands = customCommands
452+ if c.selected == UserCommands {
453+ c.setCommandItems(c.selected)
454+ }
455+}
456+
457+// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
458+func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
459+ c.mcpPrompts = mcpPrompts
460+ if c.selected == MCPPrompts {
461+ c.setCommandItems(c.selected)
462+ }
463+}
464+
465+// StartLoading implements [LoadingDialog].
466+func (a *Commands) StartLoading() tea.Cmd {
467+ if a.loading {
468+ return nil
469+ }
470+ a.loading = true
471+ return a.spinner.Tick
472+}
473+
474+// StopLoading implements [LoadingDialog].
475+func (a *Commands) StopLoading() {
476+ a.loading = false
477+}
internal/ui/dialog/commands_item.gonew file+70
@@ -0,0 +1,70 @@
1+package dialog
2+
3+import (
4+ "github.com/charmbracelet/crush/internal/ui/styles"
5+ "github.com/sahilm/fuzzy"
6+)
7+
8+// CommandItem wraps a uicmd.Command to implement the ListItem interface.
9+type CommandItem struct {
10+ id string
11+ title string
12+ shortcut string
13+ action Action
14+ t *styles.Styles
15+ m fuzzy.Match
16+ cache map[int]string
17+ focused bool
18+}
19+
20+var _ ListItem = &CommandItem{}
21+
22+// NewCommandItem creates a new CommandItem.
23+func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem {
24+ return &CommandItem{
25+ id: id,
26+ t: t,
27+ title: title,
28+ shortcut: shortcut,
29+ action: action,
30+ }
31+}
32+
33+// Filter implements ListItem.
34+func (c *CommandItem) Filter() string {
35+ return c.title
36+}
37+
38+// ID implements ListItem.
39+func (c *CommandItem) ID() string {
40+ return c.id
41+}
42+
43+// SetFocused implements ListItem.
44+func (c *CommandItem) SetFocused(focused bool) {
45+ if c.focused != focused {
46+ c.cache = nil
47+ }
48+ c.focused = focused
49+}
50+
51+// SetMatch implements ListItem.
52+func (c *CommandItem) SetMatch(m fuzzy.Match) {
53+ c.cache = nil
54+ c.m = m
55+}
56+
57+// Action returns the action associated with the command item.
58+func (c *CommandItem) Action() Action {
59+ return c.action
60+}
61+
62+// Shortcut returns the shortcut associated with the command item.
63+func (c *CommandItem) Shortcut() string {
64+ return c.shortcut
65+}
66+
67+// Render implements ListItem.
68+func (c *CommandItem) Render(width int) string {
69+ return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
70+}
internal/ui/dialog/common.gonew file+130
@@ -0,0 +1,130 @@
1+package dialog
2+
3+import (
4+ "strings"
5+
6+ tea "charm.land/bubbletea/v2"
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/ui/common"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+ "github.com/charmbracelet/x/ansi"
11+)
12+
13+// InputCursor adjusts the cursor position for an input field within a dialog.
14+func InputCursor(t *styles.Styles, cur *tea.Cursor) *tea.Cursor {
15+ if cur != nil {
16+ titleStyle := t.Dialog.Title
17+ dialogStyle := t.Dialog.View
18+ inputStyle := t.Dialog.InputPrompt
19+ // Adjust cursor position to account for dialog layout
20+ cur.X += inputStyle.GetBorderLeftSize() +
21+ inputStyle.GetMarginLeft() +
22+ inputStyle.GetPaddingLeft() +
23+ dialogStyle.GetBorderLeftSize() +
24+ dialogStyle.GetPaddingLeft() +
25+ dialogStyle.GetMarginLeft()
26+ cur.Y += titleStyle.GetVerticalFrameSize() +
27+ inputStyle.GetBorderTopSize() +
28+ inputStyle.GetMarginTop() +
29+ inputStyle.GetPaddingTop() +
30+ inputStyle.GetBorderBottomSize() +
31+ inputStyle.GetMarginBottom() +
32+ inputStyle.GetPaddingBottom() +
33+ dialogStyle.GetPaddingTop() +
34+ dialogStyle.GetMarginTop() +
35+ dialogStyle.GetBorderTopSize()
36+ }
37+ return cur
38+}
39+
40+// RenderContext is a dialog rendering context that can be used to render
41+// common dialog layouts.
42+type RenderContext struct {
43+ // Styles is the styles to use for rendering.
44+ Styles *styles.Styles
45+ // Width is the total width of the dialog including any margins, borders,
46+ // and paddings.
47+ Width int
48+ // Gap is the gap between content parts. Zero means no gap.
49+ Gap int
50+ // Title is the title of the dialog. This will be styled using the default
51+ // dialog title style and prepended to the content parts slice.
52+ Title string
53+ // TitleInfo is additional information to display next to the title. This
54+ // part is displayed as is, any styling must be applied before setting this
55+ // field.
56+ TitleInfo string
57+ // Parts are the rendered parts of the dialog.
58+ Parts []string
59+ // Help is the help view content. This will be appended to the content parts
60+ // slice using the default dialog help style.
61+ Help string
62+}
63+
64+// NewRenderContext creates a new RenderContext with the provided styles and width.
65+func NewRenderContext(t *styles.Styles, width int) *RenderContext {
66+ return &RenderContext{
67+ Styles: t,
68+ Width: width,
69+ Parts: []string{},
70+ }
71+}
72+
73+// AddPart adds a rendered part to the dialog.
74+func (rc *RenderContext) AddPart(part string) {
75+ if len(part) > 0 {
76+ rc.Parts = append(rc.Parts, part)
77+ }
78+}
79+
80+// Render renders the dialog using the provided context.
81+func (rc *RenderContext) Render() string {
82+ titleStyle := rc.Styles.Dialog.Title
83+ dialogStyle := rc.Styles.Dialog.View.Width(rc.Width)
84+
85+ parts := []string{}
86+ if len(rc.Title) > 0 {
87+ var titleInfoWidth int
88+ if len(rc.TitleInfo) > 0 {
89+ titleInfoWidth = lipgloss.Width(rc.TitleInfo)
90+ }
91+ title := common.DialogTitle(rc.Styles, rc.Title,
92+ max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
93+ titleStyle.GetHorizontalFrameSize()-
94+ titleInfoWidth))
95+ if len(rc.TitleInfo) > 0 {
96+ title += rc.TitleInfo
97+ }
98+ parts = append(parts, titleStyle.Render(title))
99+ if rc.Gap > 0 {
100+ parts = append(parts, make([]string, rc.Gap)...)
101+ }
102+ }
103+
104+ if rc.Gap <= 0 {
105+ parts = append(parts, rc.Parts...)
106+ } else {
107+ for i, p := range rc.Parts {
108+ if len(p) > 0 {
109+ parts = append(parts, p)
110+ }
111+ if i < len(rc.Parts)-1 {
112+ parts = append(parts, make([]string, rc.Gap)...)
113+ }
114+ }
115+ }
116+
117+ if len(rc.Help) > 0 {
118+ if rc.Gap > 0 {
119+ parts = append(parts, make([]string, rc.Gap)...)
120+ }
121+ helpStyle := rc.Styles.Dialog.HelpView
122+ helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize())
123+ helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "")
124+ parts = append(parts, helpView)
125+ }
126+
127+ content := strings.Join(parts, "\n")
128+
129+ return dialogStyle.Render(content)
130+}
internal/ui/dialog/dialog.gonew file+197
@@ -0,0 +1,197 @@
1+package dialog
2+
3+import (
4+ "charm.land/bubbles/v2/key"
5+ tea "charm.land/bubbletea/v2"
6+ "charm.land/lipgloss/v2"
7+ "github.com/charmbracelet/crush/internal/ui/common"
8+ uv "github.com/charmbracelet/ultraviolet"
9+)
10+
11+// Dialog sizing constants.
12+const (
13+ // defaultDialogMaxWidth is the maximum width for standard dialogs.
14+ defaultDialogMaxWidth = 120
15+ // defaultDialogHeight is the default height for standard dialogs.
16+ defaultDialogHeight = 30
17+ // titleContentHeight is the height of the title content line.
18+ titleContentHeight = 1
19+ // inputContentHeight is the height of the input content line.
20+ inputContentHeight = 1
21+)
22+
23+// CloseKey is the default key binding to close dialogs.
24+var CloseKey = key.NewBinding(
25+ key.WithKeys("esc", "alt+esc"),
26+ key.WithHelp("esc", "exit"),
27+)
28+
29+// Action represents an action taken in a dialog after handling a message.
30+type Action any
31+
32+// Dialog is a component that can be displayed on top of the UI.
33+type Dialog interface {
34+ // ID returns the unique identifier of the dialog.
35+ ID() string
36+ // HandleMsg processes a message and returns an action. An [Action] can be
37+ // anything and the caller is responsible for handling it appropriately.
38+ HandleMsg(msg tea.Msg) Action
39+ // Draw draws the dialog onto the provided screen within the specified area
40+ // and returns the desired cursor position on the screen.
41+ Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor
42+}
43+
44+// LoadingDialog is a dialog that can show a loading state.
45+type LoadingDialog interface {
46+ StartLoading() tea.Cmd
47+ StopLoading()
48+}
49+
50+// Overlay manages multiple dialogs as an overlay.
51+type Overlay struct {
52+ dialogs []Dialog
53+}
54+
55+// NewOverlay creates a new [Overlay] instance.
56+func NewOverlay(dialogs ...Dialog) *Overlay {
57+ return &Overlay{
58+ dialogs: dialogs,
59+ }
60+}
61+
62+// HasDialogs checks if there are any active dialogs.
63+func (d *Overlay) HasDialogs() bool {
64+ return len(d.dialogs) > 0
65+}
66+
67+// ContainsDialog checks if a dialog with the specified ID exists.
68+func (d *Overlay) ContainsDialog(dialogID string) bool {
69+ for _, dialog := range d.dialogs {
70+ if dialog.ID() == dialogID {
71+ return true
72+ }
73+ }
74+ return false
75+}
76+
77+// OpenDialog opens a new dialog to the stack.
78+func (d *Overlay) OpenDialog(dialog Dialog) {
79+ d.dialogs = append(d.dialogs, dialog)
80+}
81+
82+// CloseDialog closes the dialog with the specified ID from the stack.
83+func (d *Overlay) CloseDialog(dialogID string) {
84+ for i, dialog := range d.dialogs {
85+ if dialog.ID() == dialogID {
86+ d.removeDialog(i)
87+ return
88+ }
89+ }
90+}
91+
92+// CloseFrontDialog closes the front dialog in the stack.
93+func (d *Overlay) CloseFrontDialog() {
94+ if len(d.dialogs) == 0 {
95+ return
96+ }
97+ d.removeDialog(len(d.dialogs) - 1)
98+}
99+
100+// Dialog returns the dialog with the specified ID, or nil if not found.
101+func (d *Overlay) Dialog(dialogID string) Dialog {
102+ for _, dialog := range d.dialogs {
103+ if dialog.ID() == dialogID {
104+ return dialog
105+ }
106+ }
107+ return nil
108+}
109+
110+// DialogLast returns the front dialog, or nil if there are no dialogs.
111+func (d *Overlay) DialogLast() Dialog {
112+ if len(d.dialogs) == 0 {
113+ return nil
114+ }
115+ return d.dialogs[len(d.dialogs)-1]
116+}
117+
118+// BringToFront brings the dialog with the specified ID to the front.
119+func (d *Overlay) BringToFront(dialogID string) {
120+ for i, dialog := range d.dialogs {
121+ if dialog.ID() == dialogID {
122+ // Move the dialog to the end of the slice
123+ d.dialogs = append(d.dialogs[:i], d.dialogs[i+1:]...)
124+ d.dialogs = append(d.dialogs, dialog)
125+ return
126+ }
127+ }
128+}
129+
130+// Update handles dialog updates.
131+func (d *Overlay) Update(msg tea.Msg) tea.Msg {
132+ if len(d.dialogs) == 0 {
133+ return nil
134+ }
135+
136+ idx := len(d.dialogs) - 1 // active dialog is the last one
137+ dialog := d.dialogs[idx]
138+ if dialog == nil {
139+ return nil
140+ }
141+
142+ return dialog.HandleMsg(msg)
143+}
144+
145+// StartLoading starts the loading state for the front dialog if it
146+// implements [LoadingDialog].
147+func (d *Overlay) StartLoading() tea.Cmd {
148+ dialog := d.DialogLast()
149+ if ld, ok := dialog.(LoadingDialog); ok {
150+ return ld.StartLoading()
151+ }
152+ return nil
153+}
154+
155+// StopLoading stops the loading state for the front dialog if it
156+// implements [LoadingDialog].
157+func (d *Overlay) StopLoading() {
158+ dialog := d.DialogLast()
159+ if ld, ok := dialog.(LoadingDialog); ok {
160+ ld.StopLoading()
161+ }
162+}
163+
164+// DrawCenterCursor draws the given string view centered in the screen area and
165+// adjusts the cursor position accordingly.
166+func DrawCenterCursor(scr uv.Screen, area uv.Rectangle, view string, cur *tea.Cursor) {
167+ width, height := lipgloss.Size(view)
168+ center := common.CenterRect(area, width, height)
169+ if cur != nil {
170+ cur.X += center.Min.X
171+ cur.Y += center.Min.Y
172+ }
173+
174+ uv.NewStyledString(view).Draw(scr, center)
175+}
176+
177+// DrawCenter draws the given string view centered in the screen area.
178+func DrawCenter(scr uv.Screen, area uv.Rectangle, view string) {
179+ DrawCenterCursor(scr, area, view, nil)
180+}
181+
182+// Draw renders the overlay and its dialogs.
183+func (d *Overlay) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
184+ var cur *tea.Cursor
185+ for _, dialog := range d.dialogs {
186+ cur = dialog.Draw(scr, area)
187+ }
188+ return cur
189+}
190+
191+// removeDialog removes a dialog from the stack.
192+func (d *Overlay) removeDialog(idx int) {
193+ if idx < 0 || idx >= len(d.dialogs) {
194+ return
195+ }
196+ d.dialogs = append(d.dialogs[:idx], d.dialogs[idx+1:]...)
197+}
internal/ui/dialog/filepicker.gonew file+304
@@ -0,0 +1,304 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "image"
6+ _ "image/jpeg" // register JPEG format
7+ _ "image/png" // register PNG format
8+ "os"
9+ "strings"
10+ "sync"
11+
12+ "charm.land/bubbles/v2/filepicker"
13+ "charm.land/bubbles/v2/help"
14+ "charm.land/bubbles/v2/key"
15+ tea "charm.land/bubbletea/v2"
16+ "charm.land/lipgloss/v2"
17+ "github.com/charmbracelet/crush/internal/home"
18+ "github.com/charmbracelet/crush/internal/ui/common"
19+ fimage "github.com/charmbracelet/crush/internal/ui/image"
20+ uv "github.com/charmbracelet/ultraviolet"
21+)
22+
23+// FilePickerID is the identifier for the FilePicker dialog.
24+const FilePickerID = "filepicker"
25+
26+// FilePicker is a dialog that allows users to select files or directories.
27+type FilePicker struct {
28+ com *common.Common
29+
30+ imgEnc fimage.Encoding
31+ imgPrevWidth, imgPrevHeight int
32+ cellSize fimage.CellSize
33+
34+ fp filepicker.Model
35+ help help.Model
36+ previewingImage bool // indicates if an image is being previewed
37+ isTmux bool
38+
39+ km struct {
40+ Select,
41+ Down,
42+ Up,
43+ Forward,
44+ Backward,
45+ Navigate,
46+ Close key.Binding
47+ }
48+}
49+
50+var _ Dialog = (*FilePicker)(nil)
51+
52+// NewFilePicker creates a new [FilePicker] dialog.
53+func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) {
54+ f := new(FilePicker)
55+ f.com = com
56+
57+ help := help.New()
58+ help.Styles = com.Styles.DialogHelpStyles()
59+
60+ f.help = help
61+
62+ f.km.Select = key.NewBinding(
63+ key.WithKeys("enter"),
64+ key.WithHelp("enter", "accept"),
65+ )
66+ f.km.Down = key.NewBinding(
67+ key.WithKeys("down", "j"),
68+ key.WithHelp("down/j", "move down"),
69+ )
70+ f.km.Up = key.NewBinding(
71+ key.WithKeys("up", "k"),
72+ key.WithHelp("up/k", "move up"),
73+ )
74+ f.km.Forward = key.NewBinding(
75+ key.WithKeys("right", "l"),
76+ key.WithHelp("right/l", "move forward"),
77+ )
78+ f.km.Backward = key.NewBinding(
79+ key.WithKeys("left", "h"),
80+ key.WithHelp("left/h", "move backward"),
81+ )
82+ f.km.Navigate = key.NewBinding(
83+ key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
84+ key.WithHelp("↑↓←→", "navigate"),
85+ )
86+ f.km.Close = key.NewBinding(
87+ key.WithKeys("esc", "alt+esc"),
88+ key.WithHelp("esc", "close/exit"),
89+ )
90+
91+ fp := filepicker.New()
92+ fp.AllowedTypes = common.AllowedImageTypes
93+ fp.ShowPermissions = false
94+ fp.ShowSize = false
95+ fp.AutoHeight = false
96+ fp.Styles = com.Styles.FilePicker
97+ fp.Cursor = ""
98+ fp.CurrentDirectory = f.WorkingDir()
99+
100+ f.fp = fp
101+
102+ return f, f.fp.Init()
103+}
104+
105+// SetImageCapabilities sets the image capabilities for the [FilePicker].
106+func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) {
107+ if caps != nil {
108+ if caps.SupportsKittyGraphics {
109+ f.imgEnc = fimage.EncodingKitty
110+ }
111+ f.cellSize = caps.CellSize()
112+ _, f.isTmux = caps.Env.LookupEnv("TMUX")
113+ }
114+}
115+
116+// WorkingDir returns the current working directory of the [FilePicker].
117+func (f *FilePicker) WorkingDir() string {
118+ wd := f.com.Config().WorkingDir()
119+ if len(wd) > 0 {
120+ return wd
121+ }
122+
123+ cwd, err := os.Getwd()
124+ if err != nil {
125+ return home.Dir()
126+ }
127+
128+ return cwd
129+}
130+
131+// ShortHelp returns the short help key bindings for the [FilePicker] dialog.
132+func (f *FilePicker) ShortHelp() []key.Binding {
133+ return []key.Binding{
134+ f.km.Navigate,
135+ f.km.Select,
136+ f.km.Close,
137+ }
138+}
139+
140+// FullHelp returns the full help key bindings for the [FilePicker] dialog.
141+func (f *FilePicker) FullHelp() [][]key.Binding {
142+ return [][]key.Binding{
143+ {
144+ f.km.Select,
145+ f.km.Down,
146+ f.km.Up,
147+ f.km.Forward,
148+ },
149+ {
150+ f.km.Backward,
151+ f.km.Close,
152+ },
153+ }
154+}
155+
156+// ID returns the identifier of the [FilePicker] dialog.
157+func (f *FilePicker) ID() string {
158+ return FilePickerID
159+}
160+
161+// HandleMsg updates the [FilePicker] dialog based on the given message.
162+func (f *FilePicker) HandleMsg(msg tea.Msg) Action {
163+ var cmds []tea.Cmd
164+ switch msg := msg.(type) {
165+ case tea.KeyPressMsg:
166+ switch {
167+ case key.Matches(msg, f.km.Close):
168+ return ActionClose{}
169+ }
170+ }
171+
172+ var cmd tea.Cmd
173+ f.fp, cmd = f.fp.Update(msg)
174+ if selFile := f.fp.HighlightedPath(); selFile != "" {
175+ var allowed bool
176+ for _, allowedExt := range f.fp.AllowedTypes {
177+ if strings.HasSuffix(strings.ToLower(selFile), allowedExt) {
178+ allowed = true
179+ break
180+ }
181+ }
182+
183+ f.previewingImage = allowed
184+ if allowed && !fimage.HasTransmitted(selFile, f.imgPrevWidth, f.imgPrevHeight) {
185+ f.previewingImage = false
186+ img, err := loadImage(selFile)
187+ if err == nil {
188+ cmds = append(cmds, tea.Sequence(
189+ f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux),
190+ func() tea.Msg {
191+ f.previewingImage = true
192+ return nil
193+ },
194+ ))
195+ }
196+ }
197+ }
198+ if cmd != nil {
199+ cmds = append(cmds, cmd)
200+ }
201+
202+ if didSelect, path := f.fp.DidSelectFile(msg); didSelect {
203+ return ActionFilePickerSelected{Path: path}
204+ }
205+
206+ return ActionCmd{tea.Batch(cmds...)}
207+}
208+
209+const (
210+ filePickerMinWidth = 70
211+ filePickerMinHeight = 10
212+)
213+
214+// Draw renders the [FilePicker] dialog as a string.
215+func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
216+ width := max(0, min(filePickerMinWidth, area.Dx()))
217+ height := max(0, min(10, area.Dy()))
218+ innerWidth := width - f.com.Styles.Dialog.View.GetHorizontalFrameSize()
219+ imgPrevHeight := filePickerMinHeight*2 - f.com.Styles.Dialog.ImagePreview.GetVerticalFrameSize()
220+ imgPrevWidth := innerWidth - f.com.Styles.Dialog.ImagePreview.GetHorizontalFrameSize()
221+ f.imgPrevWidth = imgPrevWidth
222+ f.imgPrevHeight = imgPrevHeight
223+ f.fp.SetHeight(height)
224+
225+ styles := f.com.Styles.FilePicker
226+ styles.File = styles.File.Width(innerWidth)
227+ styles.Directory = styles.Directory.Width(innerWidth)
228+ styles.Selected = styles.Selected.PaddingLeft(1).Width(innerWidth)
229+ styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(innerWidth)
230+ f.fp.Styles = styles
231+
232+ t := f.com.Styles
233+ rc := NewRenderContext(t, width)
234+ rc.Gap = 1
235+ rc.Title = "Add Image"
236+ rc.Help = f.help.View(f)
237+
238+ imgPreview := t.Dialog.ImagePreview.Align(lipgloss.Center).Width(innerWidth).Render(f.imagePreview(imgPrevWidth, imgPrevHeight))
239+ rc.AddPart(imgPreview)
240+
241+ files := strings.TrimSpace(f.fp.View())
242+ rc.AddPart(files)
243+
244+ view := rc.Render()
245+
246+ DrawCenter(scr, area, view)
247+ return nil
248+}
249+
250+var (
251+ imagePreviewCache = map[string]string{}
252+ imagePreviewMutex sync.RWMutex
253+)
254+
255+// imagePreview returns the image preview section of the [FilePicker] dialog.
256+func (f *FilePicker) imagePreview(imgPrevWidth, imgPrevHeight int) string {
257+ if !f.previewingImage {
258+ key := fmt.Sprintf("%dx%d", imgPrevWidth, imgPrevHeight)
259+ imagePreviewMutex.RLock()
260+ cached, ok := imagePreviewCache[key]
261+ imagePreviewMutex.RUnlock()
262+ if ok {
263+ return cached
264+ }
265+
266+ var sb strings.Builder
267+ for y := range imgPrevHeight {
268+ for range imgPrevWidth {
269+ sb.WriteRune('█')
270+ }
271+ if y < imgPrevHeight-1 {
272+ sb.WriteRune('\n')
273+ }
274+ }
275+
276+ imagePreviewMutex.Lock()
277+ imagePreviewCache[key] = sb.String()
278+ imagePreviewMutex.Unlock()
279+
280+ return sb.String()
281+ }
282+
283+ if id := f.fp.HighlightedPath(); id != "" {
284+ r := f.imgEnc.Render(id, imgPrevWidth, imgPrevHeight)
285+ return r
286+ }
287+
288+ return ""
289+}
290+
291+func loadImage(path string) (img image.Image, err error) {
292+ file, err := os.Open(path)
293+ if err != nil {
294+ return nil, err
295+ }
296+ defer file.Close()
297+
298+ img, _, err = image.Decode(file)
299+ if err != nil {
300+ return nil, err
301+ }
302+
303+ return img, nil
304+}
internal/ui/dialog/models.gonew file+478
@@ -0,0 +1,478 @@
1+package dialog
2+
3+import (
4+ "cmp"
5+ "fmt"
6+ "slices"
7+ "strings"
8+
9+ "charm.land/bubbles/v2/help"
10+ "charm.land/bubbles/v2/key"
11+ "charm.land/bubbles/v2/textinput"
12+ tea "charm.land/bubbletea/v2"
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/ui/common"
16+ "github.com/charmbracelet/crush/internal/uiutil"
17+ uv "github.com/charmbracelet/ultraviolet"
18+)
19+
20+// ModelType represents the type of model to select.
21+type ModelType int
22+
23+const (
24+ ModelTypeLarge ModelType = iota
25+ ModelTypeSmall
26+)
27+
28+// String returns the string representation of the [ModelType].
29+func (mt ModelType) String() string {
30+ switch mt {
31+ case ModelTypeLarge:
32+ return "Large Task"
33+ case ModelTypeSmall:
34+ return "Small Task"
35+ default:
36+ return "Unknown"
37+ }
38+}
39+
40+// Config returns the corresponding config model type.
41+func (mt ModelType) Config() config.SelectedModelType {
42+ switch mt {
43+ case ModelTypeLarge:
44+ return config.SelectedModelTypeLarge
45+ case ModelTypeSmall:
46+ return config.SelectedModelTypeSmall
47+ default:
48+ return ""
49+ }
50+}
51+
52+// Placeholder returns the input placeholder for the model type.
53+func (mt ModelType) Placeholder() string {
54+ switch mt {
55+ case ModelTypeLarge:
56+ return largeModelInputPlaceholder
57+ case ModelTypeSmall:
58+ return smallModelInputPlaceholder
59+ default:
60+ return ""
61+ }
62+}
63+
64+const (
65+ largeModelInputPlaceholder = "Choose a model for large, complex tasks"
66+ smallModelInputPlaceholder = "Choose a model for small, simple tasks"
67+)
68+
69+// ModelsID is the identifier for the model selection dialog.
70+const ModelsID = "models"
71+
72+// Models represents a model selection dialog.
73+type Models struct {
74+ com *common.Common
75+
76+ modelType ModelType
77+ providers []catwalk.Provider
78+
79+ keyMap struct {
80+ Tab key.Binding
81+ UpDown key.Binding
82+ Select key.Binding
83+ Next key.Binding
84+ Previous key.Binding
85+ Close key.Binding
86+ }
87+ list *ModelsList
88+ input textinput.Model
89+ help help.Model
90+}
91+
92+var _ Dialog = (*Models)(nil)
93+
94+// NewModels creates a new Models dialog.
95+func NewModels(com *common.Common) (*Models, error) {
96+ t := com.Styles
97+ m := &Models{}
98+ m.com = com
99+ help := help.New()
100+ help.Styles = t.DialogHelpStyles()
101+
102+ m.help = help
103+ m.list = NewModelsList(t)
104+ m.list.Focus()
105+ m.list.SetSelected(0)
106+
107+ m.input = textinput.New()
108+ m.input.SetVirtualCursor(false)
109+ m.input.Placeholder = largeModelInputPlaceholder
110+ m.input.SetStyles(com.Styles.TextInput)
111+ m.input.Focus()
112+
113+ m.keyMap.Tab = key.NewBinding(
114+ key.WithKeys("tab", "shift+tab"),
115+ key.WithHelp("tab", "toggle type"),
116+ )
117+ m.keyMap.Select = key.NewBinding(
118+ key.WithKeys("enter", "ctrl+y"),
119+ key.WithHelp("enter", "confirm"),
120+ )
121+ m.keyMap.UpDown = key.NewBinding(
122+ key.WithKeys("up", "down"),
123+ key.WithHelp("↑/↓", "choose"),
124+ )
125+ m.keyMap.Next = key.NewBinding(
126+ key.WithKeys("down", "ctrl+n"),
127+ key.WithHelp("↓", "next item"),
128+ )
129+ m.keyMap.Previous = key.NewBinding(
130+ key.WithKeys("up", "ctrl+p"),
131+ key.WithHelp("↑", "previous item"),
132+ )
133+ m.keyMap.Close = CloseKey
134+
135+ providers, err := getFilteredProviders(com.Config())
136+ if err != nil {
137+ return nil, fmt.Errorf("failed to get providers: %w", err)
138+ }
139+
140+ m.providers = providers
141+ if err := m.setProviderItems(); err != nil {
142+ return nil, fmt.Errorf("failed to set provider items: %w", err)
143+ }
144+
145+ return m, nil
146+}
147+
148+// ID implements Dialog.
149+func (m *Models) ID() string {
150+ return ModelsID
151+}
152+
153+// HandleMsg implements Dialog.
154+func (m *Models) HandleMsg(msg tea.Msg) Action {
155+ switch msg := msg.(type) {
156+ case tea.KeyPressMsg:
157+ switch {
158+ case key.Matches(msg, m.keyMap.Close):
159+ return ActionClose{}
160+ case key.Matches(msg, m.keyMap.Previous):
161+ m.list.Focus()
162+ if m.list.IsSelectedFirst() {
163+ m.list.SelectLast()
164+ m.list.ScrollToBottom()
165+ break
166+ }
167+ m.list.SelectPrev()
168+ m.list.ScrollToSelected()
169+ case key.Matches(msg, m.keyMap.Next):
170+ m.list.Focus()
171+ if m.list.IsSelectedLast() {
172+ m.list.SelectFirst()
173+ m.list.ScrollToTop()
174+ break
175+ }
176+ m.list.SelectNext()
177+ m.list.ScrollToSelected()
178+ case key.Matches(msg, m.keyMap.Select):
179+ selectedItem := m.list.SelectedItem()
180+ if selectedItem == nil {
181+ break
182+ }
183+
184+ modelItem, ok := selectedItem.(*ModelItem)
185+ if !ok {
186+ break
187+ }
188+
189+ return ActionSelectModel{
190+ Provider: modelItem.prov,
191+ Model: modelItem.SelectedModel(),
192+ ModelType: modelItem.SelectedModelType(),
193+ }
194+ case key.Matches(msg, m.keyMap.Tab):
195+ if m.modelType == ModelTypeLarge {
196+ m.modelType = ModelTypeSmall
197+ } else {
198+ m.modelType = ModelTypeLarge
199+ }
200+ if err := m.setProviderItems(); err != nil {
201+ return uiutil.ReportError(err)
202+ }
203+ default:
204+ var cmd tea.Cmd
205+ m.input, cmd = m.input.Update(msg)
206+ value := m.input.Value()
207+ m.list.SetFilter(value)
208+ m.list.ScrollToSelected()
209+ return ActionCmd{cmd}
210+ }
211+ }
212+ return nil
213+}
214+
215+// Cursor returns the cursor for the dialog.
216+func (m *Models) Cursor() *tea.Cursor {
217+ return InputCursor(m.com.Styles, m.input.Cursor())
218+}
219+
220+// modelTypeRadioView returns the radio view for model type selection.
221+func (m *Models) modelTypeRadioView() string {
222+ t := m.com.Styles
223+ textStyle := t.HalfMuted
224+ largeRadioStyle := t.RadioOff
225+ smallRadioStyle := t.RadioOff
226+ if m.modelType == ModelTypeLarge {
227+ largeRadioStyle = t.RadioOn
228+ } else {
229+ smallRadioStyle = t.RadioOn
230+ }
231+
232+ largeRadio := largeRadioStyle.Padding(0, 1).Render()
233+ smallRadio := smallRadioStyle.Padding(0, 1).Render()
234+
235+ return fmt.Sprintf("%s%s %s%s",
236+ largeRadio, textStyle.Render(ModelTypeLarge.String()),
237+ smallRadio, textStyle.Render(ModelTypeSmall.String()))
238+}
239+
240+// Draw implements [Dialog].
241+func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
242+ t := m.com.Styles
243+ width := max(0, min(defaultDialogMaxWidth, area.Dx()))
244+ height := max(0, min(defaultDialogHeight, area.Dy()))
245+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
246+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
247+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
248+ t.Dialog.HelpView.GetVerticalFrameSize() +
249+ t.Dialog.View.GetVerticalFrameSize()
250+ m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
251+ m.list.SetSize(innerWidth, height-heightOffset)
252+ m.help.SetWidth(innerWidth)
253+
254+ rc := NewRenderContext(t, width)
255+ rc.Title = "Switch Model"
256+ rc.TitleInfo = m.modelTypeRadioView()
257+ inputView := t.Dialog.InputPrompt.Render(m.input.View())
258+ rc.AddPart(inputView)
259+ listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
260+ rc.AddPart(listView)
261+ rc.Help = m.help.View(m)
262+
263+ view := rc.Render()
264+
265+ cur := m.Cursor()
266+ DrawCenterCursor(scr, area, view, cur)
267+ return cur
268+}
269+
270+// ShortHelp returns the short help view.
271+func (m *Models) ShortHelp() []key.Binding {
272+ return []key.Binding{
273+ m.keyMap.UpDown,
274+ m.keyMap.Tab,
275+ m.keyMap.Select,
276+ m.keyMap.Close,
277+ }
278+}
279+
280+// FullHelp returns the full help view.
281+func (m *Models) FullHelp() [][]key.Binding {
282+ return [][]key.Binding{
283+ {
284+ m.keyMap.Select,
285+ m.keyMap.Next,
286+ m.keyMap.Previous,
287+ m.keyMap.Tab,
288+ },
289+ {
290+ m.keyMap.Close,
291+ },
292+ }
293+}
294+
295+// setProviderItems sets the provider items in the list.
296+func (m *Models) setProviderItems() error {
297+ t := m.com.Styles
298+ cfg := m.com.Config()
299+
300+ var selectedItemID string
301+ selectedType := m.modelType.Config()
302+ currentModel := cfg.Models[selectedType]
303+ recentItems := cfg.RecentModels[selectedType]
304+
305+ // Track providers already added to avoid duplicates
306+ addedProviders := make(map[string]bool)
307+
308+ // Get a list of known providers to compare against
309+ knownProviders, err := config.Providers(cfg)
310+ if err != nil {
311+ return fmt.Errorf("failed to get providers: %w", err)
312+ }
313+
314+ containsProviderFunc := func(id string) func(p catwalk.Provider) bool {
315+ return func(p catwalk.Provider) bool {
316+ return p.ID == catwalk.InferenceProvider(id)
317+ }
318+ }
319+
320+ // itemsMap contains the keys of added model items.
321+ itemsMap := make(map[string]*ModelItem)
322+ groups := []ModelGroup{}
323+ for id, p := range cfg.Providers.Seq2() {
324+ if p.Disable {
325+ continue
326+ }
327+
328+ // Check if this provider is not in the known providers list
329+ if !slices.ContainsFunc(knownProviders, containsProviderFunc(id)) ||
330+ !slices.ContainsFunc(m.providers, containsProviderFunc(id)) {
331+ provider := p.ToProvider()
332+
333+ // Add this unknown provider to the list
334+ name := cmp.Or(p.Name, id)
335+
336+ addedProviders[id] = true
337+
338+ group := NewModelGroup(t, name, true)
339+ for _, model := range p.Models {
340+ item := NewModelItem(t, provider, model, m.modelType, false)
341+ group.AppendItems(item)
342+ itemsMap[item.ID()] = item
343+ if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
344+ selectedItemID = item.ID()
345+ }
346+ }
347+ if len(group.Items) > 0 {
348+ groups = append(groups, group)
349+ }
350+ }
351+ }
352+
353+ // Now add known providers from the predefined list
354+ for _, provider := range m.providers {
355+ providerID := string(provider.ID)
356+ if addedProviders[providerID] {
357+ continue
358+ }
359+
360+ providerConfig, providerConfigured := cfg.Providers.Get(providerID)
361+ if providerConfigured && providerConfig.Disable {
362+ continue
363+ }
364+
365+ displayProvider := provider
366+ if providerConfigured {
367+ displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name)
368+ modelIndex := make(map[string]int, len(displayProvider.Models))
369+ for i, model := range displayProvider.Models {
370+ modelIndex[model.ID] = i
371+ }
372+ for _, model := range providerConfig.Models {
373+ if model.ID == "" {
374+ continue
375+ }
376+ if idx, ok := modelIndex[model.ID]; ok {
377+ if model.Name != "" {
378+ displayProvider.Models[idx].Name = model.Name
379+ }
380+ continue
381+ }
382+ if model.Name == "" {
383+ model.Name = model.ID
384+ }
385+ displayProvider.Models = append(displayProvider.Models, model)
386+ modelIndex[model.ID] = len(displayProvider.Models) - 1
387+ }
388+ }
389+
390+ name := displayProvider.Name
391+ if name == "" {
392+ name = providerID
393+ }
394+
395+ group := NewModelGroup(t, name, providerConfigured)
396+ for _, model := range displayProvider.Models {
397+ item := NewModelItem(t, provider, model, m.modelType, false)
398+ group.AppendItems(item)
399+ itemsMap[item.ID()] = item
400+ if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
401+ selectedItemID = item.ID()
402+ }
403+ }
404+
405+ groups = append(groups, group)
406+ }
407+
408+ if len(recentItems) > 0 {
409+ recentGroup := NewModelGroup(t, "Recently used", false)
410+
411+ var validRecentItems []config.SelectedModel
412+ for _, recent := range recentItems {
413+ key := modelKey(recent.Provider, recent.Model)
414+ item, ok := itemsMap[key]
415+ if !ok {
416+ continue
417+ }
418+
419+ // Show provider for recent items
420+ item = NewModelItem(t, item.prov, item.model, m.modelType, true)
421+ item.showProvider = true
422+
423+ validRecentItems = append(validRecentItems, recent)
424+ recentGroup.AppendItems(item)
425+ if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider {
426+ selectedItemID = item.ID()
427+ }
428+ }
429+
430+ if len(validRecentItems) != len(recentItems) {
431+ // FIXME: Does this need to be here? Is it mutating the config during a read?
432+ if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
433+ return fmt.Errorf("failed to update recent models: %w", err)
434+ }
435+ }
436+
437+ if len(recentGroup.Items) > 0 {
438+ groups = append([]ModelGroup{recentGroup}, groups...)
439+ }
440+ }
441+
442+ // Set model groups in the list.
443+ m.list.SetGroups(groups...)
444+ m.list.SetSelectedItem(selectedItemID)
445+
446+ // Update placeholder based on model type
447+ m.input.Placeholder = m.modelType.Placeholder()
448+
449+ return nil
450+}
451+
452+func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) {
453+ providers, err := config.Providers(cfg)
454+ if err != nil {
455+ return nil, fmt.Errorf("failed to get providers: %w", err)
456+ }
457+ var filteredProviders []catwalk.Provider
458+ for _, p := range providers {
459+ var (
460+ isAzure = p.ID == catwalk.InferenceProviderAzure
461+ isCopilot = p.ID == catwalk.InferenceProviderCopilot
462+ isHyper = string(p.ID) == "hyper"
463+ hasAPIKeyEnv = strings.HasPrefix(p.APIKey, "$")
464+ _, isConfigured = cfg.Providers.Get(string(p.ID))
465+ )
466+ if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured {
467+ filteredProviders = append(filteredProviders, p)
468+ }
469+ }
470+ return filteredProviders, nil
471+}
472+
473+func modelKey(providerID, modelID string) string {
474+ if providerID == "" || modelID == "" {
475+ return ""
476+ }
477+ return providerID + ":" + modelID
478+}
internal/ui/dialog/models_item.gonew file+124
@@ -0,0 +1,124 @@
1+package dialog
2+
3+import (
4+ "charm.land/lipgloss/v2"
5+ "github.com/charmbracelet/catwalk/pkg/catwalk"
6+ "github.com/charmbracelet/crush/internal/config"
7+ "github.com/charmbracelet/crush/internal/ui/common"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+ "github.com/charmbracelet/x/ansi"
10+ "github.com/sahilm/fuzzy"
11+)
12+
13+// ModelGroup represents a group of model items.
14+type ModelGroup struct {
15+ Title string
16+ Items []*ModelItem
17+ configured bool
18+ t *styles.Styles
19+}
20+
21+// NewModelGroup creates a new ModelGroup.
22+func NewModelGroup(t *styles.Styles, title string, configured bool, items ...*ModelItem) ModelGroup {
23+ return ModelGroup{
24+ Title: title,
25+ Items: items,
26+ configured: configured,
27+ t: t,
28+ }
29+}
30+
31+// AppendItems appends [ModelItem]s to the group.
32+func (m *ModelGroup) AppendItems(items ...*ModelItem) {
33+ m.Items = append(m.Items, items...)
34+}
35+
36+// Render implements [list.Item].
37+func (m *ModelGroup) Render(width int) string {
38+ var configured string
39+ if m.configured {
40+ configuredIcon := m.t.ToolCallSuccess.Render()
41+ configuredText := m.t.Subtle.Render("Configured")
42+ configured = configuredIcon + " " + configuredText
43+ }
44+
45+ title := " " + m.Title + " "
46+ title = ansi.Truncate(title, max(0, width-lipgloss.Width(configured)-1), "…")
47+
48+ return common.Section(m.t, title, width, configured)
49+}
50+
51+// ModelItem represents a list item for a model type.
52+type ModelItem struct {
53+ prov catwalk.Provider
54+ model catwalk.Model
55+ modelType ModelType
56+
57+ cache map[int]string
58+ t *styles.Styles
59+ m fuzzy.Match
60+ focused bool
61+ showProvider bool
62+}
63+
64+// SelectedModel returns this model item as a [config.SelectedModel] instance.
65+func (m *ModelItem) SelectedModel() config.SelectedModel {
66+ return config.SelectedModel{
67+ Model: m.model.ID,
68+ Provider: string(m.prov.ID),
69+ ReasoningEffort: m.model.DefaultReasoningEffort,
70+ MaxTokens: m.model.DefaultMaxTokens,
71+ }
72+}
73+
74+// SelectedModelType returns the type of model represented by this item.
75+func (m *ModelItem) SelectedModelType() config.SelectedModelType {
76+ return m.modelType.Config()
77+}
78+
79+var _ ListItem = &ModelItem{}
80+
81+// NewModelItem creates a new ModelItem.
82+func NewModelItem(t *styles.Styles, prov catwalk.Provider, model catwalk.Model, typ ModelType, showProvider bool) *ModelItem {
83+ return &ModelItem{
84+ prov: prov,
85+ model: model,
86+ modelType: typ,
87+ t: t,
88+ cache: make(map[int]string),
89+ showProvider: showProvider,
90+ }
91+}
92+
93+// Filter implements ListItem.
94+func (m *ModelItem) Filter() string {
95+ return m.model.Name
96+}
97+
98+// ID implements ListItem.
99+func (m *ModelItem) ID() string {
100+ return modelKey(string(m.prov.ID), m.model.ID)
101+}
102+
103+// Render implements ListItem.
104+func (m *ModelItem) Render(width int) string {
105+ var providerInfo string
106+ if m.showProvider {
107+ providerInfo = string(m.prov.Name)
108+ }
109+ return renderItem(m.t, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m)
110+}
111+
112+// SetFocused implements ListItem.
113+func (m *ModelItem) SetFocused(focused bool) {
114+ if m.focused != focused {
115+ m.cache = nil
116+ }
117+ m.focused = focused
118+}
119+
120+// SetMatch implements ListItem.
121+func (m *ModelItem) SetMatch(fm fuzzy.Match) {
122+ m.cache = nil
123+ m.m = fm
124+}
internal/ui/dialog/models_list.gonew file+273
@@ -0,0 +1,273 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "sort"
7+ "strings"
8+
9+ "github.com/charmbracelet/crush/internal/ui/list"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+ "github.com/sahilm/fuzzy"
12+)
13+
14+// ModelsList is a list specifically for model items and groups.
15+type ModelsList struct {
16+ *list.List
17+ groups []ModelGroup
18+ query string
19+ t *styles.Styles
20+}
21+
22+// NewModelsList creates a new list suitable for model items and groups.
23+func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList {
24+ f := &ModelsList{
25+ List: list.NewList(),
26+ groups: groups,
27+ t: sty,
28+ }
29+ f.RegisterRenderCallback(list.FocusedRenderCallback(f.List))
30+ return f
31+}
32+
33+// Len returns the number of model items across all groups.
34+func (f *ModelsList) Len() int {
35+ n := 0
36+ for _, g := range f.groups {
37+ n += len(g.Items)
38+ }
39+ return n
40+}
41+
42+// SetGroups sets the model groups and updates the list items.
43+func (f *ModelsList) SetGroups(groups ...ModelGroup) {
44+ f.groups = groups
45+ items := []list.Item{}
46+ for _, g := range f.groups {
47+ items = append(items, &g)
48+ for _, item := range g.Items {
49+ items = append(items, item)
50+ }
51+ // Add a space separator after each provider section
52+ items = append(items, list.NewSpacerItem(1))
53+ }
54+ f.SetItems(items...)
55+}
56+
57+// SetFilter sets the filter query and updates the list items.
58+func (f *ModelsList) SetFilter(q string) {
59+ f.query = q
60+}
61+
62+// SetSelected sets the selected item index. It overrides the base method to
63+// skip non-model items.
64+func (f *ModelsList) SetSelected(index int) {
65+ if index < 0 || index >= f.Len() {
66+ f.List.SetSelected(index)
67+ return
68+ }
69+
70+ f.List.SetSelected(index)
71+ for {
72+ selectedItem := f.SelectedItem()
73+ if _, ok := selectedItem.(*ModelItem); ok {
74+ return
75+ }
76+ f.List.SetSelected(index + 1)
77+ index++
78+ if index >= f.Len() {
79+ return
80+ }
81+ }
82+}
83+
84+// SetSelectedItem sets the selected item in the list by item ID.
85+func (f *ModelsList) SetSelectedItem(itemID string) {
86+ if itemID == "" {
87+ f.SetSelected(0)
88+ return
89+ }
90+
91+ count := 0
92+ for _, g := range f.groups {
93+ for _, item := range g.Items {
94+ if item.ID() == itemID {
95+ f.SetSelected(count)
96+ return
97+ }
98+ count++
99+ }
100+ }
101+}
102+
103+// SelectNext selects the next model item, skipping any non-focusable items
104+// like group headers and spacers.
105+func (f *ModelsList) SelectNext() (v bool) {
106+ for {
107+ v = f.List.SelectNext()
108+ selectedItem := f.SelectedItem()
109+ if _, ok := selectedItem.(*ModelItem); ok {
110+ return v
111+ }
112+ }
113+}
114+
115+// SelectPrev selects the previous model item, skipping any non-focusable items
116+// like group headers and spacers.
117+func (f *ModelsList) SelectPrev() (v bool) {
118+ for {
119+ v = f.List.SelectPrev()
120+ selectedItem := f.SelectedItem()
121+ if _, ok := selectedItem.(*ModelItem); ok {
122+ return v
123+ }
124+ }
125+}
126+
127+// SelectFirst selects the first model item in the list.
128+func (f *ModelsList) SelectFirst() (v bool) {
129+ v = f.List.SelectFirst()
130+ for {
131+ selectedItem := f.SelectedItem()
132+ if _, ok := selectedItem.(*ModelItem); ok {
133+ return v
134+ }
135+ v = f.List.SelectNext()
136+ }
137+}
138+
139+// SelectLast selects the last model item in the list.
140+func (f *ModelsList) SelectLast() (v bool) {
141+ v = f.List.SelectLast()
142+ for {
143+ selectedItem := f.SelectedItem()
144+ if _, ok := selectedItem.(*ModelItem); ok {
145+ return v
146+ }
147+ v = f.List.SelectPrev()
148+ }
149+}
150+
151+// IsSelectedFirst checks if the selected item is the first model item.
152+func (f *ModelsList) IsSelectedFirst() bool {
153+ originalIndex := f.Selected()
154+ f.SelectFirst()
155+ isFirst := f.Selected() == originalIndex
156+ f.List.SetSelected(originalIndex)
157+ return isFirst
158+}
159+
160+// IsSelectedLast checks if the selected item is the last model item.
161+func (f *ModelsList) IsSelectedLast() bool {
162+ originalIndex := f.Selected()
163+ f.SelectLast()
164+ isLast := f.Selected() == originalIndex
165+ f.List.SetSelected(originalIndex)
166+ return isLast
167+}
168+
169+// VisibleItems returns the visible items after filtering.
170+func (f *ModelsList) VisibleItems() []list.Item {
171+ query := strings.ToLower(strings.ReplaceAll(f.query, " ", ""))
172+
173+ if query == "" {
174+ // No filter, return all items with group headers
175+ items := []list.Item{}
176+ for _, g := range f.groups {
177+ items = append(items, &g)
178+ for _, item := range g.Items {
179+ item.SetMatch(fuzzy.Match{})
180+ items = append(items, item)
181+ }
182+ // Add a space separator after each provider section
183+ items = append(items, list.NewSpacerItem(1))
184+ }
185+ return items
186+ }
187+
188+ filterableItems := make([]list.FilterableItem, 0, f.Len())
189+ for _, g := range f.groups {
190+ for _, item := range g.Items {
191+ filterableItems = append(filterableItems, item)
192+ }
193+ }
194+
195+ items := []list.Item{}
196+ visitedGroups := map[int]bool{}
197+
198+ // Reconstruct groups with matched items
199+ // Find which group this item belongs to
200+ for gi, g := range f.groups {
201+ addedCount := 0
202+ name := strings.ToLower(g.Title) + " "
203+
204+ names := make([]string, len(filterableItems))
205+ for i, item := range filterableItems {
206+ ms := item.(*ModelItem)
207+ names[i] = fmt.Sprintf("%s%s", name, ms.Filter())
208+ }
209+
210+ matches := fuzzy.Find(query, names)
211+ sort.SliceStable(matches, func(i, j int) bool {
212+ return matches[i].Score > matches[j].Score
213+ })
214+
215+ for _, match := range matches {
216+ item := filterableItems[match.Index].(*ModelItem)
217+ idxs := []int{}
218+ for _, idx := range match.MatchedIndexes {
219+ // Adjusts removing provider name highlights
220+ if idx < len(name) {
221+ continue
222+ }
223+ idxs = append(idxs, idx-len(name))
224+ }
225+
226+ match.MatchedIndexes = idxs
227+ if slices.Contains(g.Items, item) {
228+ if !visitedGroups[gi] {
229+ // Add section header
230+ items = append(items, &g)
231+ visitedGroups[gi] = true
232+ }
233+ // Add the matched item
234+ item.SetMatch(match)
235+ items = append(items, item)
236+ addedCount++
237+ }
238+ }
239+ if addedCount > 0 {
240+ // Add a space separator after each provider section
241+ items = append(items, list.NewSpacerItem(1))
242+ }
243+ }
244+
245+ return items
246+}
247+
248+// Render renders the filterable list.
249+func (f *ModelsList) Render() string {
250+ f.SetItems(f.VisibleItems()...)
251+ return f.List.Render()
252+}
253+
254+type modelGroups []ModelGroup
255+
256+func (m modelGroups) Len() int {
257+ n := 0
258+ for _, g := range m {
259+ n += len(g.Items)
260+ }
261+ return n
262+}
263+
264+func (m modelGroups) String(i int) string {
265+ count := 0
266+ for _, g := range m {
267+ if i < count+len(g.Items) {
268+ return g.Items[i-count].Filter()
269+ }
270+ count += len(g.Items)
271+ }
272+ return ""
273+}
internal/ui/dialog/oauth.gonew file+369
@@ -0,0 +1,369 @@
1+package dialog
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "charm.land/bubbles/v2/help"
9+ "charm.land/bubbles/v2/key"
10+ "charm.land/bubbles/v2/spinner"
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/lipgloss/v2"
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/oauth"
16+ "github.com/charmbracelet/crush/internal/ui/common"
17+ "github.com/charmbracelet/crush/internal/uiutil"
18+ uv "github.com/charmbracelet/ultraviolet"
19+ "github.com/pkg/browser"
20+)
21+
22+type OAuthProvider interface {
23+ name() string
24+ initiateAuth() tea.Msg
25+ startPolling(deviceCode string, expiresIn int) tea.Cmd
26+ stopPolling() tea.Msg
27+}
28+
29+// OAuthState represents the current state of the device flow.
30+type OAuthState int
31+
32+const (
33+ OAuthStateInitializing OAuthState = iota
34+ OAuthStateDisplay
35+ OAuthStateSuccess
36+ OAuthStateError
37+)
38+
39+// OAuthID is the identifier for the model selection dialog.
40+const OAuthID = "oauth"
41+
42+// OAuth handles the OAuth flow authentication.
43+type OAuth struct {
44+ com *common.Common
45+
46+ provider catwalk.Provider
47+ model config.SelectedModel
48+ modelType config.SelectedModelType
49+ oAuthProvider OAuthProvider
50+
51+ State OAuthState
52+
53+ spinner spinner.Model
54+ help help.Model
55+ keyMap struct {
56+ Copy key.Binding
57+ Submit key.Binding
58+ Close key.Binding
59+ }
60+
61+ width int
62+ deviceCode string
63+ userCode string
64+ verificationURL string
65+ expiresIn int
66+ interval int
67+ token *oauth.Token
68+ cancelFunc context.CancelFunc
69+}
70+
71+var _ Dialog = (*OAuth)(nil)
72+
73+// newOAuth creates a new device flow component.
74+func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) {
75+ t := com.Styles
76+
77+ m := OAuth{}
78+ m.com = com
79+ m.provider = provider
80+ m.model = model
81+ m.modelType = modelType
82+ m.oAuthProvider = oAuthProvider
83+ m.width = 60
84+ m.State = OAuthStateInitializing
85+
86+ m.spinner = spinner.New(
87+ spinner.WithSpinner(spinner.Dot),
88+ spinner.WithStyle(t.Base.Foreground(t.GreenLight)),
89+ )
90+
91+ m.help = help.New()
92+ m.help.Styles = t.DialogHelpStyles()
93+
94+ m.keyMap.Copy = key.NewBinding(
95+ key.WithKeys("c"),
96+ key.WithHelp("c", "copy code"),
97+ )
98+ m.keyMap.Submit = key.NewBinding(
99+ key.WithKeys("enter", "ctrl+y"),
100+ key.WithHelp("enter", "copy & open"),
101+ )
102+ m.keyMap.Close = CloseKey
103+
104+ return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth)
105+}
106+
107+// ID implements Dialog.
108+func (m *OAuth) ID() string {
109+ return OAuthID
110+}
111+
112+// HandleMsg handles messages and state transitions.
113+func (m *OAuth) HandleMsg(msg tea.Msg) Action {
114+ switch msg := msg.(type) {
115+ case spinner.TickMsg:
116+ switch m.State {
117+ case OAuthStateInitializing, OAuthStateDisplay:
118+ var cmd tea.Cmd
119+ m.spinner, cmd = m.spinner.Update(msg)
120+ if cmd != nil {
121+ return ActionCmd{cmd}
122+ }
123+ }
124+
125+ case tea.KeyPressMsg:
126+ switch {
127+ case key.Matches(msg, m.keyMap.Copy):
128+ cmd := m.copyCode()
129+ return ActionCmd{cmd}
130+
131+ case key.Matches(msg, m.keyMap.Submit):
132+ switch m.State {
133+ case OAuthStateSuccess:
134+ return m.saveKeyAndContinue()
135+
136+ default:
137+ cmd := m.copyCodeAndOpenURL()
138+ return ActionCmd{cmd}
139+ }
140+
141+ case key.Matches(msg, m.keyMap.Close):
142+ switch m.State {
143+ case OAuthStateSuccess:
144+ return m.saveKeyAndContinue()
145+
146+ default:
147+ return ActionClose{}
148+ }
149+ }
150+
151+ case ActionInitiateOAuth:
152+ m.deviceCode = msg.DeviceCode
153+ m.userCode = msg.UserCode
154+ m.expiresIn = msg.ExpiresIn
155+ m.verificationURL = msg.VerificationURL
156+ m.interval = msg.Interval
157+ m.State = OAuthStateDisplay
158+ return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)}
159+
160+ case ActionCompleteOAuth:
161+ m.State = OAuthStateSuccess
162+ m.token = msg.Token
163+ return ActionCmd{m.oAuthProvider.stopPolling}
164+
165+ case ActionOAuthErrored:
166+ m.State = OAuthStateError
167+ cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error))
168+ return ActionCmd{cmd}
169+ }
170+ return nil
171+}
172+
173+// View renders the device flow dialog.
174+func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
175+ var (
176+ t = m.com.Styles
177+ dialogStyle = t.Dialog.View.Width(m.width)
178+ view = dialogStyle.Render(m.dialogContent())
179+ )
180+ DrawCenterCursor(scr, area, view, nil)
181+ return nil
182+}
183+
184+func (m *OAuth) dialogContent() string {
185+ var (
186+ t = m.com.Styles
187+ helpStyle = t.Dialog.HelpView
188+ )
189+
190+ switch m.State {
191+ case OAuthStateInitializing:
192+ return m.innerDialogContent()
193+
194+ default:
195+ elements := []string{
196+ m.headerContent(),
197+ m.innerDialogContent(),
198+ helpStyle.Render(m.help.View(m)),
199+ }
200+ return strings.Join(elements, "\n")
201+ }
202+}
203+
204+func (m *OAuth) headerContent() string {
205+ var (
206+ t = m.com.Styles
207+ titleStyle = t.Dialog.Title
208+ dialogStyle = t.Dialog.View.Width(m.width)
209+ headerOffset = titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
210+ )
211+ return common.DialogTitle(t, titleStyle.Render("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset)
212+}
213+
214+func (m *OAuth) innerDialogContent() string {
215+ var (
216+ t = m.com.Styles
217+ whiteStyle = lipgloss.NewStyle().Foreground(t.White)
218+ primaryStyle = lipgloss.NewStyle().Foreground(t.Primary)
219+ greenStyle = lipgloss.NewStyle().Foreground(t.GreenLight)
220+ linkStyle = lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
221+ errorStyle = lipgloss.NewStyle().Foreground(t.Error)
222+ mutedStyle = lipgloss.NewStyle().Foreground(t.FgMuted)
223+ )
224+
225+ switch m.State {
226+ case OAuthStateInitializing:
227+ return lipgloss.NewStyle().
228+ Margin(1, 1).
229+ Width(m.width - 2).
230+ Align(lipgloss.Center).
231+ Render(
232+ greenStyle.Render(m.spinner.View()) +
233+ mutedStyle.Render("Initializing..."),
234+ )
235+
236+ case OAuthStateDisplay:
237+ instructions := lipgloss.NewStyle().
238+ Margin(0, 1).
239+ Width(m.width - 2).
240+ Render(
241+ whiteStyle.Render("Press ") +
242+ primaryStyle.Render("enter") +
243+ whiteStyle.Render(" to copy the code below and open the browser."),
244+ )
245+
246+ codeBox := lipgloss.NewStyle().
247+ Width(m.width-2).
248+ Height(7).
249+ Align(lipgloss.Center, lipgloss.Center).
250+ Background(t.BgBaseLighter).
251+ Margin(0, 1).
252+ Render(
253+ lipgloss.NewStyle().
254+ Bold(true).
255+ Foreground(t.White).
256+ Render(m.userCode),
257+ )
258+
259+ link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL)
260+ url := mutedStyle.
261+ Margin(0, 1).
262+ Width(m.width - 2).
263+ Render("Browser not opening? Refer to\n" + link)
264+
265+ waiting := lipgloss.NewStyle().
266+ Margin(0, 1).
267+ Width(m.width - 2).
268+ Render(
269+ greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."),
270+ )
271+
272+ return lipgloss.JoinVertical(
273+ lipgloss.Left,
274+ "",
275+ instructions,
276+ "",
277+ codeBox,
278+ "",
279+ url,
280+ "",
281+ waiting,
282+ "",
283+ )
284+
285+ case OAuthStateSuccess:
286+ return greenStyle.
287+ Margin(1).
288+ Width(m.width - 2).
289+ Render("Authentication successful!")
290+
291+ case OAuthStateError:
292+ return lipgloss.NewStyle().
293+ Margin(1).
294+ Width(m.width - 2).
295+ Render(errorStyle.Render("Authentication failed."))
296+
297+ default:
298+ return ""
299+ }
300+}
301+
302+// FullHelp returns the full help view.
303+func (m *OAuth) FullHelp() [][]key.Binding {
304+ return [][]key.Binding{m.ShortHelp()}
305+}
306+
307+// ShortHelp returns the full help view.
308+func (m *OAuth) ShortHelp() []key.Binding {
309+ switch m.State {
310+ case OAuthStateError:
311+ return []key.Binding{m.keyMap.Close}
312+
313+ case OAuthStateSuccess:
314+ return []key.Binding{
315+ key.NewBinding(
316+ key.WithKeys("finish", "ctrl+y", "esc"),
317+ key.WithHelp("enter", "finish"),
318+ ),
319+ }
320+
321+ default:
322+ return []key.Binding{
323+ m.keyMap.Copy,
324+ m.keyMap.Submit,
325+ m.keyMap.Close,
326+ }
327+ }
328+}
329+
330+func (d *OAuth) copyCode() tea.Cmd {
331+ if d.State != OAuthStateDisplay {
332+ return nil
333+ }
334+ return tea.Sequence(
335+ tea.SetClipboard(d.userCode),
336+ uiutil.ReportInfo("Code copied to clipboard"),
337+ )
338+}
339+
340+func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
341+ if d.State != OAuthStateDisplay {
342+ return nil
343+ }
344+ return tea.Sequence(
345+ tea.SetClipboard(d.userCode),
346+ func() tea.Msg {
347+ if err := browser.OpenURL(d.verificationURL); err != nil {
348+ return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)}
349+ }
350+ return nil
351+ },
352+ uiutil.ReportInfo("Code copied and URL opened"),
353+ )
354+}
355+
356+func (m *OAuth) saveKeyAndContinue() Action {
357+ cfg := m.com.Config()
358+
359+ err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
360+ if err != nil {
361+ return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
362+ }
363+
364+ return ActionSelectModel{
365+ Provider: m.provider,
366+ Model: m.model,
367+ ModelType: m.modelType,
368+ }
369+}
internal/ui/dialog/oauth_copilot.gonew file+72
@@ -0,0 +1,72 @@
1+package dialog
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ tea "charm.land/bubbletea/v2"
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
10+ "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/oauth/copilot"
12+ "github.com/charmbracelet/crush/internal/ui/common"
13+)
14+
15+func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
16+ return newOAuth(com, provider, model, modelType, &OAuthCopilot{})
17+}
18+
19+type OAuthCopilot struct {
20+ deviceCode *copilot.DeviceCode
21+ cancelFunc func()
22+}
23+
24+var _ OAuthProvider = (*OAuthCopilot)(nil)
25+
26+func (m *OAuthCopilot) name() string {
27+ return "GitHub Copilot"
28+}
29+
30+func (m *OAuthCopilot) initiateAuth() tea.Msg {
31+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
32+ defer cancel()
33+
34+ deviceCode, err := copilot.RequestDeviceCode(ctx)
35+ if err != nil {
36+ return ActionOAuthErrored{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
37+ }
38+
39+ m.deviceCode = deviceCode
40+
41+ return ActionInitiateOAuth{
42+ DeviceCode: deviceCode.DeviceCode,
43+ UserCode: deviceCode.UserCode,
44+ VerificationURL: deviceCode.VerificationURI,
45+ ExpiresIn: deviceCode.ExpiresIn,
46+ Interval: deviceCode.Interval,
47+ }
48+}
49+
50+func (m *OAuthCopilot) startPolling(deviceCode string, expiresIn int) tea.Cmd {
51+ return func() tea.Msg {
52+ ctx, cancel := context.WithCancel(context.Background())
53+ m.cancelFunc = cancel
54+
55+ token, err := copilot.PollForToken(ctx, m.deviceCode)
56+ if err != nil {
57+ if ctx.Err() != nil {
58+ return nil // cancelled, don't report error.
59+ }
60+ return ActionOAuthErrored{Error: err}
61+ }
62+
63+ return ActionCompleteOAuth{Token: token}
64+ }
65+}
66+
67+func (m *OAuthCopilot) stopPolling() tea.Msg {
68+ if m.cancelFunc != nil {
69+ m.cancelFunc()
70+ }
71+ return nil
72+}
internal/ui/dialog/oauth_hyper.gonew file+90
@@ -0,0 +1,90 @@
1+package dialog
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ tea "charm.land/bubbletea/v2"
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
10+ "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/oauth/hyper"
12+ "github.com/charmbracelet/crush/internal/ui/common"
13+)
14+
15+func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) {
16+ return newOAuth(com, provider, model, modelType, &OAuthHyper{})
17+}
18+
19+type OAuthHyper struct {
20+ cancelFunc func()
21+}
22+
23+var _ OAuthProvider = (*OAuthHyper)(nil)
24+
25+func (m *OAuthHyper) name() string {
26+ return "Hyper"
27+}
28+
29+func (m *OAuthHyper) initiateAuth() tea.Msg {
30+ minimumWait := 750 * time.Millisecond
31+ startTime := time.Now()
32+
33+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
34+ defer cancel()
35+
36+ authResp, err := hyper.InitiateDeviceAuth(ctx)
37+
38+ ellapsed := time.Since(startTime)
39+ if ellapsed < minimumWait {
40+ time.Sleep(minimumWait - ellapsed)
41+ }
42+
43+ if err != nil {
44+ return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)}
45+ }
46+
47+ return ActionInitiateOAuth{
48+ DeviceCode: authResp.DeviceCode,
49+ UserCode: authResp.UserCode,
50+ ExpiresIn: authResp.ExpiresIn,
51+ VerificationURL: authResp.VerificationURL,
52+ }
53+}
54+
55+func (m *OAuthHyper) startPolling(deviceCode string, expiresIn int) tea.Cmd {
56+ return func() tea.Msg {
57+ ctx, cancel := context.WithCancel(context.Background())
58+ m.cancelFunc = cancel
59+
60+ refreshToken, err := hyper.PollForToken(ctx, deviceCode, expiresIn)
61+ if err != nil {
62+ if ctx.Err() != nil {
63+ return nil
64+ }
65+ return ActionOAuthErrored{err}
66+ }
67+
68+ token, err := hyper.ExchangeToken(ctx, refreshToken)
69+ if err != nil {
70+ return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)}
71+ }
72+
73+ introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
74+ if err != nil {
75+ return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)}
76+ }
77+ if !introspect.Active {
78+ return ActionOAuthErrored{fmt.Errorf("access token is not active")}
79+ }
80+
81+ return ActionCompleteOAuth{token}
82+ }
83+}
84+
85+func (m *OAuthHyper) stopPolling() tea.Msg {
86+ if m.cancelFunc != nil {
87+ m.cancelFunc()
88+ }
89+ return nil
90+}
internal/ui/dialog/permissions.gonew file+760
@@ -0,0 +1,760 @@
1+package dialog
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "charm.land/bubbles/v2/help"
9+ "charm.land/bubbles/v2/key"
10+ "charm.land/bubbles/v2/viewport"
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/lipgloss/v2"
13+ "github.com/charmbracelet/crush/internal/agent/tools"
14+ "github.com/charmbracelet/crush/internal/fsext"
15+ "github.com/charmbracelet/crush/internal/permission"
16+ "github.com/charmbracelet/crush/internal/stringext"
17+ "github.com/charmbracelet/crush/internal/ui/common"
18+ "github.com/charmbracelet/crush/internal/ui/styles"
19+ uv "github.com/charmbracelet/ultraviolet"
20+)
21+
22+// PermissionsID is the identifier for the permissions dialog.
23+const PermissionsID = "permissions"
24+
25+// PermissionAction represents the user's response to a permission request.
26+type PermissionAction string
27+
28+const (
29+ PermissionAllow PermissionAction = "allow"
30+ PermissionAllowForSession PermissionAction = "allow_session"
31+ PermissionDeny PermissionAction = "deny"
32+)
33+
34+// Permissions dialog sizing constants.
35+const (
36+ // diffMaxWidth is the maximum width for diff views.
37+ diffMaxWidth = 180
38+ // diffSizeRatio is the size ratio for diff views relative to window.
39+ diffSizeRatio = 0.8
40+ // simpleMaxWidth is the maximum width for simple content dialogs.
41+ simpleMaxWidth = 100
42+ // simpleSizeRatio is the size ratio for simple content dialogs.
43+ simpleSizeRatio = 0.6
44+ // simpleHeightRatio is the height ratio for simple content dialogs.
45+ simpleHeightRatio = 0.5
46+ // splitModeMinWidth is the minimum width to enable split diff mode.
47+ splitModeMinWidth = 140
48+ // layoutSpacingLines is the number of empty lines used for layout spacing.
49+ layoutSpacingLines = 4
50+ // minWindowWidth is the minimum window width before forcing fullscreen.
51+ minWindowWidth = 60
52+ // minWindowHeight is the minimum window height before forcing fullscreen.
53+ minWindowHeight = 20
54+)
55+
56+// Permissions represents a dialog for permission requests.
57+type Permissions struct {
58+ com *common.Common
59+ windowWidth int // Terminal window dimensions.
60+ windowHeight int
61+ fullscreen bool // true when dialog is fullscreen
62+
63+ permission permission.PermissionRequest
64+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
65+
66+ viewport viewport.Model
67+ viewportDirty bool // true when viewport content needs to be re-rendered
68+ viewportWidth int
69+
70+ // Diff view state.
71+ diffSplitMode *bool // nil means use default based on width
72+ defaultDiffSplitMode bool // default split mode based on width
73+ unifiedDiffContent string
74+ splitDiffContent string
75+
76+ help help.Model
77+ keyMap permissionsKeyMap
78+}
79+
80+type permissionsKeyMap struct {
81+ Left key.Binding
82+ Right key.Binding
83+ Tab key.Binding
84+ Select key.Binding
85+ Allow key.Binding
86+ AllowSession key.Binding
87+ Deny key.Binding
88+ Close key.Binding
89+ ToggleDiffMode key.Binding
90+ ToggleFullscreen key.Binding
91+ ScrollUp key.Binding
92+ ScrollDown key.Binding
93+ ScrollLeft key.Binding
94+ ScrollRight key.Binding
95+ Choose key.Binding
96+ Scroll key.Binding
97+}
98+
99+func defaultPermissionsKeyMap() permissionsKeyMap {
100+ return permissionsKeyMap{
101+ Left: key.NewBinding(
102+ key.WithKeys("left", "h"),
103+ key.WithHelp("←", "previous"),
104+ ),
105+ Right: key.NewBinding(
106+ key.WithKeys("right", "l"),
107+ key.WithHelp("→", "next"),
108+ ),
109+ Tab: key.NewBinding(
110+ key.WithKeys("tab"),
111+ key.WithHelp("tab", "next option"),
112+ ),
113+ Select: key.NewBinding(
114+ key.WithKeys("enter", "ctrl+y"),
115+ key.WithHelp("enter", "confirm"),
116+ ),
117+ Allow: key.NewBinding(
118+ key.WithKeys("a", "A", "ctrl+a"),
119+ key.WithHelp("a", "allow"),
120+ ),
121+ AllowSession: key.NewBinding(
122+ key.WithKeys("s", "S", "ctrl+s"),
123+ key.WithHelp("s", "allow session"),
124+ ),
125+ Deny: key.NewBinding(
126+ key.WithKeys("d", "D"),
127+ key.WithHelp("d", "deny"),
128+ ),
129+ Close: CloseKey,
130+ ToggleDiffMode: key.NewBinding(
131+ key.WithKeys("t"),
132+ key.WithHelp("t", "toggle diff view"),
133+ ),
134+ ToggleFullscreen: key.NewBinding(
135+ key.WithKeys("f"),
136+ key.WithHelp("f", "toggle fullscreen"),
137+ ),
138+ ScrollUp: key.NewBinding(
139+ key.WithKeys("shift+up", "K"),
140+ key.WithHelp("shift+↑", "scroll up"),
141+ ),
142+ ScrollDown: key.NewBinding(
143+ key.WithKeys("shift+down", "J"),
144+ key.WithHelp("shift+↓", "scroll down"),
145+ ),
146+ ScrollLeft: key.NewBinding(
147+ key.WithKeys("shift+left", "H"),
148+ key.WithHelp("shift+←", "scroll left"),
149+ ),
150+ ScrollRight: key.NewBinding(
151+ key.WithKeys("shift+right", "L"),
152+ key.WithHelp("shift+→", "scroll right"),
153+ ),
154+ Choose: key.NewBinding(
155+ key.WithKeys("left", "right"),
156+ key.WithHelp("←/→", "choose"),
157+ ),
158+ Scroll: key.NewBinding(
159+ key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
160+ key.WithHelp("shift+←↓↑→", "scroll"),
161+ ),
162+ }
163+}
164+
165+var _ Dialog = (*Permissions)(nil)
166+
167+// PermissionsOption configures the permissions dialog.
168+type PermissionsOption func(*Permissions)
169+
170+// WithDiffMode sets the initial diff mode (split or unified).
171+func WithDiffMode(split bool) PermissionsOption {
172+ return func(p *Permissions) {
173+ p.diffSplitMode = &split
174+ }
175+}
176+
177+// NewPermissions creates a new permissions dialog.
178+func NewPermissions(com *common.Common, perm permission.PermissionRequest, opts ...PermissionsOption) *Permissions {
179+ h := help.New()
180+ h.Styles = com.Styles.DialogHelpStyles()
181+
182+ km := defaultPermissionsKeyMap()
183+
184+ // Configure viewport with matching keybindings.
185+ vp := viewport.New()
186+ vp.KeyMap = viewport.KeyMap{
187+ Up: km.ScrollUp,
188+ Down: km.ScrollDown,
189+ Left: km.ScrollLeft,
190+ Right: km.ScrollRight,
191+ // Disable other viewport keys to avoid conflicts with dialog shortcuts.
192+ PageUp: key.NewBinding(key.WithDisabled()),
193+ PageDown: key.NewBinding(key.WithDisabled()),
194+ HalfPageUp: key.NewBinding(key.WithDisabled()),
195+ HalfPageDown: key.NewBinding(key.WithDisabled()),
196+ }
197+
198+ p := &Permissions{
199+ com: com,
200+ permission: perm,
201+ selectedOption: 0,
202+ viewport: vp,
203+ help: h,
204+ keyMap: km,
205+ }
206+
207+ for _, opt := range opts {
208+ opt(p)
209+ }
210+
211+ return p
212+}
213+
214+// Calculate usable content width (dialog border + horizontal padding).
215+func (p *Permissions) calculateContentWidth(width int) int {
216+ t := p.com.Styles
217+ const dialogHorizontalPadding = 2
218+ return width - t.Dialog.View.GetHorizontalFrameSize() - dialogHorizontalPadding
219+}
220+
221+// ID implements [Dialog].
222+func (*Permissions) ID() string {
223+ return PermissionsID
224+}
225+
226+// HandleMsg implements [Dialog].
227+func (p *Permissions) HandleMsg(msg tea.Msg) Action {
228+ switch msg := msg.(type) {
229+ case tea.KeyPressMsg:
230+ switch {
231+ case key.Matches(msg, p.keyMap.Close):
232+ // Escape denies the permission request.
233+ return p.respond(PermissionDeny)
234+ case key.Matches(msg, p.keyMap.Right), key.Matches(msg, p.keyMap.Tab):
235+ p.selectedOption = (p.selectedOption + 1) % 3
236+ case key.Matches(msg, p.keyMap.Left):
237+ // Add 2 instead of subtracting 1 to avoid negative modulo.
238+ p.selectedOption = (p.selectedOption + 2) % 3
239+ case key.Matches(msg, p.keyMap.Select):
240+ return p.selectCurrentOption()
241+ case key.Matches(msg, p.keyMap.Allow):
242+ return p.respond(PermissionAllow)
243+ case key.Matches(msg, p.keyMap.AllowSession):
244+ return p.respond(PermissionAllowForSession)
245+ case key.Matches(msg, p.keyMap.Deny):
246+ return p.respond(PermissionDeny)
247+ case key.Matches(msg, p.keyMap.ToggleDiffMode):
248+ if p.hasDiffView() {
249+ newMode := !p.isSplitMode()
250+ p.diffSplitMode = &newMode
251+ p.viewportDirty = true
252+ }
253+ case key.Matches(msg, p.keyMap.ToggleFullscreen):
254+ if p.hasDiffView() {
255+ p.fullscreen = !p.fullscreen
256+ }
257+ case key.Matches(msg, p.keyMap.ScrollDown):
258+ p.viewport, _ = p.viewport.Update(msg)
259+ case key.Matches(msg, p.keyMap.ScrollUp):
260+ p.viewport, _ = p.viewport.Update(msg)
261+ case key.Matches(msg, p.keyMap.ScrollLeft):
262+ p.viewport, _ = p.viewport.Update(msg)
263+ case key.Matches(msg, p.keyMap.ScrollRight):
264+ p.viewport, _ = p.viewport.Update(msg)
265+ }
266+ case tea.MouseWheelMsg:
267+ p.viewport, _ = p.viewport.Update(msg)
268+ default:
269+ // Pass unhandled keys to viewport for non-diff content scrolling.
270+ if !p.hasDiffView() {
271+ p.viewport, _ = p.viewport.Update(msg)
272+ p.viewportDirty = true
273+ }
274+ }
275+
276+ return nil
277+}
278+
279+func (p *Permissions) selectCurrentOption() tea.Msg {
280+ switch p.selectedOption {
281+ case 0:
282+ return p.respond(PermissionAllow)
283+ case 1:
284+ return p.respond(PermissionAllowForSession)
285+ default:
286+ return p.respond(PermissionDeny)
287+ }
288+}
289+
290+func (p *Permissions) respond(action PermissionAction) tea.Msg {
291+ return ActionPermissionResponse{
292+ Permission: p.permission,
293+ Action: action,
294+ }
295+}
296+
297+func (p *Permissions) hasDiffView() bool {
298+ switch p.permission.ToolName {
299+ case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
300+ return true
301+ }
302+ return false
303+}
304+
305+func (p *Permissions) isSplitMode() bool {
306+ if p.diffSplitMode != nil {
307+ return *p.diffSplitMode
308+ }
309+ return p.defaultDiffSplitMode
310+}
311+
312+// Draw implements [Dialog].
313+func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
314+ t := p.com.Styles
315+ // Force fullscreen when window is too small.
316+ forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
317+
318+ // Calculate dialog dimensions based on fullscreen state and content type.
319+ var width, maxHeight int
320+ if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
321+ // Use nearly full window for fullscreen.
322+ width = area.Dx()
323+ maxHeight = area.Dy()
324+ } else if p.hasDiffView() {
325+ // Wide for side-by-side diffs, capped for readability.
326+ width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
327+ maxHeight = int(float64(area.Dy()) * diffSizeRatio)
328+ } else {
329+ // Narrower for simple content like commands/URLs.
330+ width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
331+ maxHeight = int(float64(area.Dy()) * simpleHeightRatio)
332+ }
333+
334+ dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
335+
336+ contentWidth := p.calculateContentWidth(width)
337+ header := p.renderHeader(contentWidth)
338+ buttons := p.renderButtons(contentWidth)
339+ helpView := p.help.View(p)
340+
341+ // Calculate available height for content.
342+ headerHeight := lipgloss.Height(header)
343+ buttonsHeight := lipgloss.Height(buttons)
344+ helpHeight := lipgloss.Height(helpView)
345+ frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
346+
347+ p.defaultDiffSplitMode = width >= splitModeMinWidth
348+
349+ // Pre-render content to measure its actual height.
350+ renderedContent := p.renderContent(contentWidth)
351+ contentHeight := lipgloss.Height(renderedContent)
352+
353+ // For non-diff views, shrink dialog to fit content if it's smaller than max.
354+ var availableHeight int
355+ if !p.hasDiffView() && !forceFullscreen {
356+ fixedHeight := headerHeight + buttonsHeight + helpHeight + frameHeight
357+ neededHeight := fixedHeight + contentHeight
358+ if neededHeight < maxHeight {
359+ availableHeight = contentHeight
360+ } else {
361+ availableHeight = maxHeight - fixedHeight
362+ }
363+ } else {
364+ availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
365+ }
366+
367+ // Determine if scrollbar is needed.
368+ needsScrollbar := p.hasDiffView() || contentHeight > availableHeight
369+ viewportWidth := contentWidth
370+ if needsScrollbar {
371+ viewportWidth = contentWidth - 1 // Reserve space for scrollbar.
372+ }
373+
374+ if p.viewport.Width() != viewportWidth {
375+ // Mark content as dirty if width has changed.
376+ p.viewportDirty = true
377+ renderedContent = p.renderContent(viewportWidth)
378+ }
379+
380+ var content string
381+ var scrollbar string
382+ p.viewport.SetWidth(viewportWidth)
383+ p.viewport.SetHeight(availableHeight)
384+ if p.viewportDirty {
385+ p.viewport.SetContent(renderedContent)
386+ p.viewportWidth = p.viewport.Width()
387+ p.viewportDirty = false
388+ }
389+ content = p.viewport.View()
390+ if needsScrollbar {
391+ scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
392+ }
393+
394+ // Join content with scrollbar if present.
395+ if scrollbar != "" {
396+ content = lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar)
397+ }
398+
399+ parts := []string{header}
400+ if content != "" {
401+ parts = append(parts, "", content)
402+ }
403+ parts = append(parts, "", buttons, "", helpView)
404+
405+ innerContent := lipgloss.JoinVertical(lipgloss.Left, parts...)
406+ DrawCenterCursor(scr, area, dialogStyle.Render(innerContent), nil)
407+ return nil
408+}
409+
410+func (p *Permissions) renderHeader(contentWidth int) string {
411+ t := p.com.Styles
412+
413+ title := common.DialogTitle(t, "Permission Required", contentWidth-t.Dialog.Title.GetHorizontalFrameSize())
414+ title = t.Dialog.Title.Render(title)
415+
416+ // Tool info.
417+ toolLine := p.renderToolName(contentWidth)
418+ pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
419+
420+ lines := []string{title, "", toolLine, pathLine}
421+
422+ // Add tool-specific header info.
423+ switch p.permission.ToolName {
424+ case tools.BashToolName:
425+ if params, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
426+ lines = append(lines, p.renderKeyValue("Desc", params.Description, contentWidth))
427+ }
428+ case tools.DownloadToolName:
429+ if params, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
430+ lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
431+ lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
432+ }
433+ case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
434+ var filePath string
435+ switch params := p.permission.Params.(type) {
436+ case tools.EditPermissionsParams:
437+ filePath = params.FilePath
438+ case tools.WritePermissionsParams:
439+ filePath = params.FilePath
440+ case tools.MultiEditPermissionsParams:
441+ filePath = params.FilePath
442+ case tools.ViewPermissionsParams:
443+ filePath = params.FilePath
444+ }
445+ if filePath != "" {
446+ lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(filePath), contentWidth))
447+ }
448+ case tools.LSToolName:
449+ if params, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
450+ lines = append(lines, p.renderKeyValue("Directory", fsext.PrettyPath(params.Path), contentWidth))
451+ }
452+ }
453+
454+ return lipgloss.JoinVertical(lipgloss.Left, lines...)
455+}
456+
457+func (p *Permissions) renderKeyValue(key, value string, width int) string {
458+ t := p.com.Styles
459+ keyStyle := t.Muted
460+ valueStyle := t.Base
461+
462+ keyStr := keyStyle.Render(key)
463+ valueStr := valueStyle.Width(width - lipgloss.Width(keyStr) - 1).Render(" " + value)
464+
465+ return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
466+}
467+
468+func (p *Permissions) renderToolName(width int) string {
469+ toolName := p.permission.ToolName
470+
471+ // Check if this is an MCP tool (format: mcp_<mcpname>_<toolname>).
472+ if strings.HasPrefix(toolName, "mcp_") {
473+ parts := strings.SplitN(toolName, "_", 3)
474+ if len(parts) == 3 {
475+ mcpName := prettyName(parts[1])
476+ toolPart := prettyName(parts[2])
477+ toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart)
478+ }
479+ }
480+
481+ return p.renderKeyValue("Tool", toolName, width)
482+}
483+
484+// prettyName converts snake_case or kebab-case to Title Case.
485+func prettyName(name string) string {
486+ name = strings.ReplaceAll(name, "_", " ")
487+ name = strings.ReplaceAll(name, "-", " ")
488+ return stringext.Capitalize(name)
489+}
490+
491+func (p *Permissions) renderContent(width int) string {
492+ switch p.permission.ToolName {
493+ case tools.BashToolName:
494+ return p.renderBashContent(width)
495+ case tools.EditToolName:
496+ return p.renderEditContent(width)
497+ case tools.WriteToolName:
498+ return p.renderWriteContent(width)
499+ case tools.MultiEditToolName:
500+ return p.renderMultiEditContent(width)
501+ case tools.DownloadToolName:
502+ return p.renderDownloadContent(width)
503+ case tools.FetchToolName:
504+ return p.renderFetchContent(width)
505+ case tools.AgenticFetchToolName:
506+ return p.renderAgenticFetchContent(width)
507+ case tools.ViewToolName:
508+ return p.renderViewContent(width)
509+ case tools.LSToolName:
510+ return p.renderLSContent(width)
511+ default:
512+ return p.renderDefaultContent(width)
513+ }
514+}
515+
516+func (p *Permissions) renderBashContent(width int) string {
517+ params, ok := p.permission.Params.(tools.BashPermissionsParams)
518+ if !ok {
519+ return ""
520+ }
521+
522+ return p.renderContentPanel(params.Command, width)
523+}
524+
525+func (p *Permissions) renderEditContent(contentWidth int) string {
526+ params, ok := p.permission.Params.(tools.EditPermissionsParams)
527+ if !ok {
528+ return ""
529+ }
530+ return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
531+}
532+
533+func (p *Permissions) renderWriteContent(contentWidth int) string {
534+ params, ok := p.permission.Params.(tools.WritePermissionsParams)
535+ if !ok {
536+ return ""
537+ }
538+ return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
539+}
540+
541+func (p *Permissions) renderMultiEditContent(contentWidth int) string {
542+ params, ok := p.permission.Params.(tools.MultiEditPermissionsParams)
543+ if !ok {
544+ return ""
545+ }
546+ return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
547+}
548+
549+func (p *Permissions) renderDiff(filePath, oldContent, newContent string, contentWidth int) string {
550+ if !p.viewportDirty {
551+ if p.isSplitMode() {
552+ return p.splitDiffContent
553+ }
554+ return p.unifiedDiffContent
555+ }
556+
557+ isSplitMode := p.isSplitMode()
558+ formatter := common.DiffFormatter(p.com.Styles).
559+ Before(fsext.PrettyPath(filePath), oldContent).
560+ After(fsext.PrettyPath(filePath), newContent).
561+ // TODO: Allow horizontal scrolling instead of cropping. However, the
562+ // diffview currently would only background color the width of the
563+ // content. If the viewport is wider than the content, the rest of the
564+ // line would not be colored properly.
565+ Width(contentWidth)
566+
567+ var result string
568+ if isSplitMode {
569+ formatter = formatter.Split()
570+ p.splitDiffContent = formatter.String()
571+ result = p.splitDiffContent
572+ } else {
573+ formatter = formatter.Unified()
574+ p.unifiedDiffContent = formatter.String()
575+ result = p.unifiedDiffContent
576+ }
577+
578+ return result
579+}
580+
581+func (p *Permissions) renderDownloadContent(width int) string {
582+ params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
583+ if !ok {
584+ return ""
585+ }
586+
587+ content := fmt.Sprintf("URL: %s\nFile: %s", params.URL, fsext.PrettyPath(params.FilePath))
588+ if params.Timeout > 0 {
589+ content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
590+ }
591+
592+ return p.renderContentPanel(content, width)
593+}
594+
595+func (p *Permissions) renderFetchContent(width int) string {
596+ params, ok := p.permission.Params.(tools.FetchPermissionsParams)
597+ if !ok {
598+ return ""
599+ }
600+
601+ return p.renderContentPanel(params.URL, width)
602+}
603+
604+func (p *Permissions) renderAgenticFetchContent(width int) string {
605+ params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
606+ if !ok {
607+ return ""
608+ }
609+
610+ var content string
611+ if params.URL != "" {
612+ content = fmt.Sprintf("URL: %s\n\nPrompt: %s", params.URL, params.Prompt)
613+ } else {
614+ content = fmt.Sprintf("Prompt: %s", params.Prompt)
615+ }
616+
617+ return p.renderContentPanel(content, width)
618+}
619+
620+func (p *Permissions) renderViewContent(width int) string {
621+ params, ok := p.permission.Params.(tools.ViewPermissionsParams)
622+ if !ok {
623+ return ""
624+ }
625+
626+ content := fmt.Sprintf("File: %s", fsext.PrettyPath(params.FilePath))
627+ if params.Offset > 0 {
628+ content += fmt.Sprintf("\nStarting from line: %d", params.Offset+1)
629+ }
630+ if params.Limit > 0 && params.Limit != 2000 {
631+ content += fmt.Sprintf("\nLines to read: %d", params.Limit)
632+ }
633+
634+ return p.renderContentPanel(content, width)
635+}
636+
637+func (p *Permissions) renderLSContent(width int) string {
638+ params, ok := p.permission.Params.(tools.LSPermissionsParams)
639+ if !ok {
640+ return ""
641+ }
642+
643+ content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(params.Path))
644+ if len(params.Ignore) > 0 {
645+ content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
646+ }
647+
648+ return p.renderContentPanel(content, width)
649+}
650+
651+func (p *Permissions) renderDefaultContent(width int) string {
652+ t := p.com.Styles
653+ var content string
654+ // do not add the description for mcp tools
655+ if !strings.HasPrefix(p.permission.ToolName, "mcp_") {
656+ content = p.permission.Description
657+ }
658+
659+ // Pretty-print JSON params if available.
660+ if p.permission.Params != nil {
661+ var paramStr string
662+ if str, ok := p.permission.Params.(string); ok {
663+ paramStr = str
664+ } else {
665+ paramStr = fmt.Sprintf("%v", p.permission.Params)
666+ }
667+
668+ var parsed any
669+ if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
670+ if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
671+ jsonContent := string(b)
672+ highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.BgSubtle)
673+ if err == nil {
674+ jsonContent = highlighted
675+ }
676+ if content != "" {
677+ content += "\n\n"
678+ }
679+ content += jsonContent
680+ }
681+ } else if paramStr != "" {
682+ if content != "" {
683+ content += "\n\n"
684+ }
685+ content += paramStr
686+ }
687+ }
688+
689+ if content == "" {
690+ return ""
691+ }
692+
693+ return p.renderContentPanel(strings.TrimSpace(content), width)
694+}
695+
696+// renderContentPanel renders content in a panel with the full width.
697+func (p *Permissions) renderContentPanel(content string, width int) string {
698+ panelStyle := p.com.Styles.Dialog.ContentPanel
699+ return panelStyle.Width(width).Render(content)
700+}
701+
702+func (p *Permissions) renderButtons(contentWidth int) string {
703+ buttons := []common.ButtonOpts{
704+ {Text: "Allow", UnderlineIndex: 0, Selected: p.selectedOption == 0},
705+ {Text: "Allow for Session", UnderlineIndex: 10, Selected: p.selectedOption == 1},
706+ {Text: "Deny", UnderlineIndex: 0, Selected: p.selectedOption == 2},
707+ }
708+
709+ content := common.ButtonGroup(p.com.Styles, buttons, " ")
710+
711+ // If buttons are too wide, stack them vertically.
712+ if lipgloss.Width(content) > contentWidth {
713+ content = common.ButtonGroup(p.com.Styles, buttons, "\n")
714+ return lipgloss.NewStyle().
715+ Width(contentWidth).
716+ Align(lipgloss.Center).
717+ Render(content)
718+ }
719+
720+ return lipgloss.NewStyle().
721+ Width(contentWidth).
722+ Align(lipgloss.Right).
723+ Render(content)
724+}
725+
726+func (p *Permissions) canScroll() bool {
727+ if p.hasDiffView() {
728+ // Diff views can always scroll.
729+ return true
730+ }
731+ // For non-diff content, check if viewport has scrollable content.
732+ return !p.viewport.AtTop() || !p.viewport.AtBottom()
733+}
734+
735+// ShortHelp implements [help.KeyMap].
736+func (p *Permissions) ShortHelp() []key.Binding {
737+ bindings := []key.Binding{
738+ p.keyMap.Choose,
739+ p.keyMap.Select,
740+ p.keyMap.Close,
741+ }
742+
743+ if p.canScroll() {
744+ bindings = append(bindings, p.keyMap.Scroll)
745+ }
746+
747+ if p.hasDiffView() {
748+ bindings = append(bindings,
749+ p.keyMap.ToggleDiffMode,
750+ p.keyMap.ToggleFullscreen,
751+ )
752+ }
753+
754+ return bindings
755+}
756+
757+// FullHelp implements [help.KeyMap].
758+func (p *Permissions) FullHelp() [][]key.Binding {
759+ return [][]key.Binding{p.ShortHelp()}
760+}
internal/ui/dialog/quit.gonew file+133
@@ -0,0 +1,133 @@
1+package dialog
2+
3+import (
4+ "charm.land/bubbles/v2/key"
5+ tea "charm.land/bubbletea/v2"
6+ "charm.land/lipgloss/v2"
7+ "github.com/charmbracelet/crush/internal/ui/common"
8+ uv "github.com/charmbracelet/ultraviolet"
9+)
10+
11+// QuitID is the identifier for the quit dialog.
12+const QuitID = "quit"
13+
14+// Quit represents a confirmation dialog for quitting the application.
15+type Quit struct {
16+ com *common.Common
17+ selectedNo bool // true if "No" button is selected
18+ keyMap struct {
19+ LeftRight,
20+ EnterSpace,
21+ Yes,
22+ No,
23+ Tab,
24+ Close,
25+ Quit key.Binding
26+ }
27+}
28+
29+var _ Dialog = (*Quit)(nil)
30+
31+// NewQuit creates a new quit confirmation dialog.
32+func NewQuit(com *common.Common) *Quit {
33+ q := &Quit{
34+ com: com,
35+ selectedNo: true,
36+ }
37+ q.keyMap.LeftRight = key.NewBinding(
38+ key.WithKeys("left", "right"),
39+ key.WithHelp("←/→", "switch options"),
40+ )
41+ q.keyMap.EnterSpace = key.NewBinding(
42+ key.WithKeys("enter", " "),
43+ key.WithHelp("enter/space", "confirm"),
44+ )
45+ q.keyMap.Yes = key.NewBinding(
46+ key.WithKeys("y", "Y", "ctrl+c"),
47+ key.WithHelp("y/Y/ctrl+c", "yes"),
48+ )
49+ q.keyMap.No = key.NewBinding(
50+ key.WithKeys("n", "N"),
51+ key.WithHelp("n/N", "no"),
52+ )
53+ q.keyMap.Tab = key.NewBinding(
54+ key.WithKeys("tab"),
55+ key.WithHelp("tab", "switch options"),
56+ )
57+ q.keyMap.Close = CloseKey
58+ q.keyMap.Quit = key.NewBinding(
59+ key.WithKeys("ctrl+c"),
60+ key.WithHelp("ctrl+c", "quit"),
61+ )
62+ return q
63+}
64+
65+// ID implements [Model].
66+func (*Quit) ID() string {
67+ return QuitID
68+}
69+
70+// HandleMsg implements [Model].
71+func (q *Quit) HandleMsg(msg tea.Msg) Action {
72+ switch msg := msg.(type) {
73+ case tea.KeyPressMsg:
74+ switch {
75+ case key.Matches(msg, q.keyMap.Quit):
76+ return ActionQuit{}
77+ case key.Matches(msg, q.keyMap.Close):
78+ return ActionClose{}
79+ case key.Matches(msg, q.keyMap.LeftRight, q.keyMap.Tab):
80+ q.selectedNo = !q.selectedNo
81+ case key.Matches(msg, q.keyMap.EnterSpace):
82+ if !q.selectedNo {
83+ return ActionQuit{}
84+ }
85+ return ActionClose{}
86+ case key.Matches(msg, q.keyMap.Yes):
87+ return ActionQuit{}
88+ case key.Matches(msg, q.keyMap.No, q.keyMap.Close):
89+ return ActionClose{}
90+ }
91+ }
92+
93+ return nil
94+}
95+
96+// Draw implements [Dialog].
97+func (q *Quit) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
98+ const question = "Are you sure you want to quit?"
99+ baseStyle := q.com.Styles.Base
100+ buttonOpts := []common.ButtonOpts{
101+ {Text: "Yep!", Selected: !q.selectedNo, Padding: 3},
102+ {Text: "Nope", Selected: q.selectedNo, Padding: 3},
103+ }
104+ buttons := common.ButtonGroup(q.com.Styles, buttonOpts, " ")
105+ content := baseStyle.Render(
106+ lipgloss.JoinVertical(
107+ lipgloss.Center,
108+ question,
109+ "",
110+ buttons,
111+ ),
112+ )
113+
114+ view := q.com.Styles.BorderFocus.Render(content)
115+ DrawCenter(scr, area, view)
116+ return nil
117+}
118+
119+// ShortHelp implements [help.KeyMap].
120+func (q *Quit) ShortHelp() []key.Binding {
121+ return []key.Binding{
122+ q.keyMap.LeftRight,
123+ q.keyMap.EnterSpace,
124+ }
125+}
126+
127+// FullHelp implements [help.KeyMap].
128+func (q *Quit) FullHelp() [][]key.Binding {
129+ return [][]key.Binding{
130+ {q.keyMap.LeftRight, q.keyMap.EnterSpace, q.keyMap.Yes, q.keyMap.No},
131+ {q.keyMap.Tab, q.keyMap.Close},
132+ }
133+}
internal/ui/dialog/reasoning.gonew file+297
@@ -0,0 +1,297 @@
1+package dialog
2+
3+import (
4+ "errors"
5+
6+ "charm.land/bubbles/v2/help"
7+ "charm.land/bubbles/v2/key"
8+ "charm.land/bubbles/v2/textinput"
9+ tea "charm.land/bubbletea/v2"
10+ "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/ui/common"
12+ "github.com/charmbracelet/crush/internal/ui/list"
13+ "github.com/charmbracelet/crush/internal/ui/styles"
14+ uv "github.com/charmbracelet/ultraviolet"
15+ "github.com/sahilm/fuzzy"
16+ "golang.org/x/text/cases"
17+ "golang.org/x/text/language"
18+)
19+
20+const (
21+ // ReasoningID is the identifier for the reasoning effort dialog.
22+ ReasoningID = "reasoning"
23+ reasoningDialogMaxWidth = 80
24+ reasoningDialogMaxHeight = 12
25+)
26+
27+// Reasoning represents a dialog for selecting reasoning effort.
28+type Reasoning struct {
29+ com *common.Common
30+ help help.Model
31+ list *list.FilterableList
32+ input textinput.Model
33+
34+ keyMap struct {
35+ Select key.Binding
36+ Next key.Binding
37+ Previous key.Binding
38+ UpDown key.Binding
39+ Close key.Binding
40+ }
41+}
42+
43+// ReasoningItem represents a reasoning effort list item.
44+type ReasoningItem struct {
45+ effort string
46+ title string
47+ isCurrent bool
48+ t *styles.Styles
49+ m fuzzy.Match
50+ cache map[int]string
51+ focused bool
52+}
53+
54+var (
55+ _ Dialog = (*Reasoning)(nil)
56+ _ ListItem = (*ReasoningItem)(nil)
57+)
58+
59+// NewReasoning creates a new reasoning effort dialog.
60+func NewReasoning(com *common.Common) (*Reasoning, error) {
61+ r := &Reasoning{com: com}
62+
63+ help := help.New()
64+ help.Styles = com.Styles.DialogHelpStyles()
65+ r.help = help
66+
67+ r.list = list.NewFilterableList()
68+ r.list.Focus()
69+
70+ r.input = textinput.New()
71+ r.input.SetVirtualCursor(false)
72+ r.input.Placeholder = "Type to filter"
73+ r.input.SetStyles(com.Styles.TextInput)
74+ r.input.Focus()
75+
76+ r.keyMap.Select = key.NewBinding(
77+ key.WithKeys("enter", "ctrl+y"),
78+ key.WithHelp("enter", "confirm"),
79+ )
80+ r.keyMap.Next = key.NewBinding(
81+ key.WithKeys("down", "ctrl+n"),
82+ key.WithHelp("↓", "next item"),
83+ )
84+ r.keyMap.Previous = key.NewBinding(
85+ key.WithKeys("up", "ctrl+p"),
86+ key.WithHelp("↑", "previous item"),
87+ )
88+ r.keyMap.UpDown = key.NewBinding(
89+ key.WithKeys("up", "down"),
90+ key.WithHelp("↑/↓", "choose"),
91+ )
92+ r.keyMap.Close = CloseKey
93+
94+ if err := r.setReasoningItems(); err != nil {
95+ return nil, err
96+ }
97+
98+ return r, nil
99+}
100+
101+// ID implements Dialog.
102+func (r *Reasoning) ID() string {
103+ return ReasoningID
104+}
105+
106+// HandleMsg implements [Dialog].
107+func (r *Reasoning) HandleMsg(msg tea.Msg) Action {
108+ switch msg := msg.(type) {
109+ case tea.KeyPressMsg:
110+ switch {
111+ case key.Matches(msg, r.keyMap.Close):
112+ return ActionClose{}
113+ case key.Matches(msg, r.keyMap.Previous):
114+ r.list.Focus()
115+ if r.list.IsSelectedFirst() {
116+ r.list.SelectLast()
117+ r.list.ScrollToBottom()
118+ break
119+ }
120+ r.list.SelectPrev()
121+ r.list.ScrollToSelected()
122+ case key.Matches(msg, r.keyMap.Next):
123+ r.list.Focus()
124+ if r.list.IsSelectedLast() {
125+ r.list.SelectFirst()
126+ r.list.ScrollToTop()
127+ break
128+ }
129+ r.list.SelectNext()
130+ r.list.ScrollToSelected()
131+ case key.Matches(msg, r.keyMap.Select):
132+ selectedItem := r.list.SelectedItem()
133+ if selectedItem == nil {
134+ break
135+ }
136+ reasoningItem, ok := selectedItem.(*ReasoningItem)
137+ if !ok {
138+ break
139+ }
140+ return ActionSelectReasoningEffort{Effort: reasoningItem.effort}
141+ default:
142+ var cmd tea.Cmd
143+ r.input, cmd = r.input.Update(msg)
144+ value := r.input.Value()
145+ r.list.SetFilter(value)
146+ r.list.ScrollToTop()
147+ r.list.SetSelected(0)
148+ return ActionCmd{cmd}
149+ }
150+ }
151+ return nil
152+}
153+
154+// Cursor returns the cursor position relative to the dialog.
155+func (r *Reasoning) Cursor() *tea.Cursor {
156+ return InputCursor(r.com.Styles, r.input.Cursor())
157+}
158+
159+// Draw implements [Dialog].
160+func (r *Reasoning) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
161+ t := r.com.Styles
162+ width := max(0, min(reasoningDialogMaxWidth, area.Dx()))
163+ height := max(0, min(reasoningDialogMaxHeight, area.Dy()))
164+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize()
165+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
166+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
167+ t.Dialog.HelpView.GetVerticalFrameSize() +
168+ t.Dialog.View.GetVerticalFrameSize()
169+
170+ r.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
171+ r.list.SetSize(innerWidth, height-heightOffset)
172+ r.help.SetWidth(innerWidth)
173+
174+ rc := NewRenderContext(t, width)
175+ rc.Title = "Select Reasoning Effort"
176+ inputView := t.Dialog.InputPrompt.Render(r.input.View())
177+ rc.AddPart(inputView)
178+
179+ visibleCount := len(r.list.VisibleItems())
180+ if r.list.Height() >= visibleCount {
181+ r.list.ScrollToTop()
182+ } else {
183+ r.list.ScrollToSelected()
184+ }
185+
186+ listView := t.Dialog.List.Height(r.list.Height()).Render(r.list.Render())
187+ rc.AddPart(listView)
188+ rc.Help = r.help.View(r)
189+
190+ view := rc.Render()
191+
192+ cur := r.Cursor()
193+ DrawCenterCursor(scr, area, view, cur)
194+ return cur
195+}
196+
197+// ShortHelp implements [help.KeyMap].
198+func (r *Reasoning) ShortHelp() []key.Binding {
199+ return []key.Binding{
200+ r.keyMap.UpDown,
201+ r.keyMap.Select,
202+ r.keyMap.Close,
203+ }
204+}
205+
206+// FullHelp implements [help.KeyMap].
207+func (r *Reasoning) FullHelp() [][]key.Binding {
208+ m := [][]key.Binding{}
209+ slice := []key.Binding{
210+ r.keyMap.Select,
211+ r.keyMap.Next,
212+ r.keyMap.Previous,
213+ r.keyMap.Close,
214+ }
215+ for i := 0; i < len(slice); i += 4 {
216+ end := min(i+4, len(slice))
217+ m = append(m, slice[i:end])
218+ }
219+ return m
220+}
221+
222+func (r *Reasoning) setReasoningItems() error {
223+ cfg := r.com.Config()
224+ agentCfg, ok := cfg.Agents[config.AgentCoder]
225+ if !ok {
226+ return errors.New("agent configuration not found")
227+ }
228+
229+ selectedModel := cfg.Models[agentCfg.Model]
230+ model := cfg.GetModelByType(agentCfg.Model)
231+ if model == nil {
232+ return errors.New("model configuration not found")
233+ }
234+
235+ if len(model.ReasoningLevels) == 0 {
236+ return errors.New("no reasoning levels available")
237+ }
238+
239+ currentEffort := selectedModel.ReasoningEffort
240+ if currentEffort == "" {
241+ currentEffort = model.DefaultReasoningEffort
242+ }
243+
244+ caser := cases.Title(language.English)
245+ items := make([]list.FilterableItem, 0, len(model.ReasoningLevels))
246+ selectedIndex := 0
247+ for i, effort := range model.ReasoningLevels {
248+ item := &ReasoningItem{
249+ effort: effort,
250+ title: caser.String(effort),
251+ isCurrent: effort == currentEffort,
252+ t: r.com.Styles,
253+ }
254+ items = append(items, item)
255+ if effort == currentEffort {
256+ selectedIndex = i
257+ }
258+ }
259+
260+ r.list.SetItems(items...)
261+ r.list.SetSelected(selectedIndex)
262+ r.list.ScrollToSelected()
263+ return nil
264+}
265+
266+// Filter returns the filter value for the reasoning item.
267+func (r *ReasoningItem) Filter() string {
268+ return r.title
269+}
270+
271+// ID returns the unique identifier for the reasoning effort.
272+func (r *ReasoningItem) ID() string {
273+ return r.effort
274+}
275+
276+// SetFocused sets the focus state of the reasoning item.
277+func (r *ReasoningItem) SetFocused(focused bool) {
278+ if r.focused != focused {
279+ r.cache = nil
280+ }
281+ r.focused = focused
282+}
283+
284+// SetMatch sets the fuzzy match for the reasoning item.
285+func (r *ReasoningItem) SetMatch(m fuzzy.Match) {
286+ r.cache = nil
287+ r.m = m
288+}
289+
290+// Render returns the string representation of the reasoning item.
291+func (r *ReasoningItem) Render(width int) string {
292+ info := ""
293+ if r.isCurrent {
294+ info = "current"
295+ }
296+ return renderItem(r.t, r.title, info, r.focused, width, r.cache, &r.m)
297+}
internal/ui/dialog/sessions.gonew file+194
@@ -0,0 +1,194 @@
1+package dialog
2+
3+import (
4+ "context"
5+
6+ "charm.land/bubbles/v2/help"
7+ "charm.land/bubbles/v2/key"
8+ "charm.land/bubbles/v2/textinput"
9+ tea "charm.land/bubbletea/v2"
10+ "github.com/charmbracelet/crush/internal/ui/common"
11+ "github.com/charmbracelet/crush/internal/ui/list"
12+ uv "github.com/charmbracelet/ultraviolet"
13+)
14+
15+// SessionsID is the identifier for the session selector dialog.
16+const SessionsID = "session"
17+
18+// Session is a session selector dialog.
19+type Session struct {
20+ com *common.Common
21+ help help.Model
22+ list *list.FilterableList
23+ input textinput.Model
24+ selectedSessionInx int
25+
26+ keyMap struct {
27+ Select key.Binding
28+ Next key.Binding
29+ Previous key.Binding
30+ UpDown key.Binding
31+ Close key.Binding
32+ }
33+}
34+
35+var _ Dialog = (*Session)(nil)
36+
37+// NewSessions creates a new Session dialog.
38+func NewSessions(com *common.Common, selectedSessionID string) (*Session, error) {
39+ s := new(Session)
40+ s.com = com
41+ sessions, err := com.App.Sessions.List(context.TODO())
42+ if err != nil {
43+ return nil, err
44+ }
45+
46+ for i, sess := range sessions {
47+ if sess.ID == selectedSessionID {
48+ s.selectedSessionInx = i
49+ break
50+ }
51+ }
52+
53+ help := help.New()
54+ help.Styles = com.Styles.DialogHelpStyles()
55+
56+ s.help = help
57+ s.list = list.NewFilterableList(sessionItems(com.Styles, sessions...)...)
58+ s.list.Focus()
59+ s.list.SetSelected(s.selectedSessionInx)
60+ s.list.ScrollToSelected()
61+
62+ s.input = textinput.New()
63+ s.input.SetVirtualCursor(false)
64+ s.input.Placeholder = "Enter session name"
65+ s.input.SetStyles(com.Styles.TextInput)
66+ s.input.Focus()
67+
68+ s.keyMap.Select = key.NewBinding(
69+ key.WithKeys("enter", "tab", "ctrl+y"),
70+ key.WithHelp("enter", "choose"),
71+ )
72+ s.keyMap.Next = key.NewBinding(
73+ key.WithKeys("down", "ctrl+n"),
74+ key.WithHelp("↓", "next item"),
75+ )
76+ s.keyMap.Previous = key.NewBinding(
77+ key.WithKeys("up", "ctrl+p"),
78+ key.WithHelp("↑", "previous item"),
79+ )
80+ s.keyMap.UpDown = key.NewBinding(
81+ key.WithKeys("up", "down"),
82+ key.WithHelp("↑↓", "choose"),
83+ )
84+ s.keyMap.Close = CloseKey
85+
86+ return s, nil
87+}
88+
89+// ID implements Dialog.
90+func (s *Session) ID() string {
91+ return SessionsID
92+}
93+
94+// HandleMsg implements Dialog.
95+func (s *Session) HandleMsg(msg tea.Msg) Action {
96+ switch msg := msg.(type) {
97+ case tea.KeyPressMsg:
98+ switch {
99+ case key.Matches(msg, s.keyMap.Close):
100+ return ActionClose{}
101+ case key.Matches(msg, s.keyMap.Previous):
102+ s.list.Focus()
103+ if s.list.IsSelectedFirst() {
104+ s.list.SelectLast()
105+ s.list.ScrollToBottom()
106+ break
107+ }
108+ s.list.SelectPrev()
109+ s.list.ScrollToSelected()
110+ case key.Matches(msg, s.keyMap.Next):
111+ s.list.Focus()
112+ if s.list.IsSelectedLast() {
113+ s.list.SelectFirst()
114+ s.list.ScrollToTop()
115+ break
116+ }
117+ s.list.SelectNext()
118+ s.list.ScrollToSelected()
119+ case key.Matches(msg, s.keyMap.Select):
120+ if item := s.list.SelectedItem(); item != nil {
121+ sessionItem := item.(*SessionItem)
122+ return ActionSelectSession{sessionItem.Session}
123+ }
124+ default:
125+ var cmd tea.Cmd
126+ s.input, cmd = s.input.Update(msg)
127+ value := s.input.Value()
128+ s.list.SetFilter(value)
129+ s.list.ScrollToTop()
130+ s.list.SetSelected(0)
131+ return ActionCmd{cmd}
132+ }
133+ }
134+ return nil
135+}
136+
137+// Cursor returns the cursor position relative to the dialog.
138+func (s *Session) Cursor() *tea.Cursor {
139+ return InputCursor(s.com.Styles, s.input.Cursor())
140+}
141+
142+// Draw implements [Dialog].
143+func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
144+ t := s.com.Styles
145+ width := max(0, min(defaultDialogMaxWidth, area.Dx()))
146+ height := max(0, min(defaultDialogHeight, area.Dy()))
147+ innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
148+ heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
149+ t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
150+ t.Dialog.HelpView.GetVerticalFrameSize() +
151+ t.Dialog.View.GetVerticalFrameSize()
152+ s.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
153+ s.list.SetSize(innerWidth, height-heightOffset)
154+ s.help.SetWidth(innerWidth)
155+
156+ rc := NewRenderContext(t, width)
157+ rc.Title = "Switch Session"
158+ inputView := t.Dialog.InputPrompt.Render(s.input.View())
159+ rc.AddPart(inputView)
160+ listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
161+ rc.AddPart(listView)
162+ rc.Help = s.help.View(s)
163+
164+ view := rc.Render()
165+
166+ cur := s.Cursor()
167+ DrawCenterCursor(scr, area, view, cur)
168+ return cur
169+}
170+
171+// ShortHelp implements [help.KeyMap].
172+func (s *Session) ShortHelp() []key.Binding {
173+ return []key.Binding{
174+ s.keyMap.UpDown,
175+ s.keyMap.Select,
176+ s.keyMap.Close,
177+ }
178+}
179+
180+// FullHelp implements [help.KeyMap].
181+func (s *Session) FullHelp() [][]key.Binding {
182+ m := [][]key.Binding{}
183+ slice := []key.Binding{
184+ s.keyMap.Select,
185+ s.keyMap.Next,
186+ s.keyMap.Previous,
187+ s.keyMap.Close,
188+ }
189+ for i := 0; i < len(slice); i += 4 {
190+ end := min(i+4, len(slice))
191+ m = append(m, slice[i:end])
192+ }
193+ return m
194+}
internal/ui/dialog/sessions_item.gonew file+187
@@ -0,0 +1,187 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "charm.land/lipgloss/v2"
9+ "github.com/charmbracelet/crush/internal/session"
10+ "github.com/charmbracelet/crush/internal/ui/list"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+ "github.com/charmbracelet/x/ansi"
13+ "github.com/dustin/go-humanize"
14+ "github.com/rivo/uniseg"
15+ "github.com/sahilm/fuzzy"
16+)
17+
18+// ListItem represents a selectable and searchable item in a dialog list.
19+type ListItem interface {
20+ list.FilterableItem
21+ list.Focusable
22+ list.MatchSettable
23+
24+ // ID returns the unique identifier of the item.
25+ ID() string
26+}
27+
28+// SessionItem wraps a [session.Session] to implement the [ListItem] interface.
29+type SessionItem struct {
30+ session.Session
31+ t *styles.Styles
32+ m fuzzy.Match
33+ cache map[int]string
34+ focused bool
35+}
36+
37+var _ ListItem = &SessionItem{}
38+
39+// Filter returns the filterable value of the session.
40+func (s *SessionItem) Filter() string {
41+ return s.Title
42+}
43+
44+// ID returns the unique identifier of the session.
45+func (s *SessionItem) ID() string {
46+ return s.Session.ID
47+}
48+
49+// SetMatch sets the fuzzy match for the session item.
50+func (s *SessionItem) SetMatch(m fuzzy.Match) {
51+ s.cache = nil
52+ s.m = m
53+}
54+
55+// Render returns the string representation of the session item.
56+func (s *SessionItem) Render(width int) string {
57+ info := humanize.Time(time.Unix(s.UpdatedAt, 0))
58+ return renderItem(s.t, s.Title, info, s.focused, width, s.cache, &s.m)
59+}
60+
61+func renderItem(t *styles.Styles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
62+ if cache == nil {
63+ cache = make(map[int]string)
64+ }
65+
66+ cached, ok := cache[width]
67+ if ok {
68+ return cached
69+ }
70+
71+ style := t.Dialog.NormalItem
72+ if focused {
73+ style = t.Dialog.SelectedItem
74+ }
75+
76+ var infoText string
77+ var infoWidth int
78+ lineWidth := width
79+ if len(info) > 0 {
80+ infoText = fmt.Sprintf(" %s ", info)
81+ if focused {
82+ infoText = t.Base.Render(infoText)
83+ } else {
84+ infoText = t.Subtle.Render(infoText)
85+ }
86+
87+ infoWidth = lipgloss.Width(infoText)
88+ }
89+
90+ title = ansi.Truncate(title, max(0, lineWidth-infoWidth), "")
91+ titleWidth := lipgloss.Width(title)
92+ gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth))
93+ content := title
94+ if matches := len(m.MatchedIndexes); matches > 0 {
95+ var lastPos int
96+ parts := make([]string, 0)
97+ ranges := matchedRanges(m.MatchedIndexes)
98+ for _, rng := range ranges {
99+ start, stop := bytePosToVisibleCharPos(title, rng)
100+ if start > lastPos {
101+ parts = append(parts, title[lastPos:start])
102+ }
103+ // NOTE: We're using [ansi.Style] here instead of [lipglosStyle]
104+ // because we can control the underline start and stop more
105+ // precisely via [ansi.AttrUnderline] and [ansi.AttrNoUnderline]
106+ // which only affect the underline attribute without interfering
107+ // with other style
108+ parts = append(parts,
109+ ansi.NewStyle().Underline(true).String(),
110+ title[start:stop+1],
111+ ansi.NewStyle().Underline(false).String(),
112+ )
113+ lastPos = stop + 1
114+ }
115+ if lastPos < len(title) {
116+ parts = append(parts, title[lastPos:])
117+ }
118+
119+ content = strings.Join(parts, "")
120+ }
121+
122+ content = style.Render(content + gap + infoText)
123+ cache[width] = content
124+ return content
125+}
126+
127+// SetFocused sets the focus state of the session item.
128+func (s *SessionItem) SetFocused(focused bool) {
129+ if s.focused != focused {
130+ s.cache = nil
131+ }
132+ s.focused = focused
133+}
134+
135+// sessionItems takes a slice of [session.Session]s and convert them to a slice
136+// of [ListItem]s.
137+func sessionItems(t *styles.Styles, sessions ...session.Session) []list.FilterableItem {
138+ items := make([]list.FilterableItem, len(sessions))
139+ for i, s := range sessions {
140+ items[i] = &SessionItem{Session: s, t: t}
141+ }
142+ return items
143+}
144+
145+func matchedRanges(in []int) [][2]int {
146+ if len(in) == 0 {
147+ return [][2]int{}
148+ }
149+ current := [2]int{in[0], in[0]}
150+ if len(in) == 1 {
151+ return [][2]int{current}
152+ }
153+ var out [][2]int
154+ for i := 1; i < len(in); i++ {
155+ if in[i] == current[1]+1 {
156+ current[1] = in[i]
157+ } else {
158+ out = append(out, current)
159+ current = [2]int{in[i], in[i]}
160+ }
161+ }
162+ out = append(out, current)
163+ return out
164+}
165+
166+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
167+ bytePos, byteStart, byteStop := 0, rng[0], rng[1]
168+ pos, start, stop := 0, 0, 0
169+ gr := uniseg.NewGraphemes(str)
170+ for byteStart > bytePos {
171+ if !gr.Next() {
172+ break
173+ }
174+ bytePos += len(gr.Str())
175+ pos += max(1, gr.Width())
176+ }
177+ start = pos
178+ for byteStop > bytePos {
179+ if !gr.Next() {
180+ break
181+ }
182+ bytePos += len(gr.Str())
183+ pos += max(1, gr.Width())
184+ }
185+ stop = pos
186+ return start, stop
187+}
internal/ui/image/image.gonew file+299
@@ -0,0 +1,299 @@
1+package image
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "hash/fnv"
7+ "image"
8+ "image/color"
9+ "io"
10+ "log/slog"
11+ "strings"
12+ "sync"
13+
14+ tea "charm.land/bubbletea/v2"
15+ "github.com/charmbracelet/crush/internal/uiutil"
16+ uv "github.com/charmbracelet/ultraviolet"
17+ "github.com/charmbracelet/x/ansi"
18+ "github.com/charmbracelet/x/ansi/kitty"
19+ "github.com/charmbracelet/x/mosaic"
20+ "github.com/disintegration/imaging"
21+)
22+
23+// Capabilities represents the capabilities of displaying images on the
24+// terminal.
25+type Capabilities struct {
26+ // Columns is the number of character columns in the terminal.
27+ Columns int
28+ // Rows is the number of character rows in the terminal.
29+ Rows int
30+ // PixelWidth is the width of the terminal in pixels.
31+ PixelWidth int
32+ // PixelHeight is the height of the terminal in pixels.
33+ PixelHeight int
34+ // SupportsKittyGraphics indicates whether the terminal supports the Kitty
35+ // graphics protocol.
36+ SupportsKittyGraphics bool
37+ // Env is the terminal environment variables.
38+ Env uv.Environ
39+}
40+
41+// CellSize returns the size of a single terminal cell in pixels.
42+func (c Capabilities) CellSize() CellSize {
43+ return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows)
44+}
45+
46+// CalculateCellSize calculates the size of a single terminal cell in pixels
47+// based on the terminal's pixel dimensions and character dimensions.
48+func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize {
49+ if charWidth == 0 || charHeight == 0 {
50+ return CellSize{}
51+ }
52+
53+ return CellSize{
54+ Width: pixelWidth / charWidth,
55+ Height: pixelHeight / charHeight,
56+ }
57+}
58+
59+// RequestCapabilities is a [tea.Cmd] that requests the terminal to report
60+// its image related capabilities to the program.
61+func RequestCapabilities(env uv.Environ) tea.Cmd {
62+ winOpReq := ansi.WindowOp(14) // Window size in pixels
63+ // ID 31 is just a random ID used to detect Kitty graphics support.
64+ kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24")
65+ if _, isTmux := env.LookupEnv("TMUX"); isTmux {
66+ kittyReq = ansi.TmuxPassthrough(kittyReq)
67+ }
68+
69+ return tea.Raw(winOpReq + kittyReq)
70+}
71+
72+// TransmittedMsg is a message indicating that an image has been transmitted to
73+// the terminal.
74+type TransmittedMsg struct {
75+ ID string
76+}
77+
78+// Encoding represents the encoding format of the image.
79+type Encoding byte
80+
81+// Image encodings.
82+const (
83+ EncodingBlocks Encoding = iota
84+ EncodingKitty
85+)
86+
87+type imageKey struct {
88+ id string
89+ cols int
90+ rows int
91+}
92+
93+// Hash returns a hash value for the image key.
94+// This uses FNV-32a for simplicity and speed.
95+func (k imageKey) Hash() uint32 {
96+ h := fnv.New32a()
97+ _, _ = io.WriteString(h, k.ID())
98+ return h.Sum32()
99+}
100+
101+// ID returns a unique string representation of the image key.
102+func (k imageKey) ID() string {
103+ return fmt.Sprintf("%s-%dx%d", k.id, k.cols, k.rows)
104+}
105+
106+// CellSize represents the size of a single terminal cell in pixels.
107+type CellSize struct {
108+ Width, Height int
109+}
110+
111+type cachedImage struct {
112+ img image.Image
113+ cols, rows int
114+}
115+
116+var (
117+ cachedImages = map[imageKey]cachedImage{}
118+ cachedMutex sync.RWMutex
119+)
120+
121+// fitImage resizes the image to fit within the specified dimensions in
122+// terminal cells, maintaining the aspect ratio.
123+func fitImage(id string, img image.Image, cs CellSize, cols, rows int) image.Image {
124+ if img == nil {
125+ return nil
126+ }
127+
128+ key := imageKey{id: id, cols: cols, rows: rows}
129+
130+ cachedMutex.RLock()
131+ cached, ok := cachedImages[key]
132+ cachedMutex.RUnlock()
133+ if ok {
134+ return cached.img
135+ }
136+
137+ if cs.Width == 0 || cs.Height == 0 {
138+ return img
139+ }
140+
141+ maxWidth := cols * cs.Width
142+ maxHeight := rows * cs.Height
143+
144+ img = imaging.Fit(img, maxWidth, maxHeight, imaging.Lanczos)
145+
146+ cachedMutex.Lock()
147+ cachedImages[key] = cachedImage{
148+ img: img,
149+ cols: cols,
150+ rows: rows,
151+ }
152+ cachedMutex.Unlock()
153+
154+ return img
155+}
156+
157+// HasTransmitted checks if the image with the given ID has already been
158+// transmitted to the terminal.
159+func HasTransmitted(id string, cols, rows int) bool {
160+ key := imageKey{id: id, cols: cols, rows: rows}
161+
162+ cachedMutex.RLock()
163+ _, ok := cachedImages[key]
164+ cachedMutex.RUnlock()
165+ return ok
166+}
167+
168+// Transmit transmits the image data to the terminal if needed. This is used to
169+// cache the image on the terminal for later rendering.
170+func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows int, tmux bool) tea.Cmd {
171+ if img == nil {
172+ return nil
173+ }
174+
175+ key := imageKey{id: id, cols: cols, rows: rows}
176+
177+ cachedMutex.RLock()
178+ _, ok := cachedImages[key]
179+ cachedMutex.RUnlock()
180+ if ok {
181+ return nil
182+ }
183+
184+ cmd := func() tea.Msg {
185+ if e != EncodingKitty {
186+ cachedMutex.Lock()
187+ cachedImages[key] = cachedImage{
188+ img: img,
189+ cols: cols,
190+ rows: rows,
191+ }
192+ cachedMutex.Unlock()
193+ return TransmittedMsg{ID: key.ID()}
194+ }
195+
196+ var buf bytes.Buffer
197+ img := fitImage(id, img, cs, cols, rows)
198+ bounds := img.Bounds()
199+ imgWidth := bounds.Dx()
200+ imgHeight := bounds.Dy()
201+ imgID := int(key.Hash())
202+ if err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
203+ ID: imgID,
204+ Action: kitty.TransmitAndPut,
205+ Transmission: kitty.Direct,
206+ Format: kitty.RGBA,
207+ ImageWidth: imgWidth,
208+ ImageHeight: imgHeight,
209+ Columns: cols,
210+ Rows: rows,
211+ VirtualPlacement: true,
212+ Quite: 1,
213+ Chunk: true,
214+ ChunkFormatter: func(chunk string) string {
215+ if tmux {
216+ return ansi.TmuxPassthrough(chunk)
217+ }
218+ return chunk
219+ },
220+ }); err != nil {
221+ slog.Error("failed to encode image for kitty graphics", "err", err)
222+ return uiutil.InfoMsg{
223+ Type: uiutil.InfoTypeError,
224+ Msg: "failed to encode image",
225+ }
226+ }
227+
228+ return tea.RawMsg{Msg: buf.String()}
229+ }
230+
231+ return cmd
232+}
233+
234+// Render renders the given image within the specified dimensions using the
235+// specified encoding.
236+func (e Encoding) Render(id string, cols, rows int) string {
237+ key := imageKey{id: id, cols: cols, rows: rows}
238+ cachedMutex.RLock()
239+ cached, ok := cachedImages[key]
240+ cachedMutex.RUnlock()
241+ if !ok {
242+ return ""
243+ }
244+
245+ img := cached.img
246+
247+ switch e {
248+ case EncodingBlocks:
249+ m := mosaic.New().Width(cols).Height(rows).Scale(1)
250+ return strings.TrimSpace(m.Render(img))
251+ case EncodingKitty:
252+ // Build Kitty graphics unicode place holders
253+ var fg color.Color
254+ var extra int
255+ var r, g, b int
256+ hashedID := key.Hash()
257+ id := int(hashedID)
258+ extra, r, g, b = id>>24&0xff, id>>16&0xff, id>>8&0xff, id&0xff
259+
260+ if id <= 255 {
261+ fg = ansi.IndexedColor(b)
262+ } else {
263+ fg = color.RGBA{
264+ R: uint8(r), //nolint:gosec
265+ G: uint8(g), //nolint:gosec
266+ B: uint8(b), //nolint:gosec
267+ A: 0xff,
268+ }
269+ }
270+
271+ fgStyle := ansi.NewStyle().ForegroundColor(fg).String()
272+
273+ var buf bytes.Buffer
274+ for y := range rows {
275+ // As an optimization, we only write the fg color sequence id, and
276+ // column-row data once on the first cell. The terminal will handle
277+ // the rest.
278+ buf.WriteString(fgStyle)
279+ buf.WriteRune(kitty.Placeholder)
280+ buf.WriteRune(kitty.Diacritic(y))
281+ buf.WriteRune(kitty.Diacritic(0))
282+ if extra > 0 {
283+ buf.WriteRune(kitty.Diacritic(extra))
284+ }
285+ for x := 1; x < cols; x++ {
286+ buf.WriteString(fgStyle)
287+ buf.WriteRune(kitty.Placeholder)
288+ }
289+ if y < rows-1 {
290+ buf.WriteByte('\n')
291+ }
292+ }
293+
294+ return buf.String()
295+
296+ default:
297+ return ""
298+ }
299+}
internal/ui/list/filterable.gonew file+125
@@ -0,0 +1,125 @@
1+package list
2+
3+import (
4+ "github.com/sahilm/fuzzy"
5+)
6+
7+// FilterableItem is an item that can be filtered via a query.
8+type FilterableItem interface {
9+ Item
10+ // Filter returns the value to be used for filtering.
11+ Filter() string
12+}
13+
14+// MatchSettable is an interface for items that can have their match indexes
15+// and match score set.
16+type MatchSettable interface {
17+ SetMatch(fuzzy.Match)
18+}
19+
20+// FilterableList is a list that takes filterable items that can be filtered
21+// via a settable query.
22+type FilterableList struct {
23+ *List
24+ items []FilterableItem
25+ query string
26+}
27+
28+// NewFilterableList creates a new filterable list.
29+func NewFilterableList(items ...FilterableItem) *FilterableList {
30+ f := &FilterableList{
31+ List: NewList(),
32+ items: items,
33+ }
34+ f.RegisterRenderCallback(FocusedRenderCallback(f.List))
35+ f.SetItems(items...)
36+ return f
37+}
38+
39+// SetItems sets the list items and updates the filtered items.
40+func (f *FilterableList) SetItems(items ...FilterableItem) {
41+ f.items = items
42+ fitems := make([]Item, len(items))
43+ for i, item := range items {
44+ fitems[i] = item
45+ }
46+ f.List.SetItems(fitems...)
47+}
48+
49+// AppendItems appends items to the list and updates the filtered items.
50+func (f *FilterableList) AppendItems(items ...FilterableItem) {
51+ f.items = append(f.items, items...)
52+ itms := make([]Item, len(f.items))
53+ for i, item := range f.items {
54+ itms[i] = item
55+ }
56+ f.List.SetItems(itms...)
57+}
58+
59+// PrependItems prepends items to the list and updates the filtered items.
60+func (f *FilterableList) PrependItems(items ...FilterableItem) {
61+ f.items = append(items, f.items...)
62+ itms := make([]Item, len(f.items))
63+ for i, item := range f.items {
64+ itms[i] = item
65+ }
66+ f.List.SetItems(itms...)
67+}
68+
69+// SetFilter sets the filter query and updates the list items.
70+func (f *FilterableList) SetFilter(q string) {
71+ f.query = q
72+ f.List.SetItems(f.VisibleItems()...)
73+ f.ScrollToTop()
74+}
75+
76+// FilterableItemsSource is a type that implements [fuzzy.Source] for filtering
77+// [FilterableItem]s.
78+type FilterableItemsSource []FilterableItem
79+
80+// Len returns the length of the source.
81+func (f FilterableItemsSource) Len() int {
82+ return len(f)
83+}
84+
85+// String returns the string representation of the item at index i.
86+func (f FilterableItemsSource) String(i int) string {
87+ return f[i].Filter()
88+}
89+
90+// VisibleItems returns the visible items after filtering.
91+func (f *FilterableList) VisibleItems() []Item {
92+ if f.query == "" {
93+ items := make([]Item, len(f.items))
94+ for i, item := range f.items {
95+ if ms, ok := item.(MatchSettable); ok {
96+ ms.SetMatch(fuzzy.Match{})
97+ item = ms.(FilterableItem)
98+ }
99+ items[i] = item
100+ }
101+ return items
102+ }
103+
104+ items := FilterableItemsSource(f.items)
105+ matches := fuzzy.FindFrom(f.query, items)
106+ matchedItems := []Item{}
107+ resultSize := len(matches)
108+ for i := range resultSize {
109+ match := matches[i]
110+ item := items[match.Index]
111+ if ms, ok := item.(MatchSettable); ok {
112+ ms.SetMatch(match)
113+ item = ms.(FilterableItem)
114+ }
115+ matchedItems = append(matchedItems, item)
116+ }
117+
118+ return matchedItems
119+}
120+
121+// Render renders the filterable list.
122+func (f *FilterableList) Render() string {
123+ f.List.SetItems(f.VisibleItems()...)
124+ return f.List.Render()
125+}
internal/ui/list/focus.gonew file+13
@@ -0,0 +1,13 @@
1+package list
2+
3+// FocusedRenderCallback is a helper function that returns a render callback
4+// that marks items as focused during rendering.
5+func FocusedRenderCallback(list *List) RenderCallback {
6+ return func(idx, selectedIdx int, item Item) Item {
7+ if focusable, ok := item.(Focusable); ok {
8+ focusable.SetFocused(list.Focused() && idx == selectedIdx)
9+ return focusable.(Item)
10+ }
11+ return item
12+ }
13+}
internal/ui/list/highlight.gonew file+208
@@ -0,0 +1,208 @@
1+package list
2+
3+import (
4+ "image"
5+ "strings"
6+
7+ "charm.land/lipgloss/v2"
8+ uv "github.com/charmbracelet/ultraviolet"
9+)
10+
11+// DefaultHighlighter is the default highlighter function that applies inverse style.
12+var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell {
13+ if c == nil {
14+ return c
15+ }
16+ c.Style.Attrs |= uv.AttrReverse
17+ return c
18+}
19+
20+// Highlighter represents a function that defines how to highlight text.
21+type Highlighter func(x, y int, c *uv.Cell) *uv.Cell
22+
23+// HighlightContent returns the content with highlighted regions based on the specified parameters.
24+func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string {
25+ var sb strings.Builder
26+ pos := image.Pt(-1, -1)
27+ HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell {
28+ pos.X = x
29+ if pos.Y == -1 {
30+ pos.Y = y
31+ } else if y > pos.Y {
32+ sb.WriteString(strings.Repeat("\n", y-pos.Y))
33+ pos.Y = y
34+ }
35+ sb.WriteString(c.Content)
36+ return c
37+ })
38+ if sb.Len() > 0 {
39+ sb.WriteString("\n")
40+ }
41+ return sb.String()
42+}
43+
44+// Highlight highlights a region of text within the given content and region.
45+func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string {
46+ buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter)
47+ if buf == nil {
48+ return content
49+ }
50+ return buf.Render()
51+}
52+
53+// HighlightBuffer highlights a region of text within the given content and
54+// region, returning a [uv.ScreenBuffer].
55+func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
56+ if startLine < 0 || startCol < 0 {
57+ return nil
58+ }
59+
60+ if highlighter == nil {
61+ highlighter = DefaultHighlighter
62+ }
63+
64+ width, height := area.Dx(), area.Dy()
65+ buf := uv.NewScreenBuffer(width, height)
66+ styled := uv.NewStyledString(content)
67+ styled.Draw(&buf, area)
68+
69+ // Treat -1 as "end of content"
70+ if endLine < 0 {
71+ endLine = height - 1
72+ }
73+ if endCol < 0 {
74+ endCol = width
75+ }
76+
77+ for y := startLine; y <= endLine && y < height; y++ {
78+ if y >= buf.Height() {
79+ break
80+ }
81+
82+ line := buf.Line(y)
83+
84+ // Determine column range for this line
85+ colStart := 0
86+ if y == startLine {
87+ colStart = min(startCol, len(line))
88+ }
89+
90+ colEnd := len(line)
91+ if y == endLine {
92+ colEnd = min(endCol, len(line))
93+ }
94+
95+ // Track last non-empty position as we go
96+ lastContentX := -1
97+
98+ // Single pass: check content and track last non-empty position
99+ for x := colStart; x < colEnd; x++ {
100+ cell := line.At(x)
101+ if cell == nil {
102+ continue
103+ }
104+
105+ // Update last content position if non-empty
106+ if cell.Content != "" && cell.Content != " " {
107+ lastContentX = x
108+ }
109+ }
110+
111+ // Only apply highlight up to last content position
112+ highlightEnd := colEnd
113+ if lastContentX >= 0 {
114+ highlightEnd = lastContentX + 1
115+ } else if lastContentX == -1 {
116+ highlightEnd = colStart // No content on this line
117+ }
118+
119+ // Apply highlight style only to cells with content
120+ for x := colStart; x < highlightEnd; x++ {
121+ if !image.Pt(x, y).In(area) {
122+ continue
123+ }
124+ cell := line.At(x)
125+ if cell != nil {
126+ line.Set(x, highlighter(x, y, cell))
127+ }
128+ }
129+ }
130+
131+ return &buf
132+}
133+
134+// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
135+func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
136+ return func(_ int, _ int, c *uv.Cell) *uv.Cell {
137+ if c != nil {
138+ c.Style = ToStyle(lgStyle)
139+ }
140+ return c
141+ }
142+}
143+
144+// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
145+func ToStyle(lgStyle lipgloss.Style) uv.Style {
146+ var uvStyle uv.Style
147+
148+ // Colors are already color.Color
149+ uvStyle.Fg = lgStyle.GetForeground()
150+ uvStyle.Bg = lgStyle.GetBackground()
151+
152+ // Build attributes using bitwise OR
153+ var attrs uint8
154+
155+ if lgStyle.GetBold() {
156+ attrs |= uv.AttrBold
157+ }
158+
159+ if lgStyle.GetItalic() {
160+ attrs |= uv.AttrItalic
161+ }
162+
163+ if lgStyle.GetUnderline() {
164+ uvStyle.Underline = uv.UnderlineSingle
165+ }
166+
167+ if lgStyle.GetStrikethrough() {
168+ attrs |= uv.AttrStrikethrough
169+ }
170+
171+ if lgStyle.GetFaint() {
172+ attrs |= uv.AttrFaint
173+ }
174+
175+ if lgStyle.GetBlink() {
176+ attrs |= uv.AttrBlink
177+ }
178+
179+ if lgStyle.GetReverse() {
180+ attrs |= uv.AttrReverse
181+ }
182+
183+ uvStyle.Attrs = attrs
184+
185+ return uvStyle
186+}
187+
188+// AdjustArea adjusts the given area rectangle by subtracting margins, borders,
189+// and padding from the style.
190+func AdjustArea(area image.Rectangle, style lipgloss.Style) image.Rectangle {
191+ topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin()
192+ topBorder, rightBorder, bottomBorder, leftBorder := style.GetBorderTopSize(),
193+ style.GetBorderRightSize(),
194+ style.GetBorderBottomSize(),
195+ style.GetBorderLeftSize()
196+ topPadding, rightPadding, bottomPadding, leftPadding := style.GetPadding()
197+
198+ return image.Rectangle{
199+ Min: image.Point{
200+ X: area.Min.X + leftMargin + leftBorder + leftPadding,
201+ Y: area.Min.Y + topMargin + topBorder + topPadding,
202+ },
203+ Max: image.Point{
204+ X: area.Max.X - (rightMargin + rightBorder + rightPadding),
205+ Y: area.Max.Y - (bottomMargin + bottomBorder + bottomPadding),
206+ },
207+ }
208+}
internal/ui/list/item.gonew file+61
@@ -0,0 +1,61 @@
1+package list
2+
3+import (
4+ "strings"
5+
6+ "github.com/charmbracelet/x/ansi"
7+)
8+
9+// Item represents a single item in the lazy-loaded list.
10+type Item interface {
11+ // Render returns the string representation of the item for the given
12+ // width.
13+ Render(width int) string
14+}
15+
16+// RawRenderable represents an item that can provide a raw rendering
17+// without additional styling.
18+type RawRenderable interface {
19+ // RawRender returns the raw rendered string without any additional
20+ // styling.
21+ RawRender(width int) string
22+}
23+
24+// Focusable represents an item that can be aware of focus state changes.
25+type Focusable interface {
26+ // SetFocused sets the focus state of the item.
27+ SetFocused(focused bool)
28+}
29+
30+// Highlightable represents an item that can highlight a portion of its content.
31+type Highlightable interface {
32+ // SetHighlight highlights the content from the given start to end
33+ // positions. Use -1 for no highlight.
34+ SetHighlight(startLine, startCol, endLine, endCol int)
35+ // Highlight returns the current highlight positions within the item.
36+ Highlight() (startLine, startCol, endLine, endCol int)
37+}
38+
39+// MouseClickable represents an item that can handle mouse click events.
40+type MouseClickable interface {
41+ // HandleMouseClick processes a mouse click event at the given coordinates.
42+ // It returns true if the event was handled, false otherwise.
43+ HandleMouseClick(btn ansi.MouseButton, x, y int) bool
44+}
45+
46+// SpacerItem is a spacer item that adds vertical space in the list.
47+type SpacerItem struct {
48+ Height int
49+}
50+
51+// NewSpacerItem creates a new [SpacerItem] with the specified height.
52+func NewSpacerItem(height int) *SpacerItem {
53+ return &SpacerItem{
54+ Height: max(0, height-1),
55+ }
56+}
57+
58+// Render implements the Item interface for [SpacerItem].
59+func (s *SpacerItem) Render(width int) string {
60+ return strings.Repeat("\n", s.Height)
61+}
internal/ui/list/list.gonew file+634
@@ -0,0 +1,634 @@
1+package list
2+
3+import (
4+ "strings"
5+)
6+
7+// List represents a list of items that can be lazily rendered. A list is
8+// always rendered like a chat conversation where items are stacked vertically
9+// from top to bottom.
10+type List struct {
11+ // Viewport size
12+ width, height int
13+
14+ // Items in the list
15+ items []Item
16+
17+ // Gap between items (0 or less means no gap)
18+ gap int
19+
20+ // show list in reverse order
21+ reverse bool
22+
23+ // Focus and selection state
24+ focused bool
25+ selectedIdx int // The current selected index -1 means no selection
26+
27+ // offsetIdx is the index of the first visible item in the viewport.
28+ offsetIdx int
29+ // offsetLine is the number of lines of the item at offsetIdx that are
30+ // scrolled out of view (above the viewport).
31+ // It must always be >= 0.
32+ offsetLine int
33+
34+ // renderCallbacks is a list of callbacks to apply when rendering items.
35+ renderCallbacks []func(idx, selectedIdx int, item Item) Item
36+}
37+
38+// renderedItem holds the rendered content and height of an item.
39+type renderedItem struct {
40+ content string
41+ height int
42+}
43+
44+// NewList creates a new lazy-loaded list.
45+func NewList(items ...Item) *List {
46+ l := new(List)
47+ l.items = items
48+ l.selectedIdx = -1
49+ return l
50+}
51+
52+// RenderCallback defines a function that can modify an item before it is
53+// rendered.
54+type RenderCallback func(idx, selectedIdx int, item Item) Item
55+
56+// RegisterRenderCallback registers a callback to be called when rendering
57+// items. This can be used to modify items before they are rendered.
58+func (l *List) RegisterRenderCallback(cb RenderCallback) {
59+ l.renderCallbacks = append(l.renderCallbacks, cb)
60+}
61+
62+// SetSize sets the size of the list viewport.
63+func (l *List) SetSize(width, height int) {
64+ l.width = width
65+ l.height = height
66+}
67+
68+// SetGap sets the gap between items.
69+func (l *List) SetGap(gap int) {
70+ l.gap = gap
71+}
72+
73+// Gap returns the gap between items.
74+func (l *List) Gap() int {
75+ return l.gap
76+}
77+
78+// SetReverse shows the list in reverse order.
79+func (l *List) SetReverse(reverse bool) {
80+ l.reverse = reverse
81+}
82+
83+// Width returns the width of the list viewport.
84+func (l *List) Width() int {
85+ return l.width
86+}
87+
88+// Height returns the height of the list viewport.
89+func (l *List) Height() int {
90+ return l.height
91+}
92+
93+// Len returns the number of items in the list.
94+func (l *List) Len() int {
95+ return len(l.items)
96+}
97+
98+// getItem renders (if needed) and returns the item at the given index.
99+func (l *List) getItem(idx int) renderedItem {
100+ if idx < 0 || idx >= len(l.items) {
101+ return renderedItem{}
102+ }
103+
104+ item := l.items[idx]
105+ if len(l.renderCallbacks) > 0 {
106+ for _, cb := range l.renderCallbacks {
107+ if it := cb(idx, l.selectedIdx, item); it != nil {
108+ item = it
109+ }
110+ }
111+ }
112+
113+ rendered := item.Render(l.width)
114+ rendered = strings.TrimRight(rendered, "\n")
115+ height := countLines(rendered)
116+ ri := renderedItem{
117+ content: rendered,
118+ height: height,
119+ }
120+
121+ return ri
122+}
123+
124+// ScrollToIndex scrolls the list to the given item index.
125+func (l *List) ScrollToIndex(index int) {
126+ if index < 0 {
127+ index = 0
128+ }
129+ if index >= len(l.items) {
130+ index = len(l.items) - 1
131+ }
132+ l.offsetIdx = index
133+ l.offsetLine = 0
134+}
135+
136+// ScrollBy scrolls the list by the given number of lines.
137+func (l *List) ScrollBy(lines int) {
138+ if len(l.items) == 0 || lines == 0 {
139+ return
140+ }
141+
142+ if l.reverse {
143+ lines = -lines
144+ }
145+
146+ if lines > 0 {
147+ // Scroll down
148+ // Calculate from the bottom how many lines needed to anchor the last
149+ // item to the bottom
150+ var totalLines int
151+ var lastItemIdx int // the last item that can be partially visible
152+ for i := len(l.items) - 1; i >= 0; i-- {
153+ item := l.getItem(i)
154+ totalLines += item.height
155+ if l.gap > 0 && i < len(l.items)-1 {
156+ totalLines += l.gap
157+ }
158+ if totalLines > l.height-1 {
159+ lastItemIdx = i
160+ break
161+ }
162+ }
163+
164+ // Now scroll down by lines
165+ var item renderedItem
166+ l.offsetLine += lines
167+ for {
168+ item = l.getItem(l.offsetIdx)
169+ totalHeight := item.height
170+ if l.gap > 0 {
171+ totalHeight += l.gap
172+ }
173+
174+ if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
175+ // Valid offset
176+ break
177+ }
178+
179+ // Move to next item
180+ l.offsetLine -= totalHeight
181+ l.offsetIdx++
182+ }
183+
184+ if l.offsetLine >= item.height {
185+ l.offsetLine = item.height
186+ }
187+ } else if lines < 0 {
188+ // Scroll up
189+ l.offsetLine += lines // lines is negative
190+ for l.offsetLine < 0 {
191+ if l.offsetIdx <= 0 {
192+ // Reached top
193+ l.ScrollToTop()
194+ break
195+ }
196+
197+ // Move to previous item
198+ l.offsetIdx--
199+ prevItem := l.getItem(l.offsetIdx)
200+ totalHeight := prevItem.height
201+ if l.gap > 0 {
202+ totalHeight += l.gap
203+ }
204+ l.offsetLine += totalHeight
205+ }
206+ }
207+}
208+
209+// VisibleItemIndices finds the range of items that are visible in the viewport.
210+// This is used for checking if selected item is in view.
211+func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
212+ if len(l.items) == 0 {
213+ return 0, 0
214+ }
215+
216+ startIdx = l.offsetIdx
217+ currentIdx := startIdx
218+ visibleHeight := -l.offsetLine
219+
220+ for currentIdx < len(l.items) {
221+ item := l.getItem(currentIdx)
222+ visibleHeight += item.height
223+ if l.gap > 0 {
224+ visibleHeight += l.gap
225+ }
226+
227+ if visibleHeight >= l.height {
228+ break
229+ }
230+ currentIdx++
231+ }
232+
233+ endIdx = currentIdx
234+ if endIdx >= len(l.items) {
235+ endIdx = len(l.items) - 1
236+ }
237+
238+ return startIdx, endIdx
239+}
240+
241+// Render renders the list and returns the visible lines.
242+func (l *List) Render() string {
243+ if len(l.items) == 0 {
244+ return ""
245+ }
246+
247+ var lines []string
248+ currentIdx := l.offsetIdx
249+ currentOffset := l.offsetLine
250+
251+ linesNeeded := l.height
252+
253+ for linesNeeded > 0 && currentIdx < len(l.items) {
254+ item := l.getItem(currentIdx)
255+ itemLines := strings.Split(item.content, "\n")
256+ itemHeight := len(itemLines)
257+
258+ if currentOffset >= 0 && currentOffset < itemHeight {
259+ // Add visible content lines
260+ lines = append(lines, itemLines[currentOffset:]...)
261+
262+ // Add gap if this is not the absolute last visual element (conceptually gaps are between items)
263+ // But in the loop we can just add it and trim later
264+ if l.gap > 0 {
265+ for i := 0; i < l.gap; i++ {
266+ lines = append(lines, "")
267+ }
268+ }
269+ } else {
270+ // offsetLine starts in the gap
271+ gapOffset := currentOffset - itemHeight
272+ gapRemaining := l.gap - gapOffset
273+ if gapRemaining > 0 {
274+ for range gapRemaining {
275+ lines = append(lines, "")
276+ }
277+ }
278+ }
279+
280+ linesNeeded = l.height - len(lines)
281+ currentIdx++
282+ currentOffset = 0 // Reset offset for subsequent items
283+ }
284+
285+ if len(lines) > l.height {
286+ lines = lines[:l.height]
287+ }
288+
289+ if l.reverse {
290+ // Reverse the lines so the list renders bottom-to-top.
291+ for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
292+ lines[i], lines[j] = lines[j], lines[i]
293+ }
294+ }
295+
296+ return strings.Join(lines, "\n")
297+}
298+
299+// PrependItems prepends items to the list.
300+func (l *List) PrependItems(items ...Item) {
301+ l.items = append(items, l.items...)
302+
303+ // Keep view position relative to the content that was visible
304+ l.offsetIdx += len(items)
305+
306+ // Update selection index if valid
307+ if l.selectedIdx != -1 {
308+ l.selectedIdx += len(items)
309+ }
310+}
311+
312+// SetItems sets the items in the list.
313+func (l *List) SetItems(items ...Item) {
314+ l.setItems(true, items...)
315+}
316+
317+// setItems sets the items in the list. If evict is true, it clears the
318+// rendered item cache.
319+func (l *List) setItems(evict bool, items ...Item) {
320+ l.items = items
321+ l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
322+ l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
323+ l.offsetLine = 0
324+}
325+
326+// AppendItems appends items to the list.
327+func (l *List) AppendItems(items ...Item) {
328+ l.items = append(l.items, items...)
329+}
330+
331+// RemoveItem removes the item at the given index from the list.
332+func (l *List) RemoveItem(idx int) {
333+ if idx < 0 || idx >= len(l.items) {
334+ return
335+ }
336+
337+ // Remove the item
338+ l.items = append(l.items[:idx], l.items[idx+1:]...)
339+
340+ // Adjust selection if needed
341+ if l.selectedIdx == idx {
342+ l.selectedIdx = -1
343+ } else if l.selectedIdx > idx {
344+ l.selectedIdx--
345+ }
346+
347+ // Adjust offset if needed
348+ if l.offsetIdx > idx {
349+ l.offsetIdx--
350+ } else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
351+ l.offsetIdx = max(0, len(l.items)-1)
352+ l.offsetLine = 0
353+ }
354+}
355+
356+// Focused returns whether the list is focused.
357+func (l *List) Focused() bool {
358+ return l.focused
359+}
360+
361+// Focus sets the focus state of the list.
362+func (l *List) Focus() {
363+ l.focused = true
364+}
365+
366+// Blur removes the focus state from the list.
367+func (l *List) Blur() {
368+ l.focused = false
369+}
370+
371+// ScrollToTop scrolls the list to the top.
372+func (l *List) ScrollToTop() {
373+ l.offsetIdx = 0
374+ l.offsetLine = 0
375+}
376+
377+// ScrollToBottom scrolls the list to the bottom.
378+func (l *List) ScrollToBottom() {
379+ if len(l.items) == 0 {
380+ return
381+ }
382+
383+ // Scroll to the last item
384+ var totalHeight int
385+ for i := len(l.items) - 1; i >= 0; i-- {
386+ item := l.getItem(i)
387+ totalHeight += item.height
388+ if l.gap > 0 && i < len(l.items)-1 {
389+ totalHeight += l.gap
390+ }
391+ if totalHeight >= l.height {
392+ l.offsetIdx = i
393+ l.offsetLine = totalHeight - l.height
394+ break
395+ }
396+ }
397+ if totalHeight < l.height {
398+ // All items fit in the viewport
399+ l.ScrollToTop()
400+ }
401+}
402+
403+// ScrollToSelected scrolls the list to the selected item.
404+func (l *List) ScrollToSelected() {
405+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
406+ return
407+ }
408+
409+ startIdx, endIdx := l.VisibleItemIndices()
410+ if l.selectedIdx < startIdx {
411+ // Selected item is above the visible range
412+ l.offsetIdx = l.selectedIdx
413+ l.offsetLine = 0
414+ } else if l.selectedIdx > endIdx {
415+ // Selected item is below the visible range
416+ // Scroll so that the selected item is at the bottom
417+ var totalHeight int
418+ for i := l.selectedIdx; i >= 0; i-- {
419+ item := l.getItem(i)
420+ totalHeight += item.height
421+ if l.gap > 0 && i < l.selectedIdx {
422+ totalHeight += l.gap
423+ }
424+ if totalHeight >= l.height {
425+ l.offsetIdx = i
426+ l.offsetLine = totalHeight - l.height
427+ break
428+ }
429+ }
430+ if totalHeight < l.height {
431+ // All items fit in the viewport
432+ l.ScrollToTop()
433+ }
434+ }
435+}
436+
437+// SelectedItemInView returns whether the selected item is currently in view.
438+func (l *List) SelectedItemInView() bool {
439+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
440+ return false
441+ }
442+ startIdx, endIdx := l.VisibleItemIndices()
443+ return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
444+}
445+
446+// SetSelected sets the selected item index in the list.
447+// It returns -1 if the index is out of bounds.
448+func (l *List) SetSelected(index int) {
449+ if index < 0 || index >= len(l.items) {
450+ l.selectedIdx = -1
451+ } else {
452+ l.selectedIdx = index
453+ }
454+}
455+
456+// Selected returns the index of the currently selected item. It returns -1 if
457+// no item is selected.
458+func (l *List) Selected() int {
459+ return l.selectedIdx
460+}
461+
462+// IsSelectedFirst returns whether the first item is selected.
463+func (l *List) IsSelectedFirst() bool {
464+ return l.selectedIdx == 0
465+}
466+
467+// IsSelectedLast returns whether the last item is selected.
468+func (l *List) IsSelectedLast() bool {
469+ return l.selectedIdx == len(l.items)-1
470+}
471+
472+// SelectPrev selects the visually previous item (moves toward visual top).
473+// It returns whether the selection changed.
474+func (l *List) SelectPrev() bool {
475+ if l.reverse {
476+ // In reverse, visual up = higher index
477+ if l.selectedIdx < len(l.items)-1 {
478+ l.selectedIdx++
479+ return true
480+ }
481+ } else {
482+ // Normal: visual up = lower index
483+ if l.selectedIdx > 0 {
484+ l.selectedIdx--
485+ return true
486+ }
487+ }
488+ return false
489+}
490+
491+// SelectNext selects the next item in the list.
492+// It returns whether the selection changed.
493+func (l *List) SelectNext() bool {
494+ if l.reverse {
495+ // In reverse, visual down = lower index
496+ if l.selectedIdx > 0 {
497+ l.selectedIdx--
498+ return true
499+ }
500+ } else {
501+ // Normal: visual down = higher index
502+ if l.selectedIdx < len(l.items)-1 {
503+ l.selectedIdx++
504+ return true
505+ }
506+ }
507+ return false
508+}
509+
510+// SelectFirst selects the first item in the list.
511+// It returns whether the selection changed.
512+func (l *List) SelectFirst() bool {
513+ if len(l.items) == 0 {
514+ return false
515+ }
516+ l.selectedIdx = 0
517+ return true
518+}
519+
520+// SelectLast selects the last item in the list (highest index).
521+// It returns whether the selection changed.
522+func (l *List) SelectLast() bool {
523+ if len(l.items) == 0 {
524+ return false
525+ }
526+ l.selectedIdx = len(l.items) - 1
527+ return true
528+}
529+
530+// WrapToStart wraps selection to the visual start (for circular navigation).
531+// In normal mode, this is index 0. In reverse mode, this is the highest index.
532+func (l *List) WrapToStart() bool {
533+ if len(l.items) == 0 {
534+ return false
535+ }
536+ if l.reverse {
537+ l.selectedIdx = len(l.items) - 1
538+ } else {
539+ l.selectedIdx = 0
540+ }
541+ return true
542+}
543+
544+// WrapToEnd wraps selection to the visual end (for circular navigation).
545+// In normal mode, this is the highest index. In reverse mode, this is index 0.
546+func (l *List) WrapToEnd() bool {
547+ if len(l.items) == 0 {
548+ return false
549+ }
550+ if l.reverse {
551+ l.selectedIdx = 0
552+ } else {
553+ l.selectedIdx = len(l.items) - 1
554+ }
555+ return true
556+}
557+
558+// SelectedItem returns the currently selected item. It may be nil if no item
559+// is selected.
560+func (l *List) SelectedItem() Item {
561+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
562+ return nil
563+ }
564+ return l.items[l.selectedIdx]
565+}
566+
567+// SelectFirstInView selects the first item currently in view.
568+func (l *List) SelectFirstInView() {
569+ startIdx, _ := l.VisibleItemIndices()
570+ l.selectedIdx = startIdx
571+}
572+
573+// SelectLastInView selects the last item currently in view.
574+func (l *List) SelectLastInView() {
575+ _, endIdx := l.VisibleItemIndices()
576+ l.selectedIdx = endIdx
577+}
578+
579+// ItemAt returns the item at the given index.
580+func (l *List) ItemAt(index int) Item {
581+ if index < 0 || index >= len(l.items) {
582+ return nil
583+ }
584+ return l.items[index]
585+}
586+
587+// ItemIndexAtPosition returns the item at the given viewport-relative y
588+// coordinate. Returns the item index and the y offset within that item. It
589+// returns -1, -1 if no item is found.
590+func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
591+ return l.findItemAtY(x, y)
592+}
593+
594+// findItemAtY finds the item at the given viewport y coordinate.
595+// Returns the item index and the y offset within that item. It returns -1, -1
596+// if no item is found.
597+func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
598+ if y < 0 || y >= l.height {
599+ return -1, -1
600+ }
601+
602+ // Walk through visible items to find which one contains this y
603+ currentIdx := l.offsetIdx
604+ currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
605+
606+ for currentIdx < len(l.items) && currentLine < l.height {
607+ item := l.getItem(currentIdx)
608+ itemEndLine := currentLine + item.height
609+
610+ // Check if y is within this item's visible range
611+ if y >= currentLine && y < itemEndLine {
612+ // Found the item, calculate itemY (offset within the item)
613+ itemY = y - currentLine
614+ return currentIdx, itemY
615+ }
616+
617+ // Move to next item
618+ currentLine = itemEndLine
619+ if l.gap > 0 {
620+ currentLine += l.gap
621+ }
622+ currentIdx++
623+ }
624+
625+ return -1, -1
626+}
627+
628+// countLines counts the number of lines in a string.
629+func countLines(s string) int {
630+ if s == "" {
631+ return 1
632+ }
633+ return strings.Count(s, "\n") + 1
634+}
internal/ui/logo/logo.gonew file+346
@@ -0,0 +1,346 @@
1+// Package logo renders a Crush wordmark in a stylized way.
2+package logo
3+
4+import (
5+ "fmt"
6+ "image/color"
7+ "strings"
8+
9+ "charm.land/lipgloss/v2"
10+ "github.com/MakeNowJust/heredoc"
11+ "github.com/charmbracelet/crush/internal/tui/styles"
12+ "github.com/charmbracelet/x/ansi"
13+ "github.com/charmbracelet/x/exp/slice"
14+)
15+
16+// letterform represents a letterform. It can be stretched horizontally by
17+// a given amount via the boolean argument.
18+type letterform func(bool) string
19+
20+const diag = `╱`
21+
22+// Opts are the options for rendering the Crush title art.
23+type Opts struct {
24+ FieldColor color.Color // diagonal lines
25+ TitleColorA color.Color // left gradient ramp point
26+ TitleColorB color.Color // right gradient ramp point
27+ CharmColor color.Color // Charm™ text color
28+ VersionColor color.Color // Version text color
29+ Width int // width of the rendered logo, used for truncation
30+}
31+
32+// Render renders the Crush logo. Set the argument to true to render the narrow
33+// version, intended for use in a sidebar.
34+//
35+// The compact argument determines whether it renders compact for the sidebar
36+// or wider for the main pane.
37+func Render(version string, compact bool, o Opts) string {
38+ const charm = " Charm™"
39+
40+ fg := func(c color.Color, s string) string {
41+ return lipgloss.NewStyle().Foreground(c).Render(s)
42+ }
43+
44+ // Title.
45+ const spacing = 1
46+ letterforms := []letterform{
47+ letterC,
48+ letterR,
49+ letterU,
50+ letterSStylized,
51+ letterH,
52+ }
53+ stretchIndex := -1 // -1 means no stretching.
54+ if !compact {
55+ stretchIndex = cachedRandN(len(letterforms))
56+ }
57+
58+ crush := renderWord(spacing, stretchIndex, letterforms...)
59+ crushWidth := lipgloss.Width(crush)
60+ b := new(strings.Builder)
61+ for r := range strings.SplitSeq(crush, "\n") {
62+ fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
63+ }
64+ crush = b.String()
65+
66+ // Charm and version.
67+ metaRowGap := 1
68+ maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
69+ version = ansi.Truncate(version, maxVersionWidth, "…") // truncate version if too long.
70+ gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
71+ metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
72+
73+ // Join the meta row and big Crush title.
74+ crush = strings.TrimSpace(metaRow + "\n" + crush)
75+
76+ // Narrow version.
77+ if compact {
78+ field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
79+ return strings.Join([]string{field, field, crush, field, ""}, "\n")
80+ }
81+
82+ fieldHeight := lipgloss.Height(crush)
83+
84+ // Left field.
85+ const leftWidth = 6
86+ leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
87+ leftField := new(strings.Builder)
88+ for range fieldHeight {
89+ fmt.Fprintln(leftField, leftFieldRow)
90+ }
91+
92+ // Right field.
93+ rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
94+ const stepDownAt = 0
95+ rightField := new(strings.Builder)
96+ for i := range fieldHeight {
97+ width := rightWidth
98+ if i >= stepDownAt {
99+ width = rightWidth - (i - stepDownAt)
100+ }
101+ fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
102+ }
103+
104+ // Return the wide version.
105+ const hGap = " "
106+ logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
107+ if o.Width > 0 {
108+ // Truncate the logo to the specified width.
109+ lines := strings.Split(logo, "\n")
110+ for i, line := range lines {
111+ lines[i] = ansi.Truncate(line, o.Width, "")
112+ }
113+ logo = strings.Join(lines, "\n")
114+ }
115+ return logo
116+}
117+
118+// SmallRender renders a smaller version of the Crush logo, suitable for
119+// smaller windows or sidebar usage.
120+func SmallRender(width int) string {
121+ t := styles.CurrentTheme()
122+ title := t.S().Base.Foreground(t.Secondary).Render("Charm™")
123+ title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
124+ remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
125+ if remainingWidth > 0 {
126+ lines := strings.Repeat("╱", remainingWidth)
127+ title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
128+ }
129+ return title
130+}
131+
132+// renderWord renders letterforms to fork a word. stretchIndex is the index of
133+// the letter to stretch, or -1 if no letter should be stretched.
134+func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string {
135+ if spacing < 0 {
136+ spacing = 0
137+ }
138+
139+ renderedLetterforms := make([]string, len(letterforms))
140+
141+ // pick one letter randomly to stretch
142+ for i, letter := range letterforms {
143+ renderedLetterforms[i] = letter(i == stretchIndex)
144+ }
145+
146+ if spacing > 0 {
147+ // Add spaces between the letters and render.
148+ renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
149+ }
150+ return strings.TrimSpace(
151+ lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
152+ )
153+}
154+
155+// letterC renders the letter C in a stylized way. It takes an integer that
156+// determines how many cells to stretch the letter. If the stretch is less than
157+// 1, it defaults to no stretching.
158+func letterC(stretch bool) string {
159+ // Here's what we're making:
160+ //
161+ // ▄▀▀▀▀
162+ // █
163+ // ▀▀▀▀
164+
165+ left := heredoc.Doc(`
166+ ▄
167+ █
168+ `)
169+ right := heredoc.Doc(`
170+ ▀
171+
172+ ▀
173+ `)
174+ return joinLetterform(
175+ left,
176+ stretchLetterformPart(right, letterformProps{
177+ stretch: stretch,
178+ width: 4,
179+ minStretch: 7,
180+ maxStretch: 12,
181+ }),
182+ )
183+}
184+
185+// letterH renders the letter H in a stylized way. It takes an integer that
186+// determines how many cells to stretch the letter. If the stretch is less than
187+// 1, it defaults to no stretching.
188+func letterH(stretch bool) string {
189+ // Here's what we're making:
190+ //
191+ // █ █
192+ // █▀▀▀█
193+ // ▀ ▀
194+
195+ side := heredoc.Doc(`
196+ █
197+ █
198+ ▀`)
199+ middle := heredoc.Doc(`
200+
201+ ▀
202+ `)
203+ return joinLetterform(
204+ side,
205+ stretchLetterformPart(middle, letterformProps{
206+ stretch: stretch,
207+ width: 3,
208+ minStretch: 8,
209+ maxStretch: 12,
210+ }),
211+ side,
212+ )
213+}
214+
215+// letterR renders the letter R in a stylized way. It takes an integer that
216+// determines how many cells to stretch the letter. If the stretch is less than
217+// 1, it defaults to no stretching.
218+func letterR(stretch bool) string {
219+ // Here's what we're making:
220+ //
221+ // █▀▀▀▄
222+ // █▀▀▀▄
223+ // ▀ ▀
224+
225+ left := heredoc.Doc(`
226+ █
227+ █
228+ ▀
229+ `)
230+ center := heredoc.Doc(`
231+ ▀
232+ ▀
233+ `)
234+ right := heredoc.Doc(`
235+ ▄
236+ ▄
237+ ▀
238+ `)
239+ return joinLetterform(
240+ left,
241+ stretchLetterformPart(center, letterformProps{
242+ stretch: stretch,
243+ width: 3,
244+ minStretch: 7,
245+ maxStretch: 12,
246+ }),
247+ right,
248+ )
249+}
250+
251+// letterSStylized renders the letter S in a stylized way, more so than
252+// [letterS]. It takes an integer that determines how many cells to stretch the
253+// letter. If the stretch is less than 1, it defaults to no stretching.
254+func letterSStylized(stretch bool) string {
255+ // Here's what we're making:
256+ //
257+ // ▄▀▀▀▀▀
258+ // ▀▀▀▀▀█
259+ // ▀▀▀▀▀
260+
261+ left := heredoc.Doc(`
262+ ▄
263+ ▀
264+ ▀
265+ `)
266+ center := heredoc.Doc(`
267+ ▀
268+ ▀
269+ ▀
270+ `)
271+ right := heredoc.Doc(`
272+ ▀
273+ █
274+ `)
275+ return joinLetterform(
276+ left,
277+ stretchLetterformPart(center, letterformProps{
278+ stretch: stretch,
279+ width: 3,
280+ minStretch: 7,
281+ maxStretch: 12,
282+ }),
283+ right,
284+ )
285+}
286+
287+// letterU renders the letter U in a stylized way. It takes an integer that
288+// determines how many cells to stretch the letter. If the stretch is less than
289+// 1, it defaults to no stretching.
290+func letterU(stretch bool) string {
291+ // Here's what we're making:
292+ //
293+ // █ █
294+ // █ █
295+ // ▀▀▀
296+
297+ side := heredoc.Doc(`
298+ █
299+ █
300+ `)
301+ middle := heredoc.Doc(`
302+
303+
304+ ▀
305+ `)
306+ return joinLetterform(
307+ side,
308+ stretchLetterformPart(middle, letterformProps{
309+ stretch: stretch,
310+ width: 3,
311+ minStretch: 7,
312+ maxStretch: 12,
313+ }),
314+ side,
315+ )
316+}
317+
318+func joinLetterform(letters ...string) string {
319+ return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
320+}
321+
322+// letterformProps defines letterform stretching properties.
323+// for readability.
324+type letterformProps struct {
325+ width int
326+ minStretch int
327+ maxStretch int
328+ stretch bool
329+}
330+
331+// stretchLetterformPart is a helper function for letter stretching. If randomize
332+// is false the minimum number will be used.
333+func stretchLetterformPart(s string, p letterformProps) string {
334+ if p.maxStretch < p.minStretch {
335+ p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
336+ }
337+ n := p.width
338+ if p.stretch {
339+ n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
340+ }
341+ parts := make([]string, n)
342+ for i := range parts {
343+ parts[i] = s
344+ }
345+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
346+}
internal/ui/logo/rand.gonew file+24
@@ -0,0 +1,24 @@
1+package logo
2+
3+import (
4+ "math/rand/v2"
5+ "sync"
6+)
7+
8+var (
9+ randCaches = make(map[int]int)
10+ randCachesMu sync.Mutex
11+)
12+
13+func cachedRandN(n int) int {
14+ randCachesMu.Lock()
15+ defer randCachesMu.Unlock()
16+
17+ if n, ok := randCaches[n]; ok {
18+ return n
19+ }
20+
21+ r := rand.IntN(n)
22+ randCaches[n] = r
23+ return r
24+}
internal/ui/model/chat.gonew file+600
@@ -0,0 +1,600 @@
1+package model
2+
3+import (
4+ "strings"
5+
6+ tea "charm.land/bubbletea/v2"
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/ui/anim"
9+ "github.com/charmbracelet/crush/internal/ui/chat"
10+ "github.com/charmbracelet/crush/internal/ui/common"
11+ "github.com/charmbracelet/crush/internal/ui/list"
12+ uv "github.com/charmbracelet/ultraviolet"
13+ "github.com/charmbracelet/x/ansi"
14+)
15+
16+// Chat represents the chat UI model that handles chat interactions and
17+// messages.
18+type Chat struct {
19+ com *common.Common
20+ list *list.List
21+ idInxMap map[string]int // Map of message IDs to their indices in the list
22+
23+ // Animation visibility optimization: track animations paused due to items
24+ // being scrolled out of view. When items become visible again, their
25+ // animations are restarted.
26+ pausedAnimations map[string]struct{}
27+
28+ // Mouse state
29+ mouseDown bool
30+ mouseDownItem int // Item index where mouse was pressed
31+ mouseDownX int // X position in item content (character offset)
32+ mouseDownY int // Y position in item (line offset)
33+ mouseDragItem int // Current item index being dragged over
34+ mouseDragX int // Current X in item content
35+ mouseDragY int // Current Y in item
36+}
37+
38+// NewChat creates a new instance of [Chat] that handles chat interactions and
39+// messages.
40+func NewChat(com *common.Common) *Chat {
41+ c := &Chat{
42+ com: com,
43+ idInxMap: make(map[string]int),
44+ pausedAnimations: make(map[string]struct{}),
45+ }
46+ l := list.NewList()
47+ l.SetGap(1)
48+ l.RegisterRenderCallback(c.applyHighlightRange)
49+ l.RegisterRenderCallback(list.FocusedRenderCallback(l))
50+ c.list = l
51+ c.mouseDownItem = -1
52+ c.mouseDragItem = -1
53+ return c
54+}
55+
56+// Height returns the height of the chat view port.
57+func (m *Chat) Height() int {
58+ return m.list.Height()
59+}
60+
61+// Draw renders the chat UI component to the screen and the given area.
62+func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
63+ uv.NewStyledString(m.list.Render()).Draw(scr, area)
64+}
65+
66+// SetSize sets the size of the chat view port.
67+func (m *Chat) SetSize(width, height int) {
68+ m.list.SetSize(width, height)
69+}
70+
71+// Len returns the number of items in the chat list.
72+func (m *Chat) Len() int {
73+ return m.list.Len()
74+}
75+
76+// SetMessages sets the chat messages to the provided list of message items.
77+func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
78+ m.idInxMap = make(map[string]int)
79+ m.pausedAnimations = make(map[string]struct{})
80+
81+ items := make([]list.Item, len(msgs))
82+ for i, msg := range msgs {
83+ m.idInxMap[msg.ID()] = i
84+ // Register nested tool IDs for tools that contain nested tools.
85+ if container, ok := msg.(chat.NestedToolContainer); ok {
86+ for _, nested := range container.NestedTools() {
87+ m.idInxMap[nested.ID()] = i
88+ }
89+ }
90+ items[i] = msg
91+ }
92+ m.list.SetItems(items...)
93+ m.list.ScrollToBottom()
94+}
95+
96+// AppendMessages appends a new message item to the chat list.
97+func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
98+ items := make([]list.Item, len(msgs))
99+ indexOffset := m.list.Len()
100+ for i, msg := range msgs {
101+ m.idInxMap[msg.ID()] = indexOffset + i
102+ // Register nested tool IDs for tools that contain nested tools.
103+ if container, ok := msg.(chat.NestedToolContainer); ok {
104+ for _, nested := range container.NestedTools() {
105+ m.idInxMap[nested.ID()] = indexOffset + i
106+ }
107+ }
108+ items[i] = msg
109+ }
110+ m.list.AppendItems(items...)
111+}
112+
113+// UpdateNestedToolIDs updates the ID map for nested tools within a container.
114+// Call this after modifying nested tools to ensure animations work correctly.
115+func (m *Chat) UpdateNestedToolIDs(containerID string) {
116+ idx, ok := m.idInxMap[containerID]
117+ if !ok {
118+ return
119+ }
120+
121+ item, ok := m.list.ItemAt(idx).(chat.MessageItem)
122+ if !ok {
123+ return
124+ }
125+
126+ container, ok := item.(chat.NestedToolContainer)
127+ if !ok {
128+ return
129+ }
130+
131+ // Register all nested tool IDs to point to the container's index.
132+ for _, nested := range container.NestedTools() {
133+ m.idInxMap[nested.ID()] = idx
134+ }
135+}
136+
137+// Animate animates items in the chat list. Only propagates animation messages
138+// to visible items to save CPU. When items are not visible, their animation ID
139+// is tracked so it can be restarted when they become visible again.
140+func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
141+ idx, ok := m.idInxMap[msg.ID]
142+ if !ok {
143+ return nil
144+ }
145+
146+ animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
147+ if !ok {
148+ return nil
149+ }
150+
151+ // Check if item is currently visible.
152+ startIdx, endIdx := m.list.VisibleItemIndices()
153+ isVisible := idx >= startIdx && idx <= endIdx
154+
155+ if !isVisible {
156+ // Item not visible - pause animation by not propagating.
157+ // Track it so we can restart when it becomes visible.
158+ m.pausedAnimations[msg.ID] = struct{}{}
159+ return nil
160+ }
161+
162+ // Item is visible - remove from paused set and animate.
163+ delete(m.pausedAnimations, msg.ID)
164+ return animatable.Animate(msg)
165+}
166+
167+// RestartPausedVisibleAnimations restarts animations for items that were paused
168+// due to being scrolled out of view but are now visible again.
169+func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
170+ if len(m.pausedAnimations) == 0 {
171+ return nil
172+ }
173+
174+ startIdx, endIdx := m.list.VisibleItemIndices()
175+ var cmds []tea.Cmd
176+
177+ for id := range m.pausedAnimations {
178+ idx, ok := m.idInxMap[id]
179+ if !ok {
180+ // Item no longer exists.
181+ delete(m.pausedAnimations, id)
182+ continue
183+ }
184+
185+ if idx >= startIdx && idx <= endIdx {
186+ // Item is now visible - restart its animation.
187+ if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
188+ if cmd := animatable.StartAnimation(); cmd != nil {
189+ cmds = append(cmds, cmd)
190+ }
191+ }
192+ delete(m.pausedAnimations, id)
193+ }
194+ }
195+
196+ if len(cmds) == 0 {
197+ return nil
198+ }
199+ return tea.Batch(cmds...)
200+}
201+
202+// Focus sets the focus state of the chat component.
203+func (m *Chat) Focus() {
204+ m.list.Focus()
205+}
206+
207+// Blur removes the focus state from the chat component.
208+func (m *Chat) Blur() {
209+ m.list.Blur()
210+}
211+
212+// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
213+// any paused animations that are now visible.
214+func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
215+ m.list.ScrollToTop()
216+ return m.RestartPausedVisibleAnimations()
217+}
218+
219+// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
220+// restart any paused animations that are now visible.
221+func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
222+ m.list.ScrollToBottom()
223+ return m.RestartPausedVisibleAnimations()
224+}
225+
226+// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
227+// a command to restart any paused animations that are now visible.
228+func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
229+ m.list.ScrollBy(lines)
230+ return m.RestartPausedVisibleAnimations()
231+}
232+
233+// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
234+// command to restart any paused animations that are now visible.
235+func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
236+ m.list.ScrollToSelected()
237+ return m.RestartPausedVisibleAnimations()
238+}
239+
240+// SelectedItemInView returns whether the selected item is currently in view.
241+func (m *Chat) SelectedItemInView() bool {
242+ return m.list.SelectedItemInView()
243+}
244+
245+func (m *Chat) isSelectable(index int) bool {
246+ item := m.list.ItemAt(index)
247+ if item == nil {
248+ return false
249+ }
250+ _, ok := item.(list.Focusable)
251+ return ok
252+}
253+
254+// SetSelected sets the selected message index in the chat list.
255+func (m *Chat) SetSelected(index int) {
256+ m.list.SetSelected(index)
257+ if index < 0 || index >= m.list.Len() {
258+ return
259+ }
260+ for {
261+ if m.isSelectable(m.list.Selected()) {
262+ return
263+ }
264+ if m.list.SelectNext() {
265+ continue
266+ }
267+ // If we're at the end and the last item isn't selectable, walk backwards
268+ // to find the nearest selectable item.
269+ for {
270+ if !m.list.SelectPrev() {
271+ return
272+ }
273+ if m.isSelectable(m.list.Selected()) {
274+ return
275+ }
276+ }
277+ }
278+}
279+
280+// SelectPrev selects the previous message in the chat list.
281+func (m *Chat) SelectPrev() {
282+ for {
283+ if !m.list.SelectPrev() {
284+ return
285+ }
286+ if m.isSelectable(m.list.Selected()) {
287+ return
288+ }
289+ }
290+}
291+
292+// SelectNext selects the next message in the chat list.
293+func (m *Chat) SelectNext() {
294+ for {
295+ if !m.list.SelectNext() {
296+ return
297+ }
298+ if m.isSelectable(m.list.Selected()) {
299+ return
300+ }
301+ }
302+}
303+
304+// SelectFirst selects the first message in the chat list.
305+func (m *Chat) SelectFirst() {
306+ if !m.list.SelectFirst() {
307+ return
308+ }
309+ if m.isSelectable(m.list.Selected()) {
310+ return
311+ }
312+ for {
313+ if !m.list.SelectNext() {
314+ return
315+ }
316+ if m.isSelectable(m.list.Selected()) {
317+ return
318+ }
319+ }
320+}
321+
322+// SelectLast selects the last message in the chat list.
323+func (m *Chat) SelectLast() {
324+ if !m.list.SelectLast() {
325+ return
326+ }
327+ if m.isSelectable(m.list.Selected()) {
328+ return
329+ }
330+ for {
331+ if !m.list.SelectPrev() {
332+ return
333+ }
334+ if m.isSelectable(m.list.Selected()) {
335+ return
336+ }
337+ }
338+}
339+
340+// SelectFirstInView selects the first message currently in view.
341+func (m *Chat) SelectFirstInView() {
342+ startIdx, endIdx := m.list.VisibleItemIndices()
343+ for i := startIdx; i <= endIdx; i++ {
344+ if m.isSelectable(i) {
345+ m.list.SetSelected(i)
346+ return
347+ }
348+ }
349+}
350+
351+// SelectLastInView selects the last message currently in view.
352+func (m *Chat) SelectLastInView() {
353+ startIdx, endIdx := m.list.VisibleItemIndices()
354+ for i := endIdx; i >= startIdx; i-- {
355+ if m.isSelectable(i) {
356+ m.list.SetSelected(i)
357+ return
358+ }
359+ }
360+}
361+
362+// ClearMessages removes all messages from the chat list.
363+func (m *Chat) ClearMessages() {
364+ m.idInxMap = make(map[string]int)
365+ m.pausedAnimations = make(map[string]struct{})
366+ m.list.SetItems()
367+ m.ClearMouse()
368+}
369+
370+// RemoveMessage removes a message from the chat list by its ID.
371+func (m *Chat) RemoveMessage(id string) {
372+ idx, ok := m.idInxMap[id]
373+ if !ok {
374+ return
375+ }
376+
377+ // Remove from list
378+ m.list.RemoveItem(idx)
379+
380+ // Remove from index map
381+ delete(m.idInxMap, id)
382+
383+ // Rebuild index map for all items after the removed one
384+ for i := idx; i < m.list.Len(); i++ {
385+ if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
386+ m.idInxMap[item.ID()] = i
387+ }
388+ }
389+
390+ // Clean up any paused animations for this message
391+ delete(m.pausedAnimations, id)
392+}
393+
394+// MessageItem returns the message item with the given ID, or nil if not found.
395+func (m *Chat) MessageItem(id string) chat.MessageItem {
396+ idx, ok := m.idInxMap[id]
397+ if !ok {
398+ return nil
399+ }
400+ item, ok := m.list.ItemAt(idx).(chat.MessageItem)
401+ if !ok {
402+ return nil
403+ }
404+ return item
405+}
406+
407+// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
408+func (m *Chat) ToggleExpandedSelectedItem() {
409+ if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
410+ expandable.ToggleExpanded()
411+ }
412+}
413+
414+// HandleMouseDown handles mouse down events for the chat component.
415+func (m *Chat) HandleMouseDown(x, y int) bool {
416+ if m.list.Len() == 0 {
417+ return false
418+ }
419+
420+ itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
421+ if itemIdx < 0 {
422+ return false
423+ }
424+ if !m.isSelectable(itemIdx) {
425+ return false
426+ }
427+
428+ m.mouseDown = true
429+ m.mouseDownItem = itemIdx
430+ m.mouseDownX = x
431+ m.mouseDownY = itemY
432+ m.mouseDragItem = itemIdx
433+ m.mouseDragX = x
434+ m.mouseDragY = itemY
435+
436+ // Select the item that was clicked
437+ m.list.SetSelected(itemIdx)
438+
439+ if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
440+ return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
441+ }
442+
443+ return true
444+}
445+
446+// HandleMouseUp handles mouse up events for the chat component.
447+func (m *Chat) HandleMouseUp(x, y int) bool {
448+ if !m.mouseDown {
449+ return false
450+ }
451+
452+ m.mouseDown = false
453+ return true
454+}
455+
456+// HandleMouseDrag handles mouse drag events for the chat component.
457+func (m *Chat) HandleMouseDrag(x, y int) bool {
458+ if !m.mouseDown {
459+ return false
460+ }
461+
462+ if m.list.Len() == 0 {
463+ return false
464+ }
465+
466+ itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
467+ if itemIdx < 0 {
468+ return false
469+ }
470+
471+ m.mouseDragItem = itemIdx
472+ m.mouseDragX = x
473+ m.mouseDragY = itemY
474+
475+ return true
476+}
477+
478+// HasHighlight returns whether there is currently highlighted content.
479+func (m *Chat) HasHighlight() bool {
480+ startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
481+ return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
482+}
483+
484+// HighlighContent returns the currently highlighted content based on the mouse
485+// selection. It returns an empty string if no content is highlighted.
486+func (m *Chat) HighlighContent() string {
487+ startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
488+ if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
489+ return ""
490+ }
491+
492+ var sb strings.Builder
493+ for i := startItemIdx; i <= endItemIdx; i++ {
494+ item := m.list.ItemAt(i)
495+ if hi, ok := item.(list.Highlightable); ok {
496+ startLine, startCol, endLine, endCol := hi.Highlight()
497+ listWidth := m.list.Width()
498+ var rendered string
499+ if rr, ok := item.(list.RawRenderable); ok {
500+ rendered = rr.RawRender(listWidth)
501+ } else {
502+ rendered = item.Render(listWidth)
503+ }
504+ sb.WriteString(list.HighlightContent(
505+ rendered,
506+ uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
507+ startLine,
508+ startCol,
509+ endLine,
510+ endCol,
511+ ))
512+ sb.WriteString(strings.Repeat("\n", m.list.Gap()))
513+ }
514+ }
515+
516+ return strings.TrimSpace(sb.String())
517+}
518+
519+// ClearMouse clears the current mouse interaction state.
520+func (m *Chat) ClearMouse() {
521+ m.mouseDown = false
522+ m.mouseDownItem = -1
523+ m.mouseDragItem = -1
524+}
525+
526+// applyHighlightRange applies the current highlight range to the chat items.
527+func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
528+ if hi, ok := item.(list.Highlightable); ok {
529+ // Apply highlight
530+ startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
531+ sLine, sCol, eLine, eCol := -1, -1, -1, -1
532+ if idx >= startItemIdx && idx <= endItemIdx {
533+ if idx == startItemIdx && idx == endItemIdx {
534+ // Single item selection
535+ sLine = startLine
536+ sCol = startCol
537+ eLine = endLine
538+ eCol = endCol
539+ } else if idx == startItemIdx {
540+ // First item - from start position to end of item
541+ sLine = startLine
542+ sCol = startCol
543+ eLine = -1
544+ eCol = -1
545+ } else if idx == endItemIdx {
546+ // Last item - from start of item to end position
547+ sLine = 0
548+ sCol = 0
549+ eLine = endLine
550+ eCol = endCol
551+ } else {
552+ // Middle item - fully highlighted
553+ sLine = 0
554+ sCol = 0
555+ eLine = -1
556+ eCol = -1
557+ }
558+ }
559+
560+ hi.SetHighlight(sLine, sCol, eLine, eCol)
561+ return hi.(list.Item)
562+ }
563+
564+ return item
565+}
566+
567+// getHighlightRange returns the current highlight range.
568+func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
569+ if m.mouseDownItem < 0 {
570+ return -1, -1, -1, -1, -1, -1
571+ }
572+
573+ downItemIdx := m.mouseDownItem
574+ dragItemIdx := m.mouseDragItem
575+
576+ // Determine selection direction
577+ draggingDown := dragItemIdx > downItemIdx ||
578+ (dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
579+ (dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
580+
581+ if draggingDown {
582+ // Normal forward selection
583+ startItemIdx = downItemIdx
584+ startLine = m.mouseDownY
585+ startCol = m.mouseDownX
586+ endItemIdx = dragItemIdx
587+ endLine = m.mouseDragY
588+ endCol = m.mouseDragX
589+ } else {
590+ // Backward selection (dragging up)
591+ startItemIdx = dragItemIdx
592+ startLine = m.mouseDragY
593+ startCol = m.mouseDragX
594+ endItemIdx = downItemIdx
595+ endLine = m.mouseDownY
596+ endCol = m.mouseDownX
597+ }
598+
599+ return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
600+}
internal/ui/model/header.gonew file+112
@@ -0,0 +1,112 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/csync"
10+ "github.com/charmbracelet/crush/internal/fsext"
11+ "github.com/charmbracelet/crush/internal/lsp"
12+ "github.com/charmbracelet/crush/internal/session"
13+ "github.com/charmbracelet/crush/internal/ui/common"
14+ "github.com/charmbracelet/crush/internal/ui/styles"
15+ "github.com/charmbracelet/x/ansi"
16+)
17+
18+const (
19+ headerDiag = "╱"
20+ minHeaderDiags = 3
21+ leftPadding = 1
22+ rightPadding = 1
23+)
24+
25+// renderCompactHeader renders the compact header for the given session.
26+func renderCompactHeader(
27+ com *common.Common,
28+ session *session.Session,
29+ lspClients *csync.Map[string, *lsp.Client],
30+ detailsOpen bool,
31+ width int,
32+) string {
33+ if session == nil || session.ID == "" {
34+ return ""
35+ }
36+
37+ t := com.Styles
38+
39+ var b strings.Builder
40+
41+ b.WriteString(t.Header.Charm.Render("Charm™"))
42+ b.WriteString(" ")
43+ b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary))
44+ b.WriteString(" ")
45+
46+ availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
47+ details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth)
48+
49+ remainingWidth := width -
50+ lipgloss.Width(b.String()) -
51+ lipgloss.Width(details) -
52+ leftPadding -
53+ rightPadding
54+
55+ if remainingWidth > 0 {
56+ b.WriteString(t.Header.Diagonals.Render(
57+ strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)),
58+ ))
59+ b.WriteString(" ")
60+ }
61+
62+ b.WriteString(details)
63+
64+ return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
65+}
66+
67+// renderHeaderDetails renders the details section of the header.
68+func renderHeaderDetails(
69+ com *common.Common,
70+ session *session.Session,
71+ lspClients *csync.Map[string, *lsp.Client],
72+ detailsOpen bool,
73+ availWidth int,
74+) string {
75+ t := com.Styles
76+
77+ var parts []string
78+
79+ errorCount := 0
80+ for l := range lspClients.Seq() {
81+ errorCount += l.GetDiagnosticCounts().Error
82+ }
83+
84+ if errorCount > 0 {
85+ parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
86+ }
87+
88+ agentCfg := config.Get().Agents[config.AgentCoder]
89+ model := config.Get().GetModelByType(agentCfg.Model)
90+ percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
91+ formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
92+ parts = append(parts, formattedPercentage)
93+
94+ const keystroke = "ctrl+d"
95+ if detailsOpen {
96+ parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close"))
97+ } else {
98+ parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open "))
99+ }
100+
101+ dot := t.Header.Separator.Render(" • ")
102+ metadata := strings.Join(parts, dot)
103+ metadata = dot + metadata
104+
105+ const dirTrimLimit = 4
106+ cfg := com.Config()
107+ cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit)
108+ cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
109+ cwd = t.Header.WorkingDir.Render(cwd)
110+
111+ return cwd + metadata
112+}
internal/ui/model/keys.gonew file+246
@@ -0,0 +1,246 @@
1+package model
2+
3+import "charm.land/bubbles/v2/key"
4+
5+type KeyMap struct {
6+ Editor struct {
7+ AddFile key.Binding
8+ SendMessage key.Binding
9+ OpenEditor key.Binding
10+ Newline key.Binding
11+ AddImage key.Binding
12+ MentionFile key.Binding
13+
14+ // Attachments key maps
15+ AttachmentDeleteMode key.Binding
16+ Escape key.Binding
17+ DeleteAllAttachments key.Binding
18+ }
19+
20+ Chat struct {
21+ NewSession key.Binding
22+ AddAttachment key.Binding
23+ Cancel key.Binding
24+ Tab key.Binding
25+ Details key.Binding
26+ TogglePills key.Binding
27+ PillLeft key.Binding
28+ PillRight key.Binding
29+ Down key.Binding
30+ Up key.Binding
31+ UpDown key.Binding
32+ DownOneItem key.Binding
33+ UpOneItem key.Binding
34+ UpDownOneItem key.Binding
35+ PageDown key.Binding
36+ PageUp key.Binding
37+ HalfPageDown key.Binding
38+ HalfPageUp key.Binding
39+ Home key.Binding
40+ End key.Binding
41+ Copy key.Binding
42+ ClearHighlight key.Binding
43+ Expand key.Binding
44+ }
45+
46+ Initialize struct {
47+ Yes,
48+ No,
49+ Enter,
50+ Switch key.Binding
51+ }
52+
53+ // Global key maps
54+ Quit key.Binding
55+ Help key.Binding
56+ Commands key.Binding
57+ Models key.Binding
58+ Suspend key.Binding
59+ Sessions key.Binding
60+ Tab key.Binding
61+}
62+
63+func DefaultKeyMap() KeyMap {
64+ km := KeyMap{
65+ Quit: key.NewBinding(
66+ key.WithKeys("ctrl+c"),
67+ key.WithHelp("ctrl+c", "quit"),
68+ ),
69+ Help: key.NewBinding(
70+ key.WithKeys("ctrl+g"),
71+ key.WithHelp("ctrl+g", "more"),
72+ ),
73+ Commands: key.NewBinding(
74+ key.WithKeys("ctrl+p"),
75+ key.WithHelp("ctrl+p", "commands"),
76+ ),
77+ Models: key.NewBinding(
78+ key.WithKeys("ctrl+m", "ctrl+l"),
79+ key.WithHelp("ctrl+l", "models"),
80+ ),
81+ Suspend: key.NewBinding(
82+ key.WithKeys("ctrl+z"),
83+ key.WithHelp("ctrl+z", "suspend"),
84+ ),
85+ Sessions: key.NewBinding(
86+ key.WithKeys("ctrl+s"),
87+ key.WithHelp("ctrl+s", "sessions"),
88+ ),
89+ Tab: key.NewBinding(
90+ key.WithKeys("tab"),
91+ key.WithHelp("tab", "change focus"),
92+ ),
93+ }
94+
95+ km.Editor.AddFile = key.NewBinding(
96+ key.WithKeys("/"),
97+ key.WithHelp("/", "add file"),
98+ )
99+ km.Editor.SendMessage = key.NewBinding(
100+ key.WithKeys("enter"),
101+ key.WithHelp("enter", "send"),
102+ )
103+ km.Editor.OpenEditor = key.NewBinding(
104+ key.WithKeys("ctrl+o"),
105+ key.WithHelp("ctrl+o", "open editor"),
106+ )
107+ km.Editor.Newline = key.NewBinding(
108+ key.WithKeys("shift+enter", "ctrl+j"),
109+ // "ctrl+j" is a common keybinding for newline in many editors. If
110+ // the terminal supports "shift+enter", we substitute the help tex
111+ // to reflect that.
112+ key.WithHelp("ctrl+j", "newline"),
113+ )
114+ km.Editor.AddImage = key.NewBinding(
115+ key.WithKeys("ctrl+f"),
116+ key.WithHelp("ctrl+f", "add image"),
117+ )
118+ km.Editor.MentionFile = key.NewBinding(
119+ key.WithKeys("@"),
120+ key.WithHelp("@", "mention file"),
121+ )
122+ km.Editor.AttachmentDeleteMode = key.NewBinding(
123+ key.WithKeys("ctrl+r"),
124+ key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
125+ )
126+ km.Editor.Escape = key.NewBinding(
127+ key.WithKeys("esc", "alt+esc"),
128+ key.WithHelp("esc", "cancel delete mode"),
129+ )
130+ km.Editor.DeleteAllAttachments = key.NewBinding(
131+ key.WithKeys("r"),
132+ key.WithHelp("ctrl+r+r", "delete all attachments"),
133+ )
134+
135+ km.Chat.NewSession = key.NewBinding(
136+ key.WithKeys("ctrl+n"),
137+ key.WithHelp("ctrl+n", "new session"),
138+ )
139+ km.Chat.AddAttachment = key.NewBinding(
140+ key.WithKeys("ctrl+f"),
141+ key.WithHelp("ctrl+f", "add attachment"),
142+ )
143+ km.Chat.Cancel = key.NewBinding(
144+ key.WithKeys("esc", "alt+esc"),
145+ key.WithHelp("esc", "cancel"),
146+ )
147+ km.Chat.Tab = key.NewBinding(
148+ key.WithKeys("tab"),
149+ key.WithHelp("tab", "change focus"),
150+ )
151+ km.Chat.Details = key.NewBinding(
152+ key.WithKeys("ctrl+d"),
153+ key.WithHelp("ctrl+d", "toggle details"),
154+ )
155+ km.Chat.TogglePills = key.NewBinding(
156+ key.WithKeys("ctrl+space"),
157+ key.WithHelp("ctrl+space", "toggle tasks"),
158+ )
159+ km.Chat.PillLeft = key.NewBinding(
160+ key.WithKeys("left"),
161+ key.WithHelp("←/→", "switch section"),
162+ )
163+ km.Chat.PillRight = key.NewBinding(
164+ key.WithKeys("right"),
165+ key.WithHelp("←/→", "switch section"),
166+ )
167+
168+ km.Chat.Down = key.NewBinding(
169+ key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
170+ key.WithHelp("↓", "down"),
171+ )
172+ km.Chat.Up = key.NewBinding(
173+ key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
174+ key.WithHelp("↑", "up"),
175+ )
176+ km.Chat.UpDown = key.NewBinding(
177+ key.WithKeys("up", "down"),
178+ key.WithHelp("↑↓", "scroll"),
179+ )
180+ km.Chat.UpOneItem = key.NewBinding(
181+ key.WithKeys("shift+up", "K"),
182+ key.WithHelp("shift+↑", "up one item"),
183+ )
184+ km.Chat.DownOneItem = key.NewBinding(
185+ key.WithKeys("shift+down", "J"),
186+ key.WithHelp("shift+↓", "down one item"),
187+ )
188+ km.Chat.UpDownOneItem = key.NewBinding(
189+ key.WithKeys("shift+up", "shift+down"),
190+ key.WithHelp("shift+↑↓", "scroll one item"),
191+ )
192+ km.Chat.HalfPageDown = key.NewBinding(
193+ key.WithKeys("d"),
194+ key.WithHelp("d", "half page down"),
195+ )
196+ km.Chat.PageDown = key.NewBinding(
197+ key.WithKeys("pgdown", " ", "f"),
198+ key.WithHelp("f/pgdn", "page down"),
199+ )
200+ km.Chat.PageUp = key.NewBinding(
201+ key.WithKeys("pgup", "b"),
202+ key.WithHelp("b/pgup", "page up"),
203+ )
204+ km.Chat.HalfPageUp = key.NewBinding(
205+ key.WithKeys("u"),
206+ key.WithHelp("u", "half page up"),
207+ )
208+ km.Chat.Home = key.NewBinding(
209+ key.WithKeys("g", "home"),
210+ key.WithHelp("g", "home"),
211+ )
212+ km.Chat.End = key.NewBinding(
213+ key.WithKeys("G", "end"),
214+ key.WithHelp("G", "end"),
215+ )
216+ km.Chat.Copy = key.NewBinding(
217+ key.WithKeys("c", "y", "C", "Y"),
218+ key.WithHelp("c/y", "copy"),
219+ )
220+ km.Chat.ClearHighlight = key.NewBinding(
221+ key.WithKeys("esc", "alt+esc"),
222+ key.WithHelp("esc", "clear selection"),
223+ )
224+ km.Chat.Expand = key.NewBinding(
225+ key.WithKeys("space"),
226+ key.WithHelp("space", "expand/collapse"),
227+ )
228+ km.Initialize.Yes = key.NewBinding(
229+ key.WithKeys("y", "Y"),
230+ key.WithHelp("y", "yes"),
231+ )
232+ km.Initialize.No = key.NewBinding(
233+ key.WithKeys("n", "N", "esc", "alt+esc"),
234+ key.WithHelp("n", "no"),
235+ )
236+ km.Initialize.Switch = key.NewBinding(
237+ key.WithKeys("left", "right", "tab"),
238+ key.WithHelp("tab", "switch"),
239+ )
240+ km.Initialize.Enter = key.NewBinding(
241+ key.WithKeys("enter"),
242+ key.WithHelp("enter", "select"),
243+ )
244+
245+ return km
246+}
internal/ui/model/landing.gonew file+50
@@ -0,0 +1,50 @@
1+package model
2+
3+import (
4+ "charm.land/lipgloss/v2"
5+ "github.com/charmbracelet/crush/internal/agent"
6+ "github.com/charmbracelet/crush/internal/ui/common"
7+ uv "github.com/charmbracelet/ultraviolet"
8+)
9+
10+// selectedLargeModel returns the currently selected large language model from
11+// the agent coordinator, if one exists.
12+func (m *UI) selectedLargeModel() *agent.Model {
13+ if m.com.App.AgentCoordinator != nil {
14+ model := m.com.App.AgentCoordinator.Model()
15+ return &model
16+ }
17+ return nil
18+}
19+
20+// landingView renders the landing page view showing the current working
21+// directory, model information, and LSP/MCP status in a two-column layout.
22+func (m *UI) landingView() string {
23+ t := m.com.Styles
24+ width := m.layout.main.Dx()
25+ cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
26+
27+ parts := []string{
28+ cwd,
29+ }
30+
31+ parts = append(parts, "", m.modelInfo(width))
32+ infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
33+
34+ _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1))
35+
36+ mcpLspSectionWidth := min(30, (width-1)/2)
37+
38+ lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
39+ mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
40+
41+ content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection)
42+
43+ return lipgloss.NewStyle().
44+ Width(width).
45+ Height(m.layout.main.Dy() - 1).
46+ PaddingTop(1).
47+ Render(
48+ lipgloss.JoinVertical(lipgloss.Left, infoSection, "", content),
49+ )
50+}
internal/ui/model/lsp.gonew file+118
@@ -0,0 +1,118 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/app"
9+ "github.com/charmbracelet/crush/internal/lsp"
10+ "github.com/charmbracelet/crush/internal/ui/common"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
13+)
14+
15+// LSPInfo wraps LSP client information with diagnostic counts by severity.
16+type LSPInfo struct {
17+ app.LSPClientInfo
18+ Diagnostics map[protocol.DiagnosticSeverity]int
19+}
20+
21+// lspInfo renders the LSP status section showing active LSP clients and their
22+// diagnostic counts.
23+func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
24+ var lsps []LSPInfo
25+ t := m.com.Styles
26+
27+ for _, state := range m.lspStates {
28+ client, ok := m.com.App.LSPClients.Get(state.Name)
29+ if !ok {
30+ continue
31+ }
32+ counts := client.GetDiagnosticCounts()
33+ lspErrs := map[protocol.DiagnosticSeverity]int{
34+ protocol.SeverityError: counts.Error,
35+ protocol.SeverityWarning: counts.Warning,
36+ protocol.SeverityHint: counts.Hint,
37+ protocol.SeverityInformation: counts.Information,
38+ }
39+
40+ lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
41+ }
42+ title := t.Subtle.Render("LSPs")
43+ if isSection {
44+ title = common.Section(t, title, width)
45+ }
46+ list := t.Subtle.Render("None")
47+ if len(lsps) > 0 {
48+ list = lspList(t, lsps, width, maxItems)
49+ }
50+
51+ return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
52+}
53+
54+// lspDiagnostics formats diagnostic counts with appropriate icons and colors.
55+func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string {
56+ errs := []string{}
57+ if diagnostics[protocol.SeverityError] > 0 {
58+ errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s %d", styles.ErrorIcon, diagnostics[protocol.SeverityError])))
59+ }
60+ if diagnostics[protocol.SeverityWarning] > 0 {
61+ errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s %d", styles.WarningIcon, diagnostics[protocol.SeverityWarning])))
62+ }
63+ if diagnostics[protocol.SeverityHint] > 0 {
64+ errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s %d", styles.HintIcon, diagnostics[protocol.SeverityHint])))
65+ }
66+ if diagnostics[protocol.SeverityInformation] > 0 {
67+ errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s %d", styles.InfoIcon, diagnostics[protocol.SeverityInformation])))
68+ }
69+ return strings.Join(errs, " ")
70+}
71+
72+// lspList renders a list of LSP clients with their status and diagnostics,
73+// truncating to maxItems if needed.
74+func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
75+ if maxItems <= 0 {
76+ return ""
77+ }
78+ var renderedLsps []string
79+ for _, l := range lsps {
80+ var icon string
81+ title := l.Name
82+ var description string
83+ var diagnostics string
84+ switch l.State {
85+ case lsp.StateStarting:
86+ icon = t.ItemBusyIcon.String()
87+ description = t.Subtle.Render("starting...")
88+ case lsp.StateReady:
89+ icon = t.ItemOnlineIcon.String()
90+ diagnostics = lspDiagnostics(t, l.Diagnostics)
91+ case lsp.StateError:
92+ icon = t.ItemErrorIcon.String()
93+ description = t.Subtle.Render("error")
94+ if l.Error != nil {
95+ description = t.Subtle.Render(fmt.Sprintf("error: %s", l.Error.Error()))
96+ }
97+ case lsp.StateDisabled:
98+ icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
99+ description = t.Subtle.Render("inactive")
100+ default:
101+ icon = t.ItemOfflineIcon.String()
102+ }
103+ renderedLsps = append(renderedLsps, common.Status(t, common.StatusOpts{
104+ Icon: icon,
105+ Title: title,
106+ Description: description,
107+ ExtraContent: diagnostics,
108+ }, width))
109+ }
110+
111+ if len(renderedLsps) > maxItems {
112+ visibleItems := renderedLsps[:maxItems-1]
113+ remaining := len(renderedLsps) - maxItems
114+ visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
115+ return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
116+ }
117+ return lipgloss.JoinVertical(lipgloss.Left, renderedLsps...)
118+}
internal/ui/model/mcp.gonew file+98
@@ -0,0 +1,98 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
9+ "github.com/charmbracelet/crush/internal/ui/common"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+)
12+
13+// mcpInfo renders the MCP status section showing active MCP clients and their
14+// tool/prompt counts.
15+func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
16+ var mcps []mcp.ClientInfo
17+ t := m.com.Styles
18+
19+ for _, mcp := range m.com.Config().MCP.Sorted() {
20+ if state, ok := m.mcpStates[mcp.Name]; ok {
21+ mcps = append(mcps, state)
22+ }
23+ }
24+
25+ title := t.Subtle.Render("MCPs")
26+ if isSection {
27+ title = common.Section(t, title, width)
28+ }
29+ list := t.Subtle.Render("None")
30+ if len(mcps) > 0 {
31+ list = mcpList(t, mcps, width, maxItems)
32+ }
33+
34+ return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
35+}
36+
37+// mcpCounts formats tool and prompt counts for display.
38+func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
39+ parts := []string{}
40+ if counts.Tools > 0 {
41+ parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d tools", counts.Tools)))
42+ }
43+ if counts.Prompts > 0 {
44+ parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts)))
45+ }
46+ return strings.Join(parts, " ")
47+}
48+
49+// mcpList renders a list of MCP clients with their status and counts,
50+// truncating to maxItems if needed.
51+func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
52+ if maxItems <= 0 {
53+ return ""
54+ }
55+ var renderedMcps []string
56+
57+ for _, m := range mcps {
58+ var icon string
59+ title := m.Name
60+ var description string
61+ var extraContent string
62+
63+ switch m.State {
64+ case mcp.StateStarting:
65+ icon = t.ItemBusyIcon.String()
66+ description = t.Subtle.Render("starting...")
67+ case mcp.StateConnected:
68+ icon = t.ItemOnlineIcon.String()
69+ extraContent = mcpCounts(t, m.Counts)
70+ case mcp.StateError:
71+ icon = t.ItemErrorIcon.String()
72+ description = t.Subtle.Render("error")
73+ if m.Error != nil {
74+ description = t.Subtle.Render(fmt.Sprintf("error: %s", m.Error.Error()))
75+ }
76+ case mcp.StateDisabled:
77+ icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
78+ description = t.Subtle.Render("disabled")
79+ default:
80+ icon = t.ItemOfflineIcon.String()
81+ }
82+
83+ renderedMcps = append(renderedMcps, common.Status(t, common.StatusOpts{
84+ Icon: icon,
85+ Title: title,
86+ Description: description,
87+ ExtraContent: extraContent,
88+ }, width))
89+ }
90+
91+ if len(renderedMcps) > maxItems {
92+ visibleItems := renderedMcps[:maxItems-1]
93+ remaining := len(renderedMcps) - maxItems
94+ visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
95+ return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
96+ }
97+ return lipgloss.JoinVertical(lipgloss.Left, renderedMcps...)
98+}
internal/ui/model/onboarding.gonew file+101
@@ -0,0 +1,101 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+ "strings"
7+
8+ "charm.land/bubbles/v2/key"
9+ tea "charm.land/bubbletea/v2"
10+ "charm.land/lipgloss/v2"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/home"
13+ "github.com/charmbracelet/crush/internal/ui/common"
14+)
15+
16+// markProjectInitialized marks the current project as initialized in the config.
17+func (m *UI) markProjectInitialized() tea.Msg {
18+ // TODO: handle error so we show it in the tui footer
19+ err := config.MarkProjectInitialized()
20+ if err != nil {
21+ slog.Error(err.Error())
22+ }
23+ return nil
24+}
25+
26+// updateInitializeView handles keyboard input for the project initialization prompt.
27+func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
28+ switch {
29+ case key.Matches(msg, m.keyMap.Initialize.Enter):
30+ if m.onboarding.yesInitializeSelected {
31+ cmds = append(cmds, m.initializeProject())
32+ } else {
33+ cmds = append(cmds, m.skipInitializeProject())
34+ }
35+ case key.Matches(msg, m.keyMap.Initialize.Switch):
36+ m.onboarding.yesInitializeSelected = !m.onboarding.yesInitializeSelected
37+ case key.Matches(msg, m.keyMap.Initialize.Yes):
38+ cmds = append(cmds, m.initializeProject())
39+ case key.Matches(msg, m.keyMap.Initialize.No):
40+ cmds = append(cmds, m.skipInitializeProject())
41+ }
42+ return cmds
43+}
44+
45+// initializeProject starts project initialization and transitions to the landing view.
46+func (m *UI) initializeProject() tea.Cmd {
47+ // TODO: initialize the project
48+ // for now we just go to the landing page
49+ m.state = uiLanding
50+ m.focus = uiFocusEditor
51+ // TODO: actually send a message to the agent
52+ return m.markProjectInitialized
53+}
54+
55+// skipInitializeProject skips project initialization and transitions to the landing view.
56+func (m *UI) skipInitializeProject() tea.Cmd {
57+ // TODO: initialize the project
58+ m.state = uiLanding
59+ m.focus = uiFocusEditor
60+ // mark the project as initialized
61+ return m.markProjectInitialized
62+}
63+
64+// initializeView renders the project initialization prompt with Yes/No buttons.
65+func (m *UI) initializeView() string {
66+ cfg := m.com.Config()
67+ s := m.com.Styles.Initialize
68+ cwd := home.Short(cfg.WorkingDir())
69+ initFile := cfg.Options.InitializeAs
70+
71+ header := s.Header.Render("Would you like to initialize this project?")
72+ path := s.Accent.PaddingLeft(2).Render(cwd)
73+ 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))
74+ hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".")
75+ prompt := s.Content.Render("Would you like to initialize now?")
76+
77+ buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{
78+ {Text: "Yep!", Selected: m.onboarding.yesInitializeSelected},
79+ {Text: "Nope", Selected: !m.onboarding.yesInitializeSelected},
80+ }, " ")
81+
82+ // max width 60 so the text is compact
83+ width := min(m.layout.main.Dx(), 60)
84+
85+ return lipgloss.NewStyle().
86+ Width(width).
87+ Height(m.layout.main.Dy()).
88+ PaddingBottom(1).
89+ AlignVertical(lipgloss.Bottom).
90+ Render(strings.Join(
91+ []string{
92+ header,
93+ path,
94+ desc,
95+ hint,
96+ prompt,
97+ buttons,
98+ },
99+ "\n\n",
100+ ))
101+}
internal/ui/model/pills.gonew file+283
@@ -0,0 +1,283 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+ "github.com/charmbracelet/crush/internal/session"
10+ "github.com/charmbracelet/crush/internal/ui/chat"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+)
13+
14+// pillStyle returns the appropriate style for a pill based on focus state.
15+func pillStyle(focused, panelFocused bool, t *styles.Styles) lipgloss.Style {
16+ if !panelFocused || focused {
17+ return t.Pills.Focused
18+ }
19+ return t.Pills.Blurred
20+}
21+
22+const (
23+ // pillHeightWithBorder is the height of a pill including its border.
24+ pillHeightWithBorder = 3
25+ // maxTaskDisplayLength is the maximum length of a task name in the pill.
26+ maxTaskDisplayLength = 40
27+ // maxQueueDisplayLength is the maximum length of a queue item in the list.
28+ maxQueueDisplayLength = 60
29+)
30+
31+// pillSection represents which section of the pills panel is focused.
32+type pillSection int
33+
34+const (
35+ pillSectionTodos pillSection = iota
36+ pillSectionQueue
37+)
38+
39+// hasIncompleteTodos returns true if there are any non-completed todos.
40+func hasIncompleteTodos(todos []session.Todo) bool {
41+ for _, todo := range todos {
42+ if todo.Status != session.TodoStatusCompleted {
43+ return true
44+ }
45+ }
46+ return false
47+}
48+
49+// hasInProgressTodo returns true if there is at least one in-progress todo.
50+func hasInProgressTodo(todos []session.Todo) bool {
51+ for _, todo := range todos {
52+ if todo.Status == session.TodoStatusInProgress {
53+ return true
54+ }
55+ }
56+ return false
57+}
58+
59+// queuePill renders the queue count pill with gradient triangles.
60+func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string {
61+ if queue <= 0 {
62+ return ""
63+ }
64+ triangles := styles.ForegroundGrad(t, "▶▶▶▶▶▶▶▶▶", false, t.RedDark, t.Secondary)
65+ if queue < len(triangles) {
66+ triangles = triangles[:queue]
67+ }
68+
69+ content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
70+ return pillStyle(focused, panelFocused, t).Render(content)
71+}
72+
73+// todoPill renders the todo progress pill with optional spinner and task name.
74+func todoPill(todos []session.Todo, spinnerView string, focused, panelFocused bool, t *styles.Styles) string {
75+ if !hasIncompleteTodos(todos) {
76+ return ""
77+ }
78+
79+ completed := 0
80+ var currentTodo *session.Todo
81+ for i := range todos {
82+ switch todos[i].Status {
83+ case session.TodoStatusCompleted:
84+ completed++
85+ case session.TodoStatusInProgress:
86+ if currentTodo == nil {
87+ currentTodo = &todos[i]
88+ }
89+ }
90+ }
91+
92+ total := len(todos)
93+
94+ label := t.Base.Render("To-Do")
95+ progress := t.Muted.Render(fmt.Sprintf("%d/%d", completed, total))
96+
97+ var content string
98+ if panelFocused {
99+ content = fmt.Sprintf("%s %s", label, progress)
100+ } else if currentTodo != nil {
101+ taskText := currentTodo.Content
102+ if currentTodo.ActiveForm != "" {
103+ taskText = currentTodo.ActiveForm
104+ }
105+ if len(taskText) > maxTaskDisplayLength {
106+ taskText = taskText[:maxTaskDisplayLength-1] + "…"
107+ }
108+ task := t.Subtle.Render(taskText)
109+ content = fmt.Sprintf("%s %s %s %s", spinnerView, label, progress, task)
110+ } else {
111+ content = fmt.Sprintf("%s %s", label, progress)
112+ }
113+
114+ return pillStyle(focused, panelFocused, t).Render(content)
115+}
116+
117+// todoList renders the expanded todo list.
118+func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Styles, width int) string {
119+ return chat.FormatTodosList(t, sessionTodos, spinnerView, width)
120+}
121+
122+// queueList renders the expanded queue items list.
123+func queueList(queueItems []string, t *styles.Styles) string {
124+ if len(queueItems) == 0 {
125+ return ""
126+ }
127+
128+ var lines []string
129+ for _, item := range queueItems {
130+ text := item
131+ if len(text) > maxQueueDisplayLength {
132+ text = text[:maxQueueDisplayLength-1] + "…"
133+ }
134+ prefix := t.Pills.QueueItemPrefix.Render() + " "
135+ lines = append(lines, prefix+t.Muted.Render(text))
136+ }
137+
138+ return strings.Join(lines, "\n")
139+}
140+
141+// togglePillsExpanded toggles the pills panel expansion state.
142+func (m *UI) togglePillsExpanded() tea.Cmd {
143+ if !m.hasSession() {
144+ return nil
145+ }
146+ if m.layout.pills.Dy() > 0 {
147+ if cmd := m.chat.ScrollByAndAnimate(0); cmd != nil {
148+ return cmd
149+ }
150+ }
151+ hasPills := hasIncompleteTodos(m.session.Todos) || m.promptQueue > 0
152+ if !hasPills {
153+ return nil
154+ }
155+ m.pillsExpanded = !m.pillsExpanded
156+ if m.pillsExpanded {
157+ if hasIncompleteTodos(m.session.Todos) {
158+ m.focusedPillSection = pillSectionTodos
159+ } else {
160+ m.focusedPillSection = pillSectionQueue
161+ }
162+ }
163+ m.updateLayoutAndSize()
164+ return nil
165+}
166+
167+// switchPillSection changes focus between todo and queue sections.
168+func (m *UI) switchPillSection(dir int) tea.Cmd {
169+ if !m.pillsExpanded || !m.hasSession() {
170+ return nil
171+ }
172+ hasIncompleteTodos := hasIncompleteTodos(m.session.Todos)
173+ hasQueue := m.promptQueue > 0
174+
175+ if dir < 0 && m.focusedPillSection == pillSectionQueue && hasIncompleteTodos {
176+ m.focusedPillSection = pillSectionTodos
177+ m.updateLayoutAndSize()
178+ return nil
179+ }
180+ if dir > 0 && m.focusedPillSection == pillSectionTodos && hasQueue {
181+ m.focusedPillSection = pillSectionQueue
182+ m.updateLayoutAndSize()
183+ return nil
184+ }
185+ return nil
186+}
187+
188+// pillsAreaHeight calculates the total height needed for the pills area.
189+func (m *UI) pillsAreaHeight() int {
190+ if !m.hasSession() {
191+ return 0
192+ }
193+ hasIncomplete := hasIncompleteTodos(m.session.Todos)
194+ hasQueue := m.promptQueue > 0
195+ hasPills := hasIncomplete || hasQueue
196+ if !hasPills {
197+ return 0
198+ }
199+
200+ pillsAreaHeight := pillHeightWithBorder
201+ if m.pillsExpanded {
202+ if m.focusedPillSection == pillSectionTodos && hasIncomplete {
203+ pillsAreaHeight += len(m.session.Todos)
204+ } else if m.focusedPillSection == pillSectionQueue && hasQueue {
205+ pillsAreaHeight += m.promptQueue
206+ }
207+ }
208+ return pillsAreaHeight
209+}
210+
211+// renderPills renders the pills panel and stores it in m.pillsView.
212+func (m *UI) renderPills() {
213+ m.pillsView = ""
214+ if !m.hasSession() {
215+ return
216+ }
217+
218+ width := m.layout.pills.Dx()
219+ if width <= 0 {
220+ return
221+ }
222+
223+ paddingLeft := 3
224+ contentWidth := max(width-paddingLeft, 0)
225+
226+ hasIncomplete := hasIncompleteTodos(m.session.Todos)
227+ hasQueue := m.promptQueue > 0
228+
229+ if !hasIncomplete && !hasQueue {
230+ return
231+ }
232+
233+ t := m.com.Styles
234+ todosFocused := m.pillsExpanded && m.focusedPillSection == pillSectionTodos
235+ queueFocused := m.pillsExpanded && m.focusedPillSection == pillSectionQueue
236+
237+ inProgressIcon := t.Tool.TodoInProgressIcon.Render(styles.SpinnerIcon)
238+ if m.todoIsSpinning {
239+ inProgressIcon = m.todoSpinner.View()
240+ }
241+
242+ var pills []string
243+ if hasIncomplete {
244+ pills = append(pills, todoPill(m.session.Todos, inProgressIcon, todosFocused, m.pillsExpanded, t))
245+ }
246+ if hasQueue {
247+ pills = append(pills, queuePill(m.promptQueue, queueFocused, m.pillsExpanded, t))
248+ }
249+
250+ var expandedList string
251+ if m.pillsExpanded {
252+ if todosFocused && hasIncomplete {
253+ expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
254+ } else if queueFocused && hasQueue {
255+ if m.com.App != nil && m.com.App.AgentCoordinator != nil {
256+ queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
257+ expandedList = queueList(queueItems, t)
258+ }
259+ }
260+ }
261+
262+ if len(pills) == 0 {
263+ return
264+ }
265+
266+ pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
267+
268+ helpDesc := "open"
269+ if m.pillsExpanded {
270+ helpDesc = "close"
271+ }
272+ helpKey := t.Pills.HelpKey.Render("ctrl+space")
273+ helpText := t.Pills.HelpText.Render(helpDesc)
274+ helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
275+ pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
276+
277+ pillsArea := pillsRow
278+ if expandedList != "" {
279+ pillsArea = lipgloss.JoinVertical(lipgloss.Left, pillsRow, expandedList)
280+ }
281+
282+ m.pillsView = t.Pills.Area.MaxWidth(width).PaddingLeft(paddingLeft).Render(pillsArea)
283+}
internal/ui/model/session.gonew file+244
@@ -0,0 +1,244 @@
1+package model
2+
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "slices"
8+ "strings"
9+
10+ tea "charm.land/bubbletea/v2"
11+ "charm.land/lipgloss/v2"
12+ "github.com/charmbracelet/crush/internal/diff"
13+ "github.com/charmbracelet/crush/internal/fsext"
14+ "github.com/charmbracelet/crush/internal/history"
15+ "github.com/charmbracelet/crush/internal/session"
16+ "github.com/charmbracelet/crush/internal/ui/common"
17+ "github.com/charmbracelet/crush/internal/ui/styles"
18+ "github.com/charmbracelet/crush/internal/uiutil"
19+ "github.com/charmbracelet/x/ansi"
20+)
21+
22+// loadSessionMsg is a message indicating that a session and its files have
23+// been loaded.
24+type loadSessionMsg struct {
25+ session *session.Session
26+ files []SessionFile
27+}
28+
29+// SessionFile tracks the first and latest versions of a file in a session,
30+// along with the total additions and deletions.
31+type SessionFile struct {
32+ FirstVersion history.File
33+ LatestVersion history.File
34+ Additions int
35+ Deletions int
36+}
37+
38+// loadSession loads the session along with its associated files and computes
39+// the diff statistics (additions and deletions) for each file in the session.
40+// It returns a tea.Cmd that, when executed, fetches the session data and
41+// returns a sessionFilesLoadedMsg containing the processed session files.
42+func (m *UI) loadSession(sessionID string) tea.Cmd {
43+ return func() tea.Msg {
44+ session, err := m.com.App.Sessions.Get(context.Background(), sessionID)
45+ if err != nil {
46+ // TODO: better error handling
47+ return uiutil.ReportError(err)()
48+ }
49+
50+ files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
51+ if err != nil {
52+ // TODO: better error handling
53+ return uiutil.ReportError(err)()
54+ }
55+
56+ filesByPath := make(map[string][]history.File)
57+ for _, f := range files {
58+ filesByPath[f.Path] = append(filesByPath[f.Path], f)
59+ }
60+
61+ sessionFiles := make([]SessionFile, 0, len(filesByPath))
62+ for _, versions := range filesByPath {
63+ if len(versions) == 0 {
64+ continue
65+ }
66+
67+ first := versions[0]
68+ last := versions[0]
69+ for _, v := range versions {
70+ if v.Version < first.Version {
71+ first = v
72+ }
73+ if v.Version > last.Version {
74+ last = v
75+ }
76+ }
77+
78+ _, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
79+
80+ sessionFiles = append(sessionFiles, SessionFile{
81+ FirstVersion: first,
82+ LatestVersion: last,
83+ Additions: additions,
84+ Deletions: deletions,
85+ })
86+ }
87+
88+ slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
89+ if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
90+ return -1
91+ }
92+ if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
93+ return 1
94+ }
95+ return 0
96+ })
97+
98+ return loadSessionMsg{
99+ session: &session,
100+ files: sessionFiles,
101+ }
102+ }
103+}
104+
105+// handleFileEvent processes file change events and updates the session file
106+// list with new or updated file information.
107+func (m *UI) handleFileEvent(file history.File) tea.Cmd {
108+ if m.session == nil || file.SessionID != m.session.ID {
109+ return nil
110+ }
111+
112+ return func() tea.Msg {
113+ existingIdx := -1
114+ for i, sf := range m.sessionFiles {
115+ if sf.FirstVersion.Path == file.Path {
116+ existingIdx = i
117+ break
118+ }
119+ }
120+
121+ if existingIdx == -1 {
122+ newFiles := make([]SessionFile, 0, len(m.sessionFiles)+1)
123+ newFiles = append(newFiles, SessionFile{
124+ FirstVersion: file,
125+ LatestVersion: file,
126+ Additions: 0,
127+ Deletions: 0,
128+ })
129+ newFiles = append(newFiles, m.sessionFiles...)
130+
131+ return loadSessionMsg{
132+ session: m.session,
133+ files: newFiles,
134+ }
135+ }
136+
137+ updated := m.sessionFiles[existingIdx]
138+
139+ if file.Version < updated.FirstVersion.Version {
140+ updated.FirstVersion = file
141+ }
142+
143+ if file.Version > updated.LatestVersion.Version {
144+ updated.LatestVersion = file
145+ }
146+
147+ _, additions, deletions := diff.GenerateDiff(
148+ updated.FirstVersion.Content,
149+ updated.LatestVersion.Content,
150+ updated.FirstVersion.Path,
151+ )
152+ updated.Additions = additions
153+ updated.Deletions = deletions
154+
155+ newFiles := make([]SessionFile, 0, len(m.sessionFiles))
156+ newFiles = append(newFiles, updated)
157+ for i, sf := range m.sessionFiles {
158+ if i != existingIdx {
159+ newFiles = append(newFiles, sf)
160+ }
161+ }
162+
163+ return loadSessionMsg{
164+ session: m.session,
165+ files: newFiles,
166+ }
167+ }
168+}
169+
170+// filesInfo renders the modified files section for the sidebar, showing files
171+// with their addition/deletion counts.
172+func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
173+ t := m.com.Styles
174+
175+ title := t.Subtle.Render("Modified Files")
176+ if isSection {
177+ title = common.Section(t, "Modified Files", width)
178+ }
179+ list := t.Subtle.Render("None")
180+
181+ if len(m.sessionFiles) > 0 {
182+ list = fileList(t, cwd, m.sessionFiles, width, maxItems)
183+ }
184+
185+ return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
186+}
187+
188+// fileList renders a list of files with their diff statistics, truncating to
189+// maxItems and showing a "...and N more" message if needed.
190+func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
191+ if maxItems <= 0 {
192+ return ""
193+ }
194+ var renderedFiles []string
195+ filesShown := 0
196+
197+ var filesWithChanges []SessionFile
198+ for _, f := range files {
199+ if f.Additions == 0 && f.Deletions == 0 {
200+ continue
201+ }
202+ filesWithChanges = append(filesWithChanges, f)
203+ }
204+
205+ for _, f := range filesWithChanges {
206+ // Skip files with no changes
207+ if filesShown >= maxItems {
208+ break
209+ }
210+
211+ // Build stats string with colors
212+ var statusParts []string
213+ if f.Additions > 0 {
214+ statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions)))
215+ }
216+ if f.Deletions > 0 {
217+ statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions)))
218+ }
219+ extraContent := strings.Join(statusParts, " ")
220+
221+ // Format file path
222+ filePath := f.FirstVersion.Path
223+ if rel, err := filepath.Rel(cwd, filePath); err == nil {
224+ filePath = rel
225+ }
226+ filePath = fsext.DirTrim(filePath, 2)
227+ filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "…")
228+
229+ line := t.Files.Path.Render(filePath)
230+ if extraContent != "" {
231+ line = fmt.Sprintf("%s %s", line, extraContent)
232+ }
233+
234+ renderedFiles = append(renderedFiles, line)
235+ filesShown++
236+ }
237+
238+ if len(filesWithChanges) > maxItems {
239+ remaining := len(filesWithChanges) - maxItems
240+ renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
241+ }
242+
243+ return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
244+}
internal/ui/model/sidebar.gonew file+163
@@ -0,0 +1,163 @@
1+package model
2+
3+import (
4+ "cmp"
5+ "fmt"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
9+ "github.com/charmbracelet/crush/internal/ui/common"
10+ "github.com/charmbracelet/crush/internal/ui/logo"
11+ uv "github.com/charmbracelet/ultraviolet"
12+ "golang.org/x/text/cases"
13+ "golang.org/x/text/language"
14+)
15+
16+// modelInfo renders the current model information including reasoning
17+// settings and context usage/cost for the sidebar.
18+func (m *UI) modelInfo(width int) string {
19+ model := m.selectedLargeModel()
20+ reasoningInfo := ""
21+ providerName := ""
22+
23+ if model != nil {
24+ // Get provider name first
25+ providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider)
26+ if ok {
27+ providerName = providerConfig.Name
28+
29+ // Only check reasoning if model can reason
30+ if model.CatwalkCfg.CanReason {
31+ switch providerConfig.Type {
32+ case catwalk.TypeAnthropic:
33+ if model.ModelCfg.Think {
34+ reasoningInfo = "Thinking On"
35+ } else {
36+ reasoningInfo = "Thinking Off"
37+ }
38+ default:
39+ formatter := cases.Title(language.English, cases.NoLower)
40+ reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
41+ reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
42+ }
43+ }
44+ }
45+ }
46+
47+ var modelContext *common.ModelContextInfo
48+ if m.session != nil {
49+ modelContext = &common.ModelContextInfo{
50+ ContextUsed: m.session.CompletionTokens + m.session.PromptTokens,
51+ Cost: m.session.Cost,
52+ ModelContext: model.CatwalkCfg.ContextWindow,
53+ }
54+ }
55+ return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
56+}
57+
58+// getDynamicHeightLimits will give us the num of items to show in each section based on the hight
59+// some items are more important than others.
60+func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int) {
61+ const (
62+ minItemsPerSection = 2
63+ defaultMaxFilesShown = 10
64+ defaultMaxLSPsShown = 8
65+ defaultMaxMCPsShown = 8
66+ minAvailableHeightLimit = 10
67+ )
68+
69+ // If we have very little space, use minimum values
70+ if availableHeight < minAvailableHeightLimit {
71+ return minItemsPerSection, minItemsPerSection, minItemsPerSection
72+ }
73+
74+ // Distribute available height among the three sections
75+ // Give priority to files, then LSPs, then MCPs
76+ totalSections := 3
77+ heightPerSection := availableHeight / totalSections
78+
79+ // Calculate limits for each section, ensuring minimums
80+ maxFiles = max(minItemsPerSection, min(defaultMaxFilesShown, heightPerSection))
81+ maxLSPs = max(minItemsPerSection, min(defaultMaxLSPsShown, heightPerSection))
82+ maxMCPs = max(minItemsPerSection, min(defaultMaxMCPsShown, heightPerSection))
83+
84+ // If we have extra space, give it to files first
85+ remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
86+ if remainingHeight > 0 {
87+ extraForFiles := min(remainingHeight, defaultMaxFilesShown-maxFiles)
88+ maxFiles += extraForFiles
89+ remainingHeight -= extraForFiles
90+
91+ if remainingHeight > 0 {
92+ extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs)
93+ maxLSPs += extraForLSPs
94+ remainingHeight -= extraForLSPs
95+
96+ if remainingHeight > 0 {
97+ maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs)
98+ }
99+ }
100+ }
101+
102+ return maxFiles, maxLSPs, maxMCPs
103+}
104+
105+// sidebar renders the chat sidebar containing session title, working
106+// directory, model info, file list, LSP status, and MCP status.
107+func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
108+ if m.session == nil {
109+ return
110+ }
111+
112+ const logoHeightBreakpoint = 30
113+
114+ t := m.com.Styles
115+ width := area.Dx()
116+ height := area.Dy()
117+
118+ title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
119+ cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
120+ sidebarLogo := m.sidebarLogo
121+ if height < logoHeightBreakpoint {
122+ sidebarLogo = logo.SmallRender(width)
123+ }
124+ blocks := []string{
125+ sidebarLogo,
126+ title,
127+ "",
128+ cwd,
129+ "",
130+ m.modelInfo(width),
131+ "",
132+ }
133+
134+ sidebarHeader := lipgloss.JoinVertical(
135+ lipgloss.Left,
136+ blocks...,
137+ )
138+
139+ _, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader)))
140+ remainingHeight := remainingHeightArea.Dy() - 10
141+ maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight)
142+
143+ lspSection := m.lspInfo(width, maxLSPs, true)
144+ mcpSection := m.mcpInfo(width, maxMCPs, true)
145+ filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true)
146+
147+ uv.NewStyledString(
148+ lipgloss.NewStyle().
149+ MaxWidth(width).
150+ MaxHeight(height).
151+ Render(
152+ lipgloss.JoinVertical(
153+ lipgloss.Left,
154+ sidebarHeader,
155+ filesSection,
156+ "",
157+ lspSection,
158+ "",
159+ mcpSection,
160+ ),
161+ ),
162+ ).Draw(scr, area)
163+}
internal/ui/model/status.gonew file+106
@@ -0,0 +1,106 @@
1+package model
2+
3+import (
4+ "time"
5+
6+ "charm.land/bubbles/v2/help"
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+ "github.com/charmbracelet/crush/internal/ui/common"
10+ "github.com/charmbracelet/crush/internal/uiutil"
11+ uv "github.com/charmbracelet/ultraviolet"
12+ "github.com/charmbracelet/x/ansi"
13+)
14+
15+// DefaultStatusTTL is the default time-to-live for status messages.
16+const DefaultStatusTTL = 5 * time.Second
17+
18+// Status is the status bar and help model.
19+type Status struct {
20+ com *common.Common
21+ help help.Model
22+ helpKm help.KeyMap
23+ msg uiutil.InfoMsg
24+}
25+
26+// NewStatus creates a new status bar and help model.
27+func NewStatus(com *common.Common, km help.KeyMap) *Status {
28+ s := new(Status)
29+ s.com = com
30+ s.help = help.New()
31+ s.help.Styles = com.Styles.Help
32+ s.helpKm = km
33+ return s
34+}
35+
36+// SetInfoMsg sets the status info message.
37+func (s *Status) SetInfoMsg(msg uiutil.InfoMsg) {
38+ s.msg = msg
39+}
40+
41+// ClearInfoMsg clears the status info message.
42+func (s *Status) ClearInfoMsg() {
43+ s.msg = uiutil.InfoMsg{}
44+}
45+
46+// SetWidth sets the width of the status bar and help view.
47+func (s *Status) SetWidth(width int) {
48+ s.help.SetWidth(width)
49+}
50+
51+// ShowingAll returns whether the full help view is shown.
52+func (s *Status) ShowingAll() bool {
53+ return s.help.ShowAll
54+}
55+
56+// ToggleHelp toggles the full help view.
57+func (s *Status) ToggleHelp() {
58+ s.help.ShowAll = !s.help.ShowAll
59+}
60+
61+// Draw draws the status bar onto the screen.
62+func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
63+ helpView := s.com.Styles.Status.Help.Render(s.help.View(s.helpKm))
64+ uv.NewStyledString(helpView).Draw(scr, area)
65+
66+ // Render notifications
67+ if s.msg.IsEmpty() {
68+ return
69+ }
70+
71+ var indStyle lipgloss.Style
72+ var msgStyle lipgloss.Style
73+ switch s.msg.Type {
74+ case uiutil.InfoTypeError:
75+ indStyle = s.com.Styles.Status.ErrorIndicator
76+ msgStyle = s.com.Styles.Status.ErrorMessage
77+ case uiutil.InfoTypeWarn:
78+ indStyle = s.com.Styles.Status.WarnIndicator
79+ msgStyle = s.com.Styles.Status.WarnMessage
80+ case uiutil.InfoTypeUpdate:
81+ indStyle = s.com.Styles.Status.UpdateIndicator
82+ msgStyle = s.com.Styles.Status.UpdateMessage
83+ case uiutil.InfoTypeInfo:
84+ indStyle = s.com.Styles.Status.InfoIndicator
85+ msgStyle = s.com.Styles.Status.InfoMessage
86+ case uiutil.InfoTypeSuccess:
87+ indStyle = s.com.Styles.Status.SuccessIndicator
88+ msgStyle = s.com.Styles.Status.SuccessMessage
89+ }
90+
91+ ind := indStyle.String()
92+ messageWidth := area.Dx() - lipgloss.Width(ind)
93+ msg := ansi.Truncate(s.msg.Msg, messageWidth, "…")
94+ info := msgStyle.Width(messageWidth).Render(msg)
95+
96+ // Draw the info message over the help view
97+ uv.NewStyledString(ind+info).Draw(scr, area)
98+}
99+
100+// clearInfoMsgCmd returns a command that clears the info message after the
101+// given TTL.
102+func clearInfoMsgCmd(ttl time.Duration) tea.Cmd {
103+ return tea.Tick(ttl, func(time.Time) tea.Msg {
104+ return uiutil.ClearStatusMsg{}
105+ })
106+}
internal/ui/model/ui.gonew file+2895
@@ -0,0 +1,2895 @@
1+package model
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "image"
9+ "log/slog"
10+ "math/rand"
11+ "net/http"
12+ "os"
13+ "path/filepath"
14+ "regexp"
15+ "slices"
16+ "strconv"
17+ "strings"
18+ "time"
19+
20+ "charm.land/bubbles/v2/help"
21+ "charm.land/bubbles/v2/key"
22+ "charm.land/bubbles/v2/spinner"
23+ "charm.land/bubbles/v2/textarea"
24+ tea "charm.land/bubbletea/v2"
25+ "charm.land/lipgloss/v2"
26+ "github.com/atotto/clipboard"
27+ "github.com/charmbracelet/catwalk/pkg/catwalk"
28+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
29+ "github.com/charmbracelet/crush/internal/app"
30+ "github.com/charmbracelet/crush/internal/commands"
31+ "github.com/charmbracelet/crush/internal/config"
32+ "github.com/charmbracelet/crush/internal/filetracker"
33+ "github.com/charmbracelet/crush/internal/history"
34+ "github.com/charmbracelet/crush/internal/home"
35+ "github.com/charmbracelet/crush/internal/message"
36+ "github.com/charmbracelet/crush/internal/permission"
37+ "github.com/charmbracelet/crush/internal/pubsub"
38+ "github.com/charmbracelet/crush/internal/session"
39+ "github.com/charmbracelet/crush/internal/ui/anim"
40+ "github.com/charmbracelet/crush/internal/ui/attachments"
41+ "github.com/charmbracelet/crush/internal/ui/chat"
42+ "github.com/charmbracelet/crush/internal/ui/common"
43+ "github.com/charmbracelet/crush/internal/ui/completions"
44+ "github.com/charmbracelet/crush/internal/ui/dialog"
45+ timage "github.com/charmbracelet/crush/internal/ui/image"
46+ "github.com/charmbracelet/crush/internal/ui/logo"
47+ "github.com/charmbracelet/crush/internal/ui/styles"
48+ "github.com/charmbracelet/crush/internal/uiutil"
49+ "github.com/charmbracelet/crush/internal/version"
50+ uv "github.com/charmbracelet/ultraviolet"
51+ "github.com/charmbracelet/ultraviolet/screen"
52+ "github.com/charmbracelet/x/editor"
53+)
54+
55+// Compact mode breakpoints.
56+const (
57+ compactModeWidthBreakpoint = 120
58+ compactModeHeightBreakpoint = 30
59+)
60+
61+// If pasted text has more than 2 newlines, treat it as a file attachment.
62+const pasteLinesThreshold = 10
63+
64+// Session details panel max height.
65+const sessionDetailsMaxHeight = 20
66+
67+// uiFocusState represents the current focus state of the UI.
68+type uiFocusState uint8
69+
70+// Possible uiFocusState values.
71+const (
72+ uiFocusNone uiFocusState = iota
73+ uiFocusEditor
74+ uiFocusMain
75+)
76+
77+type uiState uint8
78+
79+// Possible uiState values.
80+const (
81+ uiConfigure uiState = iota
82+ uiInitialize
83+ uiLanding
84+ uiChat
85+)
86+
87+type openEditorMsg struct {
88+ Text string
89+}
90+
91+type (
92+ // cancelTimerExpiredMsg is sent when the cancel timer expires.
93+ cancelTimerExpiredMsg struct{}
94+ // userCommandsLoadedMsg is sent when user commands are loaded.
95+ userCommandsLoadedMsg struct {
96+ Commands []commands.CustomCommand
97+ }
98+ // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
99+ mcpPromptsLoadedMsg struct {
100+ Prompts []commands.MCPPrompt
101+ }
102+ // sendMessageMsg is sent to send a message.
103+ // currently only used for mcp prompts.
104+ sendMessageMsg struct {
105+ Content string
106+ Attachments []message.Attachment
107+ }
108+
109+ // closeDialogMsg is sent to close the current dialog.
110+ closeDialogMsg struct{}
111+
112+ // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
113+ copyChatHighlightMsg struct{}
114+)
115+
116+// UI represents the main user interface model.
117+type UI struct {
118+ com *common.Common
119+ session *session.Session
120+ sessionFiles []SessionFile
121+
122+ lastUserMessageTime int64
123+
124+ // The width and height of the terminal in cells.
125+ width int
126+ height int
127+ layout layout
128+
129+ focus uiFocusState
130+ state uiState
131+
132+ keyMap KeyMap
133+ keyenh tea.KeyboardEnhancementsMsg
134+
135+ dialog *dialog.Overlay
136+ status *Status
137+
138+ // isCanceling tracks whether the user has pressed escape once to cancel.
139+ isCanceling bool
140+
141+ // header is the last cached header logo
142+ header string
143+
144+ // sendProgressBar instructs the TUI to send progress bar updates to the
145+ // terminal.
146+ sendProgressBar bool
147+
148+ // QueryVersion instructs the TUI to query for the terminal version when it
149+ // starts.
150+ QueryVersion bool
151+
152+ // Editor components
153+ textarea textarea.Model
154+
155+ // Attachment list
156+ attachments *attachments.Attachments
157+
158+ readyPlaceholder string
159+ workingPlaceholder string
160+
161+ // Completions state
162+ completions *completions.Completions
163+ completionsOpen bool
164+ completionsStartIndex int
165+ completionsQuery string
166+ completionsPositionStart image.Point // x,y where user typed '@'
167+
168+ // Chat components
169+ chat *Chat
170+
171+ // onboarding state
172+ onboarding struct {
173+ yesInitializeSelected bool
174+ }
175+
176+ // lsp
177+ lspStates map[string]app.LSPClientInfo
178+
179+ // mcp
180+ mcpStates map[string]mcp.ClientInfo
181+
182+ // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
183+ sidebarLogo string
184+
185+ // imgCaps stores the terminal image capabilities.
186+ imgCaps timage.Capabilities
187+
188+ // custom commands & mcp commands
189+ customCommands []commands.CustomCommand
190+ mcpPrompts []commands.MCPPrompt
191+
192+ // forceCompactMode tracks whether compact mode is forced by user toggle
193+ forceCompactMode bool
194+
195+ // isCompact tracks whether we're currently in compact layout mode (either
196+ // by user toggle or auto-switch based on window size)
197+ isCompact bool
198+
199+ // detailsOpen tracks whether the details panel is open (in compact mode)
200+ detailsOpen bool
201+
202+ // pills state
203+ pillsExpanded bool
204+ focusedPillSection pillSection
205+ promptQueue int
206+ pillsView string
207+
208+ // Todo spinner
209+ todoSpinner spinner.Model
210+ todoIsSpinning bool
211+
212+ // mouse highlighting related state
213+ lastClickTime time.Time
214+}
215+
216+// New creates a new instance of the [UI] model.
217+func New(com *common.Common) *UI {
218+ // Editor components
219+ ta := textarea.New()
220+ ta.SetStyles(com.Styles.TextArea)
221+ ta.ShowLineNumbers = false
222+ ta.CharLimit = -1
223+ ta.SetVirtualCursor(false)
224+ ta.Focus()
225+
226+ ch := NewChat(com)
227+
228+ keyMap := DefaultKeyMap()
229+
230+ // Completions component
231+ comp := completions.New(
232+ com.Styles.Completions.Normal,
233+ com.Styles.Completions.Focused,
234+ com.Styles.Completions.Match,
235+ )
236+
237+ todoSpinner := spinner.New(
238+ spinner.WithSpinner(spinner.MiniDot),
239+ spinner.WithStyle(com.Styles.Pills.TodoSpinner),
240+ )
241+
242+ // Attachments component
243+ attachments := attachments.New(
244+ attachments.NewRenderer(
245+ com.Styles.Attachments.Normal,
246+ com.Styles.Attachments.Deleting,
247+ com.Styles.Attachments.Image,
248+ com.Styles.Attachments.Text,
249+ ),
250+ attachments.Keymap{
251+ DeleteMode: keyMap.Editor.AttachmentDeleteMode,
252+ DeleteAll: keyMap.Editor.DeleteAllAttachments,
253+ Escape: keyMap.Editor.Escape,
254+ },
255+ )
256+
257+ ui := &UI{
258+ com: com,
259+ dialog: dialog.NewOverlay(),
260+ keyMap: keyMap,
261+ focus: uiFocusNone,
262+ state: uiConfigure,
263+ textarea: ta,
264+ chat: ch,
265+ completions: comp,
266+ attachments: attachments,
267+ todoSpinner: todoSpinner,
268+ }
269+
270+ status := NewStatus(com, ui)
271+
272+ // set onboarding state defaults
273+ ui.onboarding.yesInitializeSelected = true
274+
275+ // If no provider is configured show the user the provider list
276+ if !com.Config().IsConfigured() {
277+ ui.state = uiConfigure
278+ // if the project needs initialization show the user the question
279+ } else if n, _ := config.ProjectNeedsInitialization(); n {
280+ ui.state = uiInitialize
281+ // otherwise go to the landing UI
282+ } else {
283+ ui.state = uiLanding
284+ ui.focus = uiFocusEditor
285+ }
286+
287+ ui.setEditorPrompt(false)
288+ ui.randomizePlaceholders()
289+ ui.textarea.Placeholder = ui.readyPlaceholder
290+ ui.status = status
291+
292+ // Initialize compact mode from config
293+ ui.forceCompactMode = com.Config().Options.TUI.CompactMode
294+
295+ return ui
296+}
297+
298+// Init initializes the UI model.
299+func (m *UI) Init() tea.Cmd {
300+ var cmds []tea.Cmd
301+ if m.QueryVersion {
302+ cmds = append(cmds, tea.RequestTerminalVersion)
303+ }
304+ // load the user commands async
305+ cmds = append(cmds, m.loadCustomCommands())
306+ return tea.Batch(cmds...)
307+}
308+
309+// loadCustomCommands loads the custom commands asynchronously.
310+func (m *UI) loadCustomCommands() tea.Cmd {
311+ return func() tea.Msg {
312+ customCommands, err := commands.LoadCustomCommands(m.com.Config())
313+ if err != nil {
314+ slog.Error("failed to load custom commands", "error", err)
315+ }
316+ return userCommandsLoadedMsg{Commands: customCommands}
317+ }
318+}
319+
320+// loadMCPrompts loads the MCP prompts asynchronously.
321+func (m *UI) loadMCPrompts() tea.Cmd {
322+ return func() tea.Msg {
323+ prompts, err := commands.LoadMCPPrompts()
324+ if err != nil {
325+ slog.Error("failed to load mcp prompts", "error", err)
326+ }
327+ if prompts == nil {
328+ // flag them as loaded even if there is none or an error
329+ prompts = []commands.MCPPrompt{}
330+ }
331+ return mcpPromptsLoadedMsg{Prompts: prompts}
332+ }
333+}
334+
335+// Update handles updates to the UI model.
336+func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
337+ var cmds []tea.Cmd
338+ if m.hasSession() && m.isAgentBusy() {
339+ queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
340+ if queueSize != m.promptQueue {
341+ m.promptQueue = queueSize
342+ m.updateLayoutAndSize()
343+ }
344+ }
345+ switch msg := msg.(type) {
346+ case tea.EnvMsg:
347+ // Is this Windows Terminal?
348+ if !m.sendProgressBar {
349+ m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
350+ }
351+ m.imgCaps.Env = uv.Environ(msg)
352+ // XXX: Right now, we're using the same logic to determine image
353+ // support. Terminals like Apple Terminal and possibly others might
354+ // bleed characters when querying for Kitty graphics via APC escape
355+ // sequences.
356+ cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
357+ case loadSessionMsg:
358+ m.state = uiChat
359+ if m.forceCompactMode {
360+ m.isCompact = true
361+ }
362+ m.session = msg.session
363+ m.sessionFiles = msg.files
364+ msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
365+ if err != nil {
366+ cmds = append(cmds, uiutil.ReportError(err))
367+ break
368+ }
369+ if cmd := m.setSessionMessages(msgs); cmd != nil {
370+ cmds = append(cmds, cmd)
371+ }
372+ if hasInProgressTodo(m.session.Todos) {
373+ // only start spinner if there is an in-progress todo
374+ if m.isAgentBusy() {
375+ m.todoIsSpinning = true
376+ cmds = append(cmds, m.todoSpinner.Tick)
377+ }
378+ m.updateLayoutAndSize()
379+ }
380+
381+ case sendMessageMsg:
382+ cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
383+
384+ case userCommandsLoadedMsg:
385+ m.customCommands = msg.Commands
386+ dia := m.dialog.Dialog(dialog.CommandsID)
387+ if dia == nil {
388+ break
389+ }
390+
391+ commands, ok := dia.(*dialog.Commands)
392+ if ok {
393+ commands.SetCustomCommands(m.customCommands)
394+ }
395+ case mcpPromptsLoadedMsg:
396+ m.mcpPrompts = msg.Prompts
397+ dia := m.dialog.Dialog(dialog.CommandsID)
398+ if dia == nil {
399+ break
400+ }
401+
402+ commands, ok := dia.(*dialog.Commands)
403+ if ok {
404+ commands.SetMCPPrompts(m.mcpPrompts)
405+ }
406+
407+ case closeDialogMsg:
408+ m.dialog.CloseFrontDialog()
409+
410+ case pubsub.Event[session.Session]:
411+ if m.session != nil && msg.Payload.ID == m.session.ID {
412+ prevHasInProgress := hasInProgressTodo(m.session.Todos)
413+ m.session = &msg.Payload
414+ if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
415+ m.todoIsSpinning = true
416+ cmds = append(cmds, m.todoSpinner.Tick)
417+ m.updateLayoutAndSize()
418+ }
419+ }
420+ case pubsub.Event[message.Message]:
421+ // Check if this is a child session message for an agent tool.
422+ if m.session == nil {
423+ break
424+ }
425+ if msg.Payload.SessionID != m.session.ID {
426+ // This might be a child session message from an agent tool.
427+ if cmd := m.handleChildSessionMessage(msg); cmd != nil {
428+ cmds = append(cmds, cmd)
429+ }
430+ break
431+ }
432+ switch msg.Type {
433+ case pubsub.CreatedEvent:
434+ cmds = append(cmds, m.appendSessionMessage(msg.Payload))
435+ case pubsub.UpdatedEvent:
436+ cmds = append(cmds, m.updateSessionMessage(msg.Payload))
437+ case pubsub.DeletedEvent:
438+ m.chat.RemoveMessage(msg.Payload.ID)
439+ }
440+ // start the spinner if there is a new message
441+ if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
442+ m.todoIsSpinning = true
443+ cmds = append(cmds, m.todoSpinner.Tick)
444+ }
445+ // stop the spinner if the agent is not busy anymore
446+ if m.todoIsSpinning && !m.isAgentBusy() {
447+ m.todoIsSpinning = false
448+ }
449+ // there is a number of things that could change the pills here so we want to re-render
450+ m.renderPills()
451+ case pubsub.Event[history.File]:
452+ cmds = append(cmds, m.handleFileEvent(msg.Payload))
453+ case pubsub.Event[app.LSPEvent]:
454+ m.lspStates = app.GetLSPStates()
455+ case pubsub.Event[mcp.Event]:
456+ m.mcpStates = mcp.GetStates()
457+ // check if all mcps are initialized
458+ initialized := true
459+ for _, state := range m.mcpStates {
460+ if state.State == mcp.StateStarting {
461+ initialized = false
462+ break
463+ }
464+ }
465+ if initialized && m.mcpPrompts == nil {
466+ cmds = append(cmds, m.loadMCPrompts())
467+ }
468+ case pubsub.Event[permission.PermissionRequest]:
469+ if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
470+ cmds = append(cmds, cmd)
471+ }
472+ case pubsub.Event[permission.PermissionNotification]:
473+ m.handlePermissionNotification(msg.Payload)
474+ case cancelTimerExpiredMsg:
475+ m.isCanceling = false
476+ case tea.TerminalVersionMsg:
477+ termVersion := strings.ToLower(msg.Name)
478+ // Only enable progress bar for the following terminals.
479+ if !m.sendProgressBar {
480+ m.sendProgressBar = strings.Contains(termVersion, "ghostty")
481+ }
482+ return m, nil
483+ case tea.WindowSizeMsg:
484+ m.width, m.height = msg.Width, msg.Height
485+ m.handleCompactMode(m.width, m.height)
486+ m.updateLayoutAndSize()
487+ // XXX: We need to store cell dimensions for image rendering.
488+ m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
489+ case tea.KeyboardEnhancementsMsg:
490+ m.keyenh = msg
491+ if msg.SupportsKeyDisambiguation() {
492+ m.keyMap.Models.SetHelp("ctrl+m", "models")
493+ m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
494+ }
495+ case copyChatHighlightMsg:
496+ cmds = append(cmds, m.copyChatHighlight())
497+ case tea.MouseClickMsg:
498+ switch m.state {
499+ case uiChat:
500+ x, y := msg.X, msg.Y
501+ // Adjust for chat area position
502+ x -= m.layout.main.Min.X
503+ y -= m.layout.main.Min.Y
504+ if m.chat.HandleMouseDown(x, y) {
505+ m.lastClickTime = time.Now()
506+ }
507+ }
508+
509+ case tea.MouseMotionMsg:
510+ switch m.state {
511+ case uiChat:
512+ if msg.Y <= 0 {
513+ if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
514+ cmds = append(cmds, cmd)
515+ }
516+ if !m.chat.SelectedItemInView() {
517+ m.chat.SelectPrev()
518+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
519+ cmds = append(cmds, cmd)
520+ }
521+ }
522+ } else if msg.Y >= m.chat.Height()-1 {
523+ if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
524+ cmds = append(cmds, cmd)
525+ }
526+ if !m.chat.SelectedItemInView() {
527+ m.chat.SelectNext()
528+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
529+ cmds = append(cmds, cmd)
530+ }
531+ }
532+ }
533+
534+ x, y := msg.X, msg.Y
535+ // Adjust for chat area position
536+ x -= m.layout.main.Min.X
537+ y -= m.layout.main.Min.Y
538+ m.chat.HandleMouseDrag(x, y)
539+ }
540+
541+ case tea.MouseReleaseMsg:
542+ const doubleClickThreshold = 500 * time.Millisecond
543+
544+ switch m.state {
545+ case uiChat:
546+ x, y := msg.X, msg.Y
547+ // Adjust for chat area position
548+ x -= m.layout.main.Min.X
549+ y -= m.layout.main.Min.Y
550+ if m.chat.HandleMouseUp(x, y) {
551+ cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
552+ if time.Since(m.lastClickTime) >= doubleClickThreshold {
553+ return copyChatHighlightMsg{}
554+ }
555+ return nil
556+ }))
557+ }
558+ }
559+ case tea.MouseWheelMsg:
560+ // Pass mouse events to dialogs first if any are open.
561+ if m.dialog.HasDialogs() {
562+ m.dialog.Update(msg)
563+ return m, tea.Batch(cmds...)
564+ }
565+
566+ // Otherwise handle mouse wheel for chat.
567+ switch m.state {
568+ case uiChat:
569+ switch msg.Button {
570+ case tea.MouseWheelUp:
571+ if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
572+ cmds = append(cmds, cmd)
573+ }
574+ if !m.chat.SelectedItemInView() {
575+ m.chat.SelectPrev()
576+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
577+ cmds = append(cmds, cmd)
578+ }
579+ }
580+ case tea.MouseWheelDown:
581+ if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
582+ cmds = append(cmds, cmd)
583+ }
584+ if !m.chat.SelectedItemInView() {
585+ m.chat.SelectNext()
586+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
587+ cmds = append(cmds, cmd)
588+ }
589+ }
590+ }
591+ }
592+ case anim.StepMsg:
593+ if m.state == uiChat {
594+ if cmd := m.chat.Animate(msg); cmd != nil {
595+ cmds = append(cmds, cmd)
596+ }
597+ }
598+ case spinner.TickMsg:
599+ if m.dialog.HasDialogs() {
600+ // route to dialog
601+ if cmd := m.handleDialogMsg(msg); cmd != nil {
602+ cmds = append(cmds, cmd)
603+ }
604+ }
605+ if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
606+ var cmd tea.Cmd
607+ m.todoSpinner, cmd = m.todoSpinner.Update(msg)
608+ if cmd != nil {
609+ m.renderPills()
610+ cmds = append(cmds, cmd)
611+ }
612+ }
613+
614+ case tea.KeyPressMsg:
615+ if cmd := m.handleKeyPressMsg(msg); cmd != nil {
616+ cmds = append(cmds, cmd)
617+ }
618+ case tea.PasteMsg:
619+ if cmd := m.handlePasteMsg(msg); cmd != nil {
620+ cmds = append(cmds, cmd)
621+ }
622+ case openEditorMsg:
623+ m.textarea.SetValue(msg.Text)
624+ m.textarea.MoveToEnd()
625+ case uiutil.InfoMsg:
626+ m.status.SetInfoMsg(msg)
627+ ttl := msg.TTL
628+ if ttl <= 0 {
629+ ttl = DefaultStatusTTL
630+ }
631+ cmds = append(cmds, clearInfoMsgCmd(ttl))
632+ case uiutil.ClearStatusMsg:
633+ m.status.ClearInfoMsg()
634+ case completions.FilesLoadedMsg:
635+ // Handle async file loading for completions.
636+ if m.completionsOpen {
637+ m.completions.SetFiles(msg.Files)
638+ }
639+ case uv.WindowPixelSizeEvent:
640+ // [timage.RequestCapabilities] requests the terminal to send a window
641+ // size event to help determine pixel dimensions.
642+ m.imgCaps.PixelWidth = msg.Width
643+ m.imgCaps.PixelHeight = msg.Height
644+ case uv.KittyGraphicsEvent:
645+ // [timage.RequestCapabilities] sends a Kitty graphics query and this
646+ // captures the response. Any response means the terminal understands
647+ // the protocol.
648+ m.imgCaps.SupportsKittyGraphics = true
649+ if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
650+ slog.Warn("unexpected Kitty graphics response",
651+ "response", string(msg.Payload),
652+ "options", msg.Options)
653+ }
654+ default:
655+ if m.dialog.HasDialogs() {
656+ if cmd := m.handleDialogMsg(msg); cmd != nil {
657+ cmds = append(cmds, cmd)
658+ }
659+ }
660+ }
661+
662+ // This logic gets triggered on any message type, but should it?
663+ switch m.focus {
664+ case uiFocusMain:
665+ case uiFocusEditor:
666+ // Textarea placeholder logic
667+ if m.isAgentBusy() {
668+ m.textarea.Placeholder = m.workingPlaceholder
669+ } else {
670+ m.textarea.Placeholder = m.readyPlaceholder
671+ }
672+ if m.com.App.Permissions.SkipRequests() {
673+ m.textarea.Placeholder = "Yolo mode!"
674+ }
675+ }
676+
677+ // at this point this can only handle [message.Attachment] message, and we
678+ // should return all cmds anyway.
679+ _ = m.attachments.Update(msg)
680+ return m, tea.Batch(cmds...)
681+}
682+
683+// setSessionMessages sets the messages for the current session in the chat
684+func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
685+ var cmds []tea.Cmd
686+ // Build tool result map to link tool calls with their results
687+ msgPtrs := make([]*message.Message, len(msgs))
688+ for i := range msgs {
689+ msgPtrs[i] = &msgs[i]
690+ }
691+ toolResultMap := chat.BuildToolResultMap(msgPtrs)
692+ if len(msgPtrs) > 0 {
693+ m.lastUserMessageTime = msgPtrs[0].CreatedAt
694+ }
695+
696+ // Add messages to chat with linked tool results
697+ items := make([]chat.MessageItem, 0, len(msgs)*2)
698+ for _, msg := range msgPtrs {
699+ switch msg.Role {
700+ case message.User:
701+ m.lastUserMessageTime = msg.CreatedAt
702+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
703+ case message.Assistant:
704+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
705+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
706+ infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0))
707+ items = append(items, infoItem)
708+ }
709+ default:
710+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
711+ }
712+ }
713+
714+ // Load nested tool calls for agent/agentic_fetch tools.
715+ m.loadNestedToolCalls(items)
716+
717+ // If the user switches between sessions while the agent is working we want
718+ // to make sure the animations are shown.
719+ for _, item := range items {
720+ if animatable, ok := item.(chat.Animatable); ok {
721+ if cmd := animatable.StartAnimation(); cmd != nil {
722+ cmds = append(cmds, cmd)
723+ }
724+ }
725+ }
726+
727+ m.chat.SetMessages(items...)
728+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
729+ cmds = append(cmds, cmd)
730+ }
731+ m.chat.SelectLast()
732+ return tea.Batch(cmds...)
733+}
734+
735+// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
736+func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
737+ for _, item := range items {
738+ nestedContainer, ok := item.(chat.NestedToolContainer)
739+ if !ok {
740+ continue
741+ }
742+ toolItem, ok := item.(chat.ToolMessageItem)
743+ if !ok {
744+ continue
745+ }
746+
747+ tc := toolItem.ToolCall()
748+ messageID := toolItem.MessageID()
749+
750+ // Get the agent tool session ID.
751+ agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
752+
753+ // Fetch nested messages.
754+ nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
755+ if err != nil || len(nestedMsgs) == 0 {
756+ continue
757+ }
758+
759+ // Build tool result map for nested messages.
760+ nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
761+ for i := range nestedMsgs {
762+ nestedMsgPtrs[i] = &nestedMsgs[i]
763+ }
764+ nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
765+
766+ // Extract nested tool items.
767+ var nestedTools []chat.ToolMessageItem
768+ for _, nestedMsg := range nestedMsgPtrs {
769+ nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
770+ for _, nestedItem := range nestedItems {
771+ if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
772+ // Mark nested tools as simple (compact) rendering.
773+ if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
774+ simplifiable.SetCompact(true)
775+ }
776+ nestedTools = append(nestedTools, nestedToolItem)
777+ }
778+ }
779+ }
780+
781+ // Recursively load nested tool calls for any agent tools within.
782+ nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
783+ for i, nt := range nestedTools {
784+ nestedMessageItems[i] = nt
785+ }
786+ m.loadNestedToolCalls(nestedMessageItems)
787+
788+ // Set nested tools on the parent.
789+ nestedContainer.SetNestedTools(nestedTools)
790+ }
791+}
792+
793+// appendSessionMessage appends a new message to the current session in the chat
794+// if the message is a tool result it will update the corresponding tool call message
795+func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
796+ var cmds []tea.Cmd
797+ existing := m.chat.MessageItem(msg.ID)
798+ if existing != nil {
799+ // message already exists, skip
800+ return nil
801+ }
802+ switch msg.Role {
803+ case message.User:
804+ m.lastUserMessageTime = msg.CreatedAt
805+ items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
806+ for _, item := range items {
807+ if animatable, ok := item.(chat.Animatable); ok {
808+ if cmd := animatable.StartAnimation(); cmd != nil {
809+ cmds = append(cmds, cmd)
810+ }
811+ }
812+ }
813+ m.chat.AppendMessages(items...)
814+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
815+ cmds = append(cmds, cmd)
816+ }
817+ case message.Assistant:
818+ items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
819+ for _, item := range items {
820+ if animatable, ok := item.(chat.Animatable); ok {
821+ if cmd := animatable.StartAnimation(); cmd != nil {
822+ cmds = append(cmds, cmd)
823+ }
824+ }
825+ }
826+ m.chat.AppendMessages(items...)
827+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
828+ cmds = append(cmds, cmd)
829+ }
830+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
831+ infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
832+ m.chat.AppendMessages(infoItem)
833+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
834+ cmds = append(cmds, cmd)
835+ }
836+ }
837+ case message.Tool:
838+ for _, tr := range msg.ToolResults() {
839+ toolItem := m.chat.MessageItem(tr.ToolCallID)
840+ if toolItem == nil {
841+ // we should have an item!
842+ continue
843+ }
844+ if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
845+ toolMsgItem.SetResult(&tr)
846+ }
847+ }
848+ }
849+ return tea.Batch(cmds...)
850+}
851+
852+// updateSessionMessage updates an existing message in the current session in the chat
853+// when an assistant message is updated it may include updated tool calls as well
854+// that is why we need to handle creating/updating each tool call message too
855+func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
856+ var cmds []tea.Cmd
857+ existingItem := m.chat.MessageItem(msg.ID)
858+
859+ if existingItem != nil {
860+ if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
861+ assistantItem.SetMessage(&msg)
862+ }
863+ }
864+
865+ shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
866+ // if the message of the assistant does not have any response just tool calls we need to remove it
867+ if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
868+ m.chat.RemoveMessage(msg.ID)
869+ if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
870+ m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
871+ }
872+ }
873+
874+ if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
875+ if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
876+ newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
877+ m.chat.AppendMessages(newInfoItem)
878+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
879+ cmds = append(cmds, cmd)
880+ }
881+ }
882+ }
883+
884+ var items []chat.MessageItem
885+ for _, tc := range msg.ToolCalls() {
886+ existingToolItem := m.chat.MessageItem(tc.ID)
887+ if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
888+ existingToolCall := toolItem.ToolCall()
889+ // only update if finished state changed or input changed
890+ // to avoid clearing the cache
891+ if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
892+ toolItem.SetToolCall(tc)
893+ }
894+ }
895+ if existingToolItem == nil {
896+ items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
897+ }
898+ }
899+
900+ for _, item := range items {
901+ if animatable, ok := item.(chat.Animatable); ok {
902+ if cmd := animatable.StartAnimation(); cmd != nil {
903+ cmds = append(cmds, cmd)
904+ }
905+ }
906+ }
907+ m.chat.AppendMessages(items...)
908+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
909+ cmds = append(cmds, cmd)
910+ }
911+
912+ return tea.Batch(cmds...)
913+}
914+
915+// handleChildSessionMessage handles messages from child sessions (agent tools).
916+func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
917+ var cmds []tea.Cmd
918+
919+ // Only process messages with tool calls or results.
920+ if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
921+ return nil
922+ }
923+
924+ // Check if this is an agent tool session and parse it.
925+ childSessionID := event.Payload.SessionID
926+ _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
927+ if !ok {
928+ return nil
929+ }
930+
931+ // Find the parent agent tool item.
932+ var agentItem chat.NestedToolContainer
933+ for i := 0; i < m.chat.Len(); i++ {
934+ item := m.chat.MessageItem(toolCallID)
935+ if item == nil {
936+ continue
937+ }
938+ if agent, ok := item.(chat.NestedToolContainer); ok {
939+ if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
940+ if toolMessageItem.ToolCall().ID == toolCallID {
941+ // Verify this agent belongs to the correct parent message.
942+ // We can't directly check parentMessageID on the item, so we trust the session parsing.
943+ agentItem = agent
944+ break
945+ }
946+ }
947+ }
948+ }
949+
950+ if agentItem == nil {
951+ return nil
952+ }
953+
954+ // Get existing nested tools.
955+ nestedTools := agentItem.NestedTools()
956+
957+ // Update or create nested tool calls.
958+ for _, tc := range event.Payload.ToolCalls() {
959+ found := false
960+ for _, existingTool := range nestedTools {
961+ if existingTool.ToolCall().ID == tc.ID {
962+ existingTool.SetToolCall(tc)
963+ found = true
964+ break
965+ }
966+ }
967+ if !found {
968+ // Create a new nested tool item.
969+ nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
970+ if simplifiable, ok := nestedItem.(chat.Compactable); ok {
971+ simplifiable.SetCompact(true)
972+ }
973+ if animatable, ok := nestedItem.(chat.Animatable); ok {
974+ if cmd := animatable.StartAnimation(); cmd != nil {
975+ cmds = append(cmds, cmd)
976+ }
977+ }
978+ nestedTools = append(nestedTools, nestedItem)
979+ }
980+ }
981+
982+ // Update nested tool results.
983+ for _, tr := range event.Payload.ToolResults() {
984+ for _, nestedTool := range nestedTools {
985+ if nestedTool.ToolCall().ID == tr.ToolCallID {
986+ nestedTool.SetResult(&tr)
987+ break
988+ }
989+ }
990+ }
991+
992+ // Update the agent item with the new nested tools.
993+ agentItem.SetNestedTools(nestedTools)
994+
995+ // Update the chat so it updates the index map for animations to work as expected
996+ m.chat.UpdateNestedToolIDs(toolCallID)
997+
998+ return tea.Batch(cmds...)
999+}
1000+
1001+func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1002+ var cmds []tea.Cmd
1003+ action := m.dialog.Update(msg)
1004+ if action == nil {
1005+ return tea.Batch(cmds...)
1006+ }
1007+
1008+ switch msg := action.(type) {
1009+ // Generic dialog messages
1010+ case dialog.ActionClose:
1011+ m.dialog.CloseFrontDialog()
1012+ if m.focus == uiFocusEditor {
1013+ cmds = append(cmds, m.textarea.Focus())
1014+ }
1015+ case dialog.ActionCmd:
1016+ if msg.Cmd != nil {
1017+ cmds = append(cmds, msg.Cmd)
1018+ }
1019+
1020+ // Session dialog messages
1021+ case dialog.ActionSelectSession:
1022+ m.dialog.CloseDialog(dialog.SessionsID)
1023+ cmds = append(cmds, m.loadSession(msg.Session.ID))
1024+
1025+ // Open dialog message
1026+ case dialog.ActionOpenDialog:
1027+ m.dialog.CloseDialog(dialog.CommandsID)
1028+ if cmd := m.openDialog(msg.DialogID); cmd != nil {
1029+ cmds = append(cmds, cmd)
1030+ }
1031+
1032+ // Command dialog messages
1033+ case dialog.ActionToggleYoloMode:
1034+ yolo := !m.com.App.Permissions.SkipRequests()
1035+ m.com.App.Permissions.SetSkipRequests(yolo)
1036+ m.setEditorPrompt(yolo)
1037+ m.dialog.CloseDialog(dialog.CommandsID)
1038+ case dialog.ActionNewSession:
1039+ if m.isAgentBusy() {
1040+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1041+ break
1042+ }
1043+ m.newSession()
1044+ m.dialog.CloseDialog(dialog.CommandsID)
1045+ case dialog.ActionSummarize:
1046+ if m.isAgentBusy() {
1047+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1048+ break
1049+ }
1050+ cmds = append(cmds, func() tea.Msg {
1051+ err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
1052+ if err != nil {
1053+ return uiutil.ReportError(err)()
1054+ }
1055+ return nil
1056+ })
1057+ m.dialog.CloseDialog(dialog.CommandsID)
1058+ case dialog.ActionToggleHelp:
1059+ m.status.ToggleHelp()
1060+ m.dialog.CloseDialog(dialog.CommandsID)
1061+ case dialog.ActionExternalEditor:
1062+ if m.isAgentBusy() {
1063+ cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1064+ break
1065+ }
1066+ cmds = append(cmds, m.openEditor(m.textarea.Value()))
1067+ m.dialog.CloseDialog(dialog.CommandsID)
1068+ case dialog.ActionToggleCompactMode:
1069+ cmds = append(cmds, m.toggleCompactMode())
1070+ m.dialog.CloseDialog(dialog.CommandsID)
1071+ case dialog.ActionToggleThinking:
1072+ if m.isAgentBusy() {
1073+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1074+ break
1075+ }
1076+
1077+ cmds = append(cmds, func() tea.Msg {
1078+ cfg := m.com.Config()
1079+ if cfg == nil {
1080+ return uiutil.ReportError(errors.New("configuration not found"))()
1081+ }
1082+
1083+ agentCfg, ok := cfg.Agents[config.AgentCoder]
1084+ if !ok {
1085+ return uiutil.ReportError(errors.New("agent configuration not found"))()
1086+ }
1087+
1088+ currentModel := cfg.Models[agentCfg.Model]
1089+ currentModel.Think = !currentModel.Think
1090+ if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1091+ return uiutil.ReportError(err)()
1092+ }
1093+ m.com.App.UpdateAgentModel(context.TODO())
1094+ status := "disabled"
1095+ if currentModel.Think {
1096+ status = "enabled"
1097+ }
1098+ return uiutil.NewInfoMsg("Thinking mode " + status)
1099+ })
1100+ m.dialog.CloseDialog(dialog.CommandsID)
1101+ case dialog.ActionQuit:
1102+ cmds = append(cmds, tea.Quit)
1103+ case dialog.ActionInitializeProject:
1104+ if m.isAgentBusy() {
1105+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1106+ break
1107+ }
1108+ cmds = append(cmds, m.initializeProject())
1109+
1110+ case dialog.ActionSelectModel:
1111+ if m.isAgentBusy() {
1112+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1113+ break
1114+ }
1115+
1116+ cfg := m.com.Config()
1117+ if cfg == nil {
1118+ cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1119+ break
1120+ }
1121+
1122+ var (
1123+ providerID = msg.Model.Provider
1124+ isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1125+ isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1126+ )
1127+
1128+ // Attempt to import GitHub Copilot tokens from VSCode if available.
1129+ if isCopilot && !isConfigured() {
1130+ config.Get().ImportCopilot()
1131+ }
1132+
1133+ if !isConfigured() {
1134+ m.dialog.CloseDialog(dialog.ModelsID)
1135+ if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1136+ cmds = append(cmds, cmd)
1137+ }
1138+ break
1139+ }
1140+
1141+ if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
1142+ cmds = append(cmds, uiutil.ReportError(err))
1143+ }
1144+
1145+ cmds = append(cmds, func() tea.Msg {
1146+ m.com.App.UpdateAgentModel(context.TODO())
1147+
1148+ modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1149+
1150+ return uiutil.NewInfoMsg(modelMsg)
1151+ })
1152+
1153+ m.dialog.CloseDialog(dialog.APIKeyInputID)
1154+ m.dialog.CloseDialog(dialog.OAuthID)
1155+ m.dialog.CloseDialog(dialog.ModelsID)
1156+ case dialog.ActionSelectReasoningEffort:
1157+ if m.isAgentBusy() {
1158+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1159+ break
1160+ }
1161+
1162+ cfg := m.com.Config()
1163+ if cfg == nil {
1164+ cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1165+ break
1166+ }
1167+
1168+ agentCfg, ok := cfg.Agents[config.AgentCoder]
1169+ if !ok {
1170+ cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
1171+ break
1172+ }
1173+
1174+ currentModel := cfg.Models[agentCfg.Model]
1175+ currentModel.ReasoningEffort = msg.Effort
1176+ if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1177+ cmds = append(cmds, uiutil.ReportError(err))
1178+ break
1179+ }
1180+
1181+ cmds = append(cmds, func() tea.Msg {
1182+ m.com.App.UpdateAgentModel(context.TODO())
1183+ return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1184+ })
1185+ m.dialog.CloseDialog(dialog.ReasoningID)
1186+ case dialog.ActionPermissionResponse:
1187+ m.dialog.CloseDialog(dialog.PermissionsID)
1188+ switch msg.Action {
1189+ case dialog.PermissionAllow:
1190+ m.com.App.Permissions.Grant(msg.Permission)
1191+ case dialog.PermissionAllowForSession:
1192+ m.com.App.Permissions.GrantPersistent(msg.Permission)
1193+ case dialog.PermissionDeny:
1194+ m.com.App.Permissions.Deny(msg.Permission)
1195+ }
1196+
1197+ case dialog.ActionFilePickerSelected:
1198+ cmds = append(cmds, tea.Sequence(
1199+ msg.Cmd(),
1200+ func() tea.Msg {
1201+ m.dialog.CloseDialog(dialog.FilePickerID)
1202+ return nil
1203+ },
1204+ ))
1205+
1206+ case dialog.ActionRunCustomCommand:
1207+ if len(msg.Arguments) > 0 && msg.Args == nil {
1208+ m.dialog.CloseFrontDialog()
1209+ argsDialog := dialog.NewArguments(
1210+ m.com,
1211+ "Custom Command Arguments",
1212+ "",
1213+ msg.Arguments,
1214+ msg, // Pass the action as the result
1215+ )
1216+ m.dialog.OpenDialog(argsDialog)
1217+ break
1218+ }
1219+ content := msg.Content
1220+ if msg.Args != nil {
1221+ content = substituteArgs(content, msg.Args)
1222+ }
1223+ cmds = append(cmds, m.sendMessage(content))
1224+ m.dialog.CloseFrontDialog()
1225+ case dialog.ActionRunMCPPrompt:
1226+ if len(msg.Arguments) > 0 && msg.Args == nil {
1227+ m.dialog.CloseFrontDialog()
1228+ title := msg.Title
1229+ if title == "" {
1230+ title = "MCP Prompt Arguments"
1231+ }
1232+ argsDialog := dialog.NewArguments(
1233+ m.com,
1234+ title,
1235+ msg.Description,
1236+ msg.Arguments,
1237+ msg, // Pass the action as the result
1238+ )
1239+ m.dialog.OpenDialog(argsDialog)
1240+ break
1241+ }
1242+ cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1243+ default:
1244+ cmds = append(cmds, uiutil.CmdHandler(msg))
1245+ }
1246+
1247+ return tea.Batch(cmds...)
1248+}
1249+
1250+// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1251+func substituteArgs(content string, args map[string]string) string {
1252+ for name, value := range args {
1253+ placeholder := "$" + name
1254+ content = strings.ReplaceAll(content, placeholder, value)
1255+ }
1256+ return content
1257+}
1258+
1259+func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1260+ var (
1261+ dlg dialog.Dialog
1262+ cmd tea.Cmd
1263+ )
1264+
1265+ switch provider.ID {
1266+ case "hyper":
1267+ dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType)
1268+ case catwalk.InferenceProviderCopilot:
1269+ dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType)
1270+ default:
1271+ dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType)
1272+ }
1273+
1274+ if m.dialog.ContainsDialog(dlg.ID()) {
1275+ m.dialog.BringToFront(dlg.ID())
1276+ return nil
1277+ }
1278+
1279+ m.dialog.OpenDialog(dlg)
1280+ return cmd
1281+}
1282+
1283+func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1284+ var cmds []tea.Cmd
1285+
1286+ handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1287+ switch {
1288+ case key.Matches(msg, m.keyMap.Help):
1289+ m.status.ToggleHelp()
1290+ m.updateLayoutAndSize()
1291+ return true
1292+ case key.Matches(msg, m.keyMap.Commands):
1293+ if cmd := m.openCommandsDialog(); cmd != nil {
1294+ cmds = append(cmds, cmd)
1295+ }
1296+ return true
1297+ case key.Matches(msg, m.keyMap.Models):
1298+ if cmd := m.openModelsDialog(); cmd != nil {
1299+ cmds = append(cmds, cmd)
1300+ }
1301+ return true
1302+ case key.Matches(msg, m.keyMap.Sessions):
1303+ if cmd := m.openSessionsDialog(); cmd != nil {
1304+ cmds = append(cmds, cmd)
1305+ }
1306+ return true
1307+ case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1308+ m.detailsOpen = !m.detailsOpen
1309+ m.updateLayoutAndSize()
1310+ return true
1311+ case key.Matches(msg, m.keyMap.Chat.TogglePills):
1312+ if m.state == uiChat && m.hasSession() {
1313+ if cmd := m.togglePillsExpanded(); cmd != nil {
1314+ cmds = append(cmds, cmd)
1315+ }
1316+ return true
1317+ }
1318+ case key.Matches(msg, m.keyMap.Chat.PillLeft):
1319+ if m.state == uiChat && m.hasSession() && m.pillsExpanded {
1320+ if cmd := m.switchPillSection(-1); cmd != nil {
1321+ cmds = append(cmds, cmd)
1322+ }
1323+ return true
1324+ }
1325+ case key.Matches(msg, m.keyMap.Chat.PillRight):
1326+ if m.state == uiChat && m.hasSession() && m.pillsExpanded {
1327+ if cmd := m.switchPillSection(1); cmd != nil {
1328+ cmds = append(cmds, cmd)
1329+ }
1330+ return true
1331+ }
1332+ }
1333+ return false
1334+ }
1335+
1336+ if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1337+ // Always handle quit keys first
1338+ if cmd := m.openQuitDialog(); cmd != nil {
1339+ cmds = append(cmds, cmd)
1340+ }
1341+
1342+ return tea.Batch(cmds...)
1343+ }
1344+
1345+ // Route all messages to dialog if one is open.
1346+ if m.dialog.HasDialogs() {
1347+ return m.handleDialogMsg(msg)
1348+ }
1349+
1350+ // Handle cancel key when agent is busy.
1351+ if key.Matches(msg, m.keyMap.Chat.Cancel) {
1352+ if m.isAgentBusy() {
1353+ if cmd := m.cancelAgent(); cmd != nil {
1354+ cmds = append(cmds, cmd)
1355+ }
1356+ return tea.Batch(cmds...)
1357+ }
1358+ }
1359+
1360+ switch m.state {
1361+ case uiConfigure:
1362+ return tea.Batch(cmds...)
1363+ case uiInitialize:
1364+ cmds = append(cmds, m.updateInitializeView(msg)...)
1365+ return tea.Batch(cmds...)
1366+ case uiChat, uiLanding:
1367+ switch m.focus {
1368+ case uiFocusEditor:
1369+ // Handle completions if open.
1370+ if m.completionsOpen {
1371+ if msg, ok := m.completions.Update(msg); ok {
1372+ switch msg := msg.(type) {
1373+ case completions.SelectionMsg:
1374+ // Handle file completion selection.
1375+ if item, ok := msg.Value.(completions.FileCompletionValue); ok {
1376+ cmds = append(cmds, m.insertFileCompletion(item.Path))
1377+ }
1378+ if !msg.Insert {
1379+ m.closeCompletions()
1380+ }
1381+ case completions.ClosedMsg:
1382+ m.completionsOpen = false
1383+ }
1384+ return tea.Batch(cmds...)
1385+ }
1386+ }
1387+
1388+ if ok := m.attachments.Update(msg); ok {
1389+ return tea.Batch(cmds...)
1390+ }
1391+
1392+ switch {
1393+ case key.Matches(msg, m.keyMap.Editor.AddImage):
1394+ if cmd := m.openFilesDialog(); cmd != nil {
1395+ cmds = append(cmds, cmd)
1396+ }
1397+
1398+ case key.Matches(msg, m.keyMap.Editor.SendMessage):
1399+ value := m.textarea.Value()
1400+ if before, ok := strings.CutSuffix(value, "\\"); ok {
1401+ // If the last character is a backslash, remove it and add a newline.
1402+ m.textarea.SetValue(before)
1403+ break
1404+ }
1405+
1406+ // Otherwise, send the message
1407+ m.textarea.Reset()
1408+
1409+ value = strings.TrimSpace(value)
1410+ if value == "exit" || value == "quit" {
1411+ return m.openQuitDialog()
1412+ }
1413+
1414+ attachments := m.attachments.List()
1415+ m.attachments.Reset()
1416+ if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1417+ return nil
1418+ }
1419+
1420+ m.randomizePlaceholders()
1421+
1422+ return m.sendMessage(value, attachments...)
1423+ case key.Matches(msg, m.keyMap.Chat.NewSession):
1424+ if !m.hasSession() {
1425+ break
1426+ }
1427+ if m.isAgentBusy() {
1428+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1429+ break
1430+ }
1431+ m.newSession()
1432+ case key.Matches(msg, m.keyMap.Tab):
1433+ m.focus = uiFocusMain
1434+ m.textarea.Blur()
1435+ m.chat.Focus()
1436+ m.chat.SetSelected(m.chat.Len() - 1)
1437+ case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1438+ if m.isAgentBusy() {
1439+ cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1440+ break
1441+ }
1442+ cmds = append(cmds, m.openEditor(m.textarea.Value()))
1443+ case key.Matches(msg, m.keyMap.Editor.Newline):
1444+ m.textarea.InsertRune('\n')
1445+ m.closeCompletions()
1446+ default:
1447+ if handleGlobalKeys(msg) {
1448+ // Handle global keys first before passing to textarea.
1449+ break
1450+ }
1451+
1452+ // Check for @ trigger before passing to textarea.
1453+ curValue := m.textarea.Value()
1454+ curIdx := len(curValue)
1455+
1456+ // Trigger completions on @.
1457+ if msg.String() == "@" && !m.completionsOpen {
1458+ // Only show if beginning of prompt or after whitespace.
1459+ if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1460+ m.completionsOpen = true
1461+ m.completionsQuery = ""
1462+ m.completionsStartIndex = curIdx
1463+ m.completionsPositionStart = m.completionsPosition()
1464+ depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1465+ cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
1466+ }
1467+ }
1468+
1469+ // remove the details if they are open when user starts typing
1470+ if m.detailsOpen {
1471+ m.detailsOpen = false
1472+ m.updateLayoutAndSize()
1473+ }
1474+
1475+ ta, cmd := m.textarea.Update(msg)
1476+ m.textarea = ta
1477+ cmds = append(cmds, cmd)
1478+
1479+ // After updating textarea, check if we need to filter completions.
1480+ // Skip filtering on the initial @ keystroke since items are loading async.
1481+ if m.completionsOpen && msg.String() != "@" {
1482+ newValue := m.textarea.Value()
1483+ newIdx := len(newValue)
1484+
1485+ // Close completions if cursor moved before start.
1486+ if newIdx <= m.completionsStartIndex {
1487+ m.closeCompletions()
1488+ } else if msg.String() == "space" {
1489+ // Close on space.
1490+ m.closeCompletions()
1491+ } else {
1492+ // Extract current word and filter.
1493+ word := m.textareaWord()
1494+ if strings.HasPrefix(word, "@") {
1495+ m.completionsQuery = word[1:]
1496+ m.completions.Filter(m.completionsQuery)
1497+ } else if m.completionsOpen {
1498+ m.closeCompletions()
1499+ }
1500+ }
1501+ }
1502+ }
1503+ case uiFocusMain:
1504+ switch {
1505+ case key.Matches(msg, m.keyMap.Tab):
1506+ m.focus = uiFocusEditor
1507+ cmds = append(cmds, m.textarea.Focus())
1508+ m.chat.Blur()
1509+ case key.Matches(msg, m.keyMap.Chat.Expand):
1510+ m.chat.ToggleExpandedSelectedItem()
1511+ case key.Matches(msg, m.keyMap.Chat.Up):
1512+ if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1513+ cmds = append(cmds, cmd)
1514+ }
1515+ if !m.chat.SelectedItemInView() {
1516+ m.chat.SelectPrev()
1517+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1518+ cmds = append(cmds, cmd)
1519+ }
1520+ }
1521+ case key.Matches(msg, m.keyMap.Chat.Down):
1522+ if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1523+ cmds = append(cmds, cmd)
1524+ }
1525+ if !m.chat.SelectedItemInView() {
1526+ m.chat.SelectNext()
1527+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1528+ cmds = append(cmds, cmd)
1529+ }
1530+ }
1531+ case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1532+ m.chat.SelectPrev()
1533+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1534+ cmds = append(cmds, cmd)
1535+ }
1536+ case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1537+ m.chat.SelectNext()
1538+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1539+ cmds = append(cmds, cmd)
1540+ }
1541+ case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1542+ if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1543+ cmds = append(cmds, cmd)
1544+ }
1545+ m.chat.SelectFirstInView()
1546+ case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1547+ if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1548+ cmds = append(cmds, cmd)
1549+ }
1550+ m.chat.SelectLastInView()
1551+ case key.Matches(msg, m.keyMap.Chat.PageUp):
1552+ if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1553+ cmds = append(cmds, cmd)
1554+ }
1555+ m.chat.SelectFirstInView()
1556+ case key.Matches(msg, m.keyMap.Chat.PageDown):
1557+ if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1558+ cmds = append(cmds, cmd)
1559+ }
1560+ m.chat.SelectLastInView()
1561+ case key.Matches(msg, m.keyMap.Chat.Home):
1562+ if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1563+ cmds = append(cmds, cmd)
1564+ }
1565+ m.chat.SelectFirst()
1566+ case key.Matches(msg, m.keyMap.Chat.End):
1567+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1568+ cmds = append(cmds, cmd)
1569+ }
1570+ m.chat.SelectLast()
1571+ default:
1572+ handleGlobalKeys(msg)
1573+ }
1574+ default:
1575+ handleGlobalKeys(msg)
1576+ }
1577+ default:
1578+ handleGlobalKeys(msg)
1579+ }
1580+
1581+ return tea.Batch(cmds...)
1582+}
1583+
1584+// Draw implements [uv.Drawable] and draws the UI model.
1585+func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1586+ layout := m.generateLayout(area.Dx(), area.Dy())
1587+
1588+ if m.layout != layout {
1589+ m.layout = layout
1590+ m.updateSize()
1591+ }
1592+
1593+ // Clear the screen first
1594+ screen.Clear(scr)
1595+
1596+ switch m.state {
1597+ case uiConfigure:
1598+ header := uv.NewStyledString(m.header)
1599+ header.Draw(scr, layout.header)
1600+
1601+ mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1602+ Height(layout.main.Dy()).
1603+ Background(lipgloss.ANSIColor(rand.Intn(256))).
1604+ Render(" Configure ")
1605+ main := uv.NewStyledString(mainView)
1606+ main.Draw(scr, layout.main)
1607+
1608+ case uiInitialize:
1609+ header := uv.NewStyledString(m.header)
1610+ header.Draw(scr, layout.header)
1611+
1612+ main := uv.NewStyledString(m.initializeView())
1613+ main.Draw(scr, layout.main)
1614+
1615+ case uiLanding:
1616+ header := uv.NewStyledString(m.header)
1617+ header.Draw(scr, layout.header)
1618+ main := uv.NewStyledString(m.landingView())
1619+ main.Draw(scr, layout.main)
1620+
1621+ editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1622+ editor.Draw(scr, layout.editor)
1623+
1624+ case uiChat:
1625+ if m.isCompact {
1626+ header := uv.NewStyledString(m.header)
1627+ header.Draw(scr, layout.header)
1628+ } else {
1629+ m.drawSidebar(scr, layout.sidebar)
1630+ }
1631+
1632+ m.chat.Draw(scr, layout.main)
1633+ if layout.pills.Dy() > 0 && m.pillsView != "" {
1634+ uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1635+ }
1636+
1637+ editorWidth := scr.Bounds().Dx()
1638+ if !m.isCompact {
1639+ editorWidth -= layout.sidebar.Dx()
1640+ }
1641+ editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1642+ editor.Draw(scr, layout.editor)
1643+
1644+ // Draw details overlay in compact mode when open
1645+ if m.isCompact && m.detailsOpen {
1646+ m.drawSessionDetails(scr, layout.sessionDetails)
1647+ }
1648+ }
1649+
1650+ // Add status and help layer
1651+ m.status.Draw(scr, layout.status)
1652+
1653+ // Draw completions popup if open
1654+ if m.completionsOpen && m.completions.HasItems() {
1655+ w, h := m.completions.Size()
1656+ x := m.completionsPositionStart.X
1657+ y := m.completionsPositionStart.Y - h
1658+
1659+ screenW := area.Dx()
1660+ if x+w > screenW {
1661+ x = screenW - w
1662+ }
1663+ x = max(0, x)
1664+ y = max(0, y)
1665+
1666+ completionsView := uv.NewStyledString(m.completions.Render())
1667+ completionsView.Draw(scr, image.Rectangle{
1668+ Min: image.Pt(x, y),
1669+ Max: image.Pt(x+w, y+h),
1670+ })
1671+ }
1672+
1673+ // Debugging rendering (visually see when the tui rerenders)
1674+ if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1675+ debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1676+ debug := uv.NewStyledString(debugView.String())
1677+ debug.Draw(scr, image.Rectangle{
1678+ Min: image.Pt(4, 1),
1679+ Max: image.Pt(8, 3),
1680+ })
1681+ }
1682+
1683+ // This needs to come last to overlay on top of everything. We always pass
1684+ // the full screen bounds because the dialogs will position themselves
1685+ // accordingly.
1686+ if m.dialog.HasDialogs() {
1687+ return m.dialog.Draw(scr, scr.Bounds())
1688+ }
1689+
1690+ switch m.focus {
1691+ case uiFocusEditor:
1692+ if m.layout.editor.Dy() <= 0 {
1693+ // Don't show cursor if editor is not visible
1694+ return nil
1695+ }
1696+ if m.detailsOpen && m.isCompact {
1697+ // Don't show cursor if details overlay is open
1698+ return nil
1699+ }
1700+
1701+ if m.textarea.Focused() {
1702+ cur := m.textarea.Cursor()
1703+ cur.X++ // Adjust for app margins
1704+ cur.Y += m.layout.editor.Min.Y
1705+ // Offset for attachment row if present.
1706+ if len(m.attachments.List()) > 0 {
1707+ cur.Y++
1708+ }
1709+ return cur
1710+ }
1711+ }
1712+ return nil
1713+}
1714+
1715+// View renders the UI model's view.
1716+func (m *UI) View() tea.View {
1717+ var v tea.View
1718+ v.AltScreen = true
1719+ v.BackgroundColor = m.com.Styles.Background
1720+ v.MouseMode = tea.MouseModeCellMotion
1721+ v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
1722+
1723+ canvas := uv.NewScreenBuffer(m.width, m.height)
1724+ v.Cursor = m.Draw(canvas, canvas.Bounds())
1725+
1726+ content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1727+ contentLines := strings.Split(content, "\n")
1728+ for i, line := range contentLines {
1729+ // Trim trailing spaces for concise rendering
1730+ contentLines[i] = strings.TrimRight(line, " ")
1731+ }
1732+
1733+ content = strings.Join(contentLines, "\n")
1734+
1735+ v.Content = content
1736+ if m.sendProgressBar && m.isAgentBusy() {
1737+ // HACK: use a random percentage to prevent ghostty from hiding it
1738+ // after a timeout.
1739+ v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1740+ }
1741+
1742+ return v
1743+}
1744+
1745+// ShortHelp implements [help.KeyMap].
1746+func (m *UI) ShortHelp() []key.Binding {
1747+ var binds []key.Binding
1748+ k := &m.keyMap
1749+ tab := k.Tab
1750+ commands := k.Commands
1751+ if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1752+ commands.SetHelp("/ or ctrl+p", "commands")
1753+ }
1754+
1755+ switch m.state {
1756+ case uiInitialize:
1757+ binds = append(binds, k.Quit)
1758+ case uiChat:
1759+ // Show cancel binding if agent is busy.
1760+ if m.isAgentBusy() {
1761+ cancelBinding := k.Chat.Cancel
1762+ if m.isCanceling {
1763+ cancelBinding.SetHelp("esc", "press again to cancel")
1764+ } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1765+ cancelBinding.SetHelp("esc", "clear queue")
1766+ }
1767+ binds = append(binds, cancelBinding)
1768+ }
1769+
1770+ if m.focus == uiFocusEditor {
1771+ tab.SetHelp("tab", "focus chat")
1772+ } else {
1773+ tab.SetHelp("tab", "focus editor")
1774+ }
1775+
1776+ binds = append(binds,
1777+ tab,
1778+ commands,
1779+ k.Models,
1780+ )
1781+
1782+ switch m.focus {
1783+ case uiFocusEditor:
1784+ binds = append(binds,
1785+ k.Editor.Newline,
1786+ )
1787+ case uiFocusMain:
1788+ binds = append(binds,
1789+ k.Chat.UpDown,
1790+ k.Chat.UpDownOneItem,
1791+ k.Chat.PageUp,
1792+ k.Chat.PageDown,
1793+ k.Chat.Copy,
1794+ )
1795+ if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1796+ binds = append(binds, k.Chat.PillLeft)
1797+ }
1798+ }
1799+ default:
1800+ // TODO: other states
1801+ // if m.session == nil {
1802+ // no session selected
1803+ binds = append(binds,
1804+ commands,
1805+ k.Models,
1806+ k.Editor.Newline,
1807+ )
1808+ }
1809+
1810+ binds = append(binds,
1811+ k.Quit,
1812+ k.Help,
1813+ )
1814+
1815+ return binds
1816+}
1817+
1818+// FullHelp implements [help.KeyMap].
1819+func (m *UI) FullHelp() [][]key.Binding {
1820+ var binds [][]key.Binding
1821+ k := &m.keyMap
1822+ help := k.Help
1823+ help.SetHelp("ctrl+g", "less")
1824+ hasAttachments := len(m.attachments.List()) > 0
1825+ hasSession := m.hasSession()
1826+ commands := k.Commands
1827+ if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1828+ commands.SetHelp("/ or ctrl+p", "commands")
1829+ }
1830+
1831+ switch m.state {
1832+ case uiInitialize:
1833+ binds = append(binds,
1834+ []key.Binding{
1835+ k.Quit,
1836+ })
1837+ case uiChat:
1838+ // Show cancel binding if agent is busy.
1839+ if m.isAgentBusy() {
1840+ cancelBinding := k.Chat.Cancel
1841+ if m.isCanceling {
1842+ cancelBinding.SetHelp("esc", "press again to cancel")
1843+ } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1844+ cancelBinding.SetHelp("esc", "clear queue")
1845+ }
1846+ binds = append(binds, []key.Binding{cancelBinding})
1847+ }
1848+
1849+ mainBinds := []key.Binding{}
1850+ tab := k.Tab
1851+ if m.focus == uiFocusEditor {
1852+ tab.SetHelp("tab", "focus chat")
1853+ } else {
1854+ tab.SetHelp("tab", "focus editor")
1855+ }
1856+
1857+ mainBinds = append(mainBinds,
1858+ tab,
1859+ commands,
1860+ k.Models,
1861+ k.Sessions,
1862+ )
1863+ if hasSession {
1864+ mainBinds = append(mainBinds, k.Chat.NewSession)
1865+ }
1866+
1867+ binds = append(binds, mainBinds)
1868+
1869+ switch m.focus {
1870+ case uiFocusEditor:
1871+ binds = append(binds,
1872+ []key.Binding{
1873+ k.Editor.Newline,
1874+ k.Editor.AddImage,
1875+ k.Editor.MentionFile,
1876+ k.Editor.OpenEditor,
1877+ },
1878+ )
1879+ if hasAttachments {
1880+ binds = append(binds,
1881+ []key.Binding{
1882+ k.Editor.AttachmentDeleteMode,
1883+ k.Editor.DeleteAllAttachments,
1884+ k.Editor.Escape,
1885+ },
1886+ )
1887+ }
1888+ case uiFocusMain:
1889+ binds = append(binds,
1890+ []key.Binding{
1891+ k.Chat.UpDown,
1892+ k.Chat.UpDownOneItem,
1893+ k.Chat.PageUp,
1894+ k.Chat.PageDown,
1895+ },
1896+ []key.Binding{
1897+ k.Chat.HalfPageUp,
1898+ k.Chat.HalfPageDown,
1899+ k.Chat.Home,
1900+ k.Chat.End,
1901+ },
1902+ []key.Binding{
1903+ k.Chat.Copy,
1904+ k.Chat.ClearHighlight,
1905+ },
1906+ )
1907+ if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1908+ binds = append(binds, []key.Binding{k.Chat.PillLeft})
1909+ }
1910+ }
1911+ default:
1912+ if m.session == nil {
1913+ // no session selected
1914+ binds = append(binds,
1915+ []key.Binding{
1916+ commands,
1917+ k.Models,
1918+ k.Sessions,
1919+ },
1920+ []key.Binding{
1921+ k.Editor.Newline,
1922+ k.Editor.AddImage,
1923+ k.Editor.MentionFile,
1924+ k.Editor.OpenEditor,
1925+ },
1926+ )
1927+ if hasAttachments {
1928+ binds = append(binds,
1929+ []key.Binding{
1930+ k.Editor.AttachmentDeleteMode,
1931+ k.Editor.DeleteAllAttachments,
1932+ k.Editor.Escape,
1933+ },
1934+ )
1935+ }
1936+ binds = append(binds,
1937+ []key.Binding{
1938+ help,
1939+ },
1940+ )
1941+ }
1942+ }
1943+
1944+ binds = append(binds,
1945+ []key.Binding{
1946+ help,
1947+ k.Quit,
1948+ },
1949+ )
1950+
1951+ return binds
1952+}
1953+
1954+// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1955+func (m *UI) toggleCompactMode() tea.Cmd {
1956+ m.forceCompactMode = !m.forceCompactMode
1957+
1958+ err := m.com.Config().SetCompactMode(m.forceCompactMode)
1959+ if err != nil {
1960+ return uiutil.ReportError(err)
1961+ }
1962+
1963+ m.handleCompactMode(m.width, m.height)
1964+ m.updateLayoutAndSize()
1965+
1966+ return nil
1967+}
1968+
1969+// handleCompactMode updates the UI state based on window size and compact mode setting.
1970+func (m *UI) handleCompactMode(newWidth, newHeight int) {
1971+ if m.state == uiChat {
1972+ if m.forceCompactMode {
1973+ m.isCompact = true
1974+ return
1975+ }
1976+ if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1977+ m.isCompact = true
1978+ } else {
1979+ m.isCompact = false
1980+ }
1981+ }
1982+}
1983+
1984+// updateLayoutAndSize updates the layout and sizes of UI components.
1985+func (m *UI) updateLayoutAndSize() {
1986+ m.layout = m.generateLayout(m.width, m.height)
1987+ m.updateSize()
1988+}
1989+
1990+// updateSize updates the sizes of UI components based on the current layout.
1991+func (m *UI) updateSize() {
1992+ // Set status width
1993+ m.status.SetWidth(m.layout.status.Dx())
1994+
1995+ m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1996+ m.textarea.SetWidth(m.layout.editor.Dx())
1997+ m.textarea.SetHeight(m.layout.editor.Dy())
1998+ m.renderPills()
1999+
2000+ // Handle different app states
2001+ switch m.state {
2002+ case uiConfigure, uiInitialize, uiLanding:
2003+ m.renderHeader(false, m.layout.header.Dx())
2004+
2005+ case uiChat:
2006+ if m.isCompact {
2007+ m.renderHeader(true, m.layout.header.Dx())
2008+ } else {
2009+ m.renderSidebarLogo(m.layout.sidebar.Dx())
2010+ }
2011+ }
2012+}
2013+
2014+// generateLayout calculates the layout rectangles for all UI components based
2015+// on the current UI state and terminal dimensions.
2016+func (m *UI) generateLayout(w, h int) layout {
2017+ // The screen area we're working with
2018+ area := image.Rect(0, 0, w, h)
2019+
2020+ // The help height
2021+ helpHeight := 1
2022+ // The editor height
2023+ editorHeight := 5
2024+ // The sidebar width
2025+ sidebarWidth := 30
2026+ // The header height
2027+ const landingHeaderHeight = 4
2028+
2029+ var helpKeyMap help.KeyMap = m
2030+ if m.status.ShowingAll() {
2031+ for _, row := range helpKeyMap.FullHelp() {
2032+ helpHeight = max(helpHeight, len(row))
2033+ }
2034+ }
2035+
2036+ // Add app margins
2037+ appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
2038+ appRect.Min.Y += 1
2039+ appRect.Max.Y -= 1
2040+ helpRect.Min.Y -= 1
2041+ appRect.Min.X += 1
2042+ appRect.Max.X -= 1
2043+
2044+ if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
2045+ // extra padding on left and right for these states
2046+ appRect.Min.X += 1
2047+ appRect.Max.X -= 1
2048+ }
2049+
2050+ layout := layout{
2051+ area: area,
2052+ status: helpRect,
2053+ }
2054+
2055+ // Handle different app states
2056+ switch m.state {
2057+ case uiConfigure, uiInitialize:
2058+ // Layout
2059+ //
2060+ // header
2061+ // ------
2062+ // main
2063+ // ------
2064+ // help
2065+
2066+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2067+ layout.header = headerRect
2068+ layout.main = mainRect
2069+
2070+ case uiLanding:
2071+ // Layout
2072+ //
2073+ // header
2074+ // ------
2075+ // main
2076+ // ------
2077+ // editor
2078+ // ------
2079+ // help
2080+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2081+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2082+ // Remove extra padding from editor (but keep it for header and main)
2083+ editorRect.Min.X -= 1
2084+ editorRect.Max.X += 1
2085+ layout.header = headerRect
2086+ layout.main = mainRect
2087+ layout.editor = editorRect
2088+
2089+ case uiChat:
2090+ if m.isCompact {
2091+ // Layout
2092+ //
2093+ // compact-header
2094+ // ------
2095+ // main
2096+ // ------
2097+ // editor
2098+ // ------
2099+ // help
2100+ const compactHeaderHeight = 1
2101+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
2102+ detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2103+ sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
2104+ layout.sessionDetails = sessionDetailsArea
2105+ layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2106+ // Add one line gap between header and main content
2107+ mainRect.Min.Y += 1
2108+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2109+ mainRect.Max.X -= 1 // Add padding right
2110+ layout.header = headerRect
2111+ pillsHeight := m.pillsAreaHeight()
2112+ if pillsHeight > 0 {
2113+ pillsHeight = min(pillsHeight, mainRect.Dy())
2114+ chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2115+ layout.main = chatRect
2116+ layout.pills = pillsRect
2117+ } else {
2118+ layout.main = mainRect
2119+ }
2120+ // Add bottom margin to main
2121+ layout.main.Max.Y -= 1
2122+ layout.editor = editorRect
2123+ } else {
2124+ // Layout
2125+ //
2126+ // ------|---
2127+ // main |
2128+ // ------| side
2129+ // editor|
2130+ // ----------
2131+ // help
2132+
2133+ mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
2134+ // Add padding left
2135+ sideRect.Min.X += 1
2136+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2137+ mainRect.Max.X -= 1 // Add padding right
2138+ layout.sidebar = sideRect
2139+ pillsHeight := m.pillsAreaHeight()
2140+ if pillsHeight > 0 {
2141+ pillsHeight = min(pillsHeight, mainRect.Dy())
2142+ chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2143+ layout.main = chatRect
2144+ layout.pills = pillsRect
2145+ } else {
2146+ layout.main = mainRect
2147+ }
2148+ // Add bottom margin to main
2149+ layout.main.Max.Y -= 1
2150+ layout.editor = editorRect
2151+ }
2152+ }
2153+
2154+ if !layout.editor.Empty() {
2155+ // Add editor margins 1 top and bottom
2156+ layout.editor.Min.Y += 1
2157+ layout.editor.Max.Y -= 1
2158+ }
2159+
2160+ return layout
2161+}
2162+
2163+// layout defines the positioning of UI elements.
2164+type layout struct {
2165+ // area is the overall available area.
2166+ area uv.Rectangle
2167+
2168+ // header is the header shown in special cases
2169+ // e.x when the sidebar is collapsed
2170+ // or when in the landing page
2171+ // or in init/config
2172+ header uv.Rectangle
2173+
2174+ // main is the area for the main pane. (e.x chat, configure, landing)
2175+ main uv.Rectangle
2176+
2177+ // pills is the area for the pills panel.
2178+ pills uv.Rectangle
2179+
2180+ // editor is the area for the editor pane.
2181+ editor uv.Rectangle
2182+
2183+ // sidebar is the area for the sidebar.
2184+ sidebar uv.Rectangle
2185+
2186+ // status is the area for the status view.
2187+ status uv.Rectangle
2188+
2189+ // session details is the area for the session details overlay in compact mode.
2190+ sessionDetails uv.Rectangle
2191+}
2192+
2193+func (m *UI) openEditor(value string) tea.Cmd {
2194+ tmpfile, err := os.CreateTemp("", "msg_*.md")
2195+ if err != nil {
2196+ return uiutil.ReportError(err)
2197+ }
2198+ defer tmpfile.Close() //nolint:errcheck
2199+ if _, err := tmpfile.WriteString(value); err != nil {
2200+ return uiutil.ReportError(err)
2201+ }
2202+ cmd, err := editor.Command(
2203+ "crush",
2204+ tmpfile.Name(),
2205+ editor.AtPosition(
2206+ m.textarea.Line()+1,
2207+ m.textarea.Column()+1,
2208+ ),
2209+ )
2210+ if err != nil {
2211+ return uiutil.ReportError(err)
2212+ }
2213+ return tea.ExecProcess(cmd, func(err error) tea.Msg {
2214+ if err != nil {
2215+ return uiutil.ReportError(err)
2216+ }
2217+ content, err := os.ReadFile(tmpfile.Name())
2218+ if err != nil {
2219+ return uiutil.ReportError(err)
2220+ }
2221+ if len(content) == 0 {
2222+ return uiutil.ReportWarn("Message is empty")
2223+ }
2224+ os.Remove(tmpfile.Name())
2225+ return openEditorMsg{
2226+ Text: strings.TrimSpace(string(content)),
2227+ }
2228+ })
2229+}
2230+
2231+// setEditorPrompt configures the textarea prompt function based on whether
2232+// yolo mode is enabled.
2233+func (m *UI) setEditorPrompt(yolo bool) {
2234+ if yolo {
2235+ m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2236+ return
2237+ }
2238+ m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2239+}
2240+
2241+// normalPromptFunc returns the normal editor prompt style (" > " on first
2242+// line, "::: " on subsequent lines).
2243+func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2244+ t := m.com.Styles
2245+ if info.LineNumber == 0 {
2246+ if info.Focused {
2247+ return " > "
2248+ }
2249+ return "::: "
2250+ }
2251+ if info.Focused {
2252+ return t.EditorPromptNormalFocused.Render()
2253+ }
2254+ return t.EditorPromptNormalBlurred.Render()
2255+}
2256+
2257+// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2258+// and colored dots.
2259+func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2260+ t := m.com.Styles
2261+ if info.LineNumber == 0 {
2262+ if info.Focused {
2263+ return t.EditorPromptYoloIconFocused.Render()
2264+ } else {
2265+ return t.EditorPromptYoloIconBlurred.Render()
2266+ }
2267+ }
2268+ if info.Focused {
2269+ return t.EditorPromptYoloDotsFocused.Render()
2270+ }
2271+ return t.EditorPromptYoloDotsBlurred.Render()
2272+}
2273+
2274+// closeCompletions closes the completions popup and resets state.
2275+func (m *UI) closeCompletions() {
2276+ m.completionsOpen = false
2277+ m.completionsQuery = ""
2278+ m.completionsStartIndex = 0
2279+ m.completions.Close()
2280+}
2281+
2282+// insertFileCompletion inserts the selected file path into the textarea,
2283+// replacing the @query, and adds the file as an attachment.
2284+func (m *UI) insertFileCompletion(path string) tea.Cmd {
2285+ value := m.textarea.Value()
2286+ word := m.textareaWord()
2287+
2288+ // Find the @ and query to replace.
2289+ if m.completionsStartIndex > len(value) {
2290+ return nil
2291+ }
2292+
2293+ // Build the new value: everything before @, the path, everything after query.
2294+ endIdx := min(m.completionsStartIndex+len(word), len(value))
2295+
2296+ newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
2297+ m.textarea.SetValue(newValue)
2298+ m.textarea.MoveToEnd()
2299+ m.textarea.InsertRune(' ')
2300+
2301+ return func() tea.Msg {
2302+ absPath, _ := filepath.Abs(path)
2303+ // Skip attachment if file was already read and hasn't been modified.
2304+ lastRead := filetracker.LastReadTime(absPath)
2305+ if !lastRead.IsZero() {
2306+ if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2307+ return nil
2308+ }
2309+ }
2310+
2311+ // Add file as attachment.
2312+ content, err := os.ReadFile(path)
2313+ if err != nil {
2314+ // If it fails, let the LLM handle it later.
2315+ return nil
2316+ }
2317+ filetracker.RecordRead(absPath)
2318+
2319+ return message.Attachment{
2320+ FilePath: path,
2321+ FileName: filepath.Base(path),
2322+ MimeType: mimeOf(content),
2323+ Content: content,
2324+ }
2325+ }
2326+}
2327+
2328+// completionsPosition returns the X and Y position for the completions popup.
2329+func (m *UI) completionsPosition() image.Point {
2330+ cur := m.textarea.Cursor()
2331+ if cur == nil {
2332+ return image.Point{
2333+ X: m.layout.editor.Min.X,
2334+ Y: m.layout.editor.Min.Y,
2335+ }
2336+ }
2337+ return image.Point{
2338+ X: cur.X + m.layout.editor.Min.X,
2339+ Y: m.layout.editor.Min.Y + cur.Y,
2340+ }
2341+}
2342+
2343+// textareaWord returns the current word at the cursor position.
2344+func (m *UI) textareaWord() string {
2345+ return m.textarea.Word()
2346+}
2347+
2348+// isWhitespace returns true if the byte is a whitespace character.
2349+func isWhitespace(b byte) bool {
2350+ return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2351+}
2352+
2353+// isAgentBusy returns true if the agent coordinator exists and is currently
2354+// busy processing a request.
2355+func (m *UI) isAgentBusy() bool {
2356+ return m.com.App != nil &&
2357+ m.com.App.AgentCoordinator != nil &&
2358+ m.com.App.AgentCoordinator.IsBusy()
2359+}
2360+
2361+// hasSession returns true if there is an active session with a valid ID.
2362+func (m *UI) hasSession() bool {
2363+ return m.session != nil && m.session.ID != ""
2364+}
2365+
2366+// mimeOf detects the MIME type of the given content.
2367+func mimeOf(content []byte) string {
2368+ mimeBufferSize := min(512, len(content))
2369+ return http.DetectContentType(content[:mimeBufferSize])
2370+}
2371+
2372+var readyPlaceholders = [...]string{
2373+ "Ready!",
2374+ "Ready...",
2375+ "Ready?",
2376+ "Ready for instructions",
2377+}
2378+
2379+var workingPlaceholders = [...]string{
2380+ "Working!",
2381+ "Working...",
2382+ "Brrrrr...",
2383+ "Prrrrrrrr...",
2384+ "Processing...",
2385+ "Thinking...",
2386+}
2387+
2388+// randomizePlaceholders selects random placeholder text for the textarea's
2389+// ready and working states.
2390+func (m *UI) randomizePlaceholders() {
2391+ m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2392+ m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2393+}
2394+
2395+// renderEditorView renders the editor view with attachments if any.
2396+func (m *UI) renderEditorView(width int) string {
2397+ if len(m.attachments.List()) == 0 {
2398+ return m.textarea.View()
2399+ }
2400+ return lipgloss.JoinVertical(
2401+ lipgloss.Top,
2402+ m.attachments.Render(width),
2403+ m.textarea.View(),
2404+ )
2405+}
2406+
2407+// renderHeader renders and caches the header logo at the specified width.
2408+func (m *UI) renderHeader(compact bool, width int) {
2409+ if compact && m.session != nil && m.com.App != nil {
2410+ m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2411+ } else {
2412+ m.header = renderLogo(m.com.Styles, compact, width)
2413+ }
2414+}
2415+
2416+// renderSidebarLogo renders and caches the sidebar logo at the specified
2417+// width.
2418+func (m *UI) renderSidebarLogo(width int) {
2419+ m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2420+}
2421+
2422+// sendMessage sends a message with the given content and attachments.
2423+func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2424+ if m.com.App.AgentCoordinator == nil {
2425+ return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2426+ }
2427+
2428+ var cmds []tea.Cmd
2429+ if !m.hasSession() {
2430+ newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2431+ if err != nil {
2432+ return uiutil.ReportError(err)
2433+ }
2434+ m.state = uiChat
2435+ if m.forceCompactMode {
2436+ m.isCompact = true
2437+ }
2438+ if newSession.ID != "" {
2439+ m.session = &newSession
2440+ cmds = append(cmds, m.loadSession(newSession.ID))
2441+ }
2442+ }
2443+
2444+ // Capture session ID to avoid race with main goroutine updating m.session.
2445+ sessionID := m.session.ID
2446+ cmds = append(cmds, func() tea.Msg {
2447+ _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2448+ if err != nil {
2449+ isCancelErr := errors.Is(err, context.Canceled)
2450+ isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2451+ if isCancelErr || isPermissionErr {
2452+ return nil
2453+ }
2454+ return uiutil.InfoMsg{
2455+ Type: uiutil.InfoTypeError,
2456+ Msg: err.Error(),
2457+ }
2458+ }
2459+ return nil
2460+ })
2461+ return tea.Batch(cmds...)
2462+}
2463+
2464+const cancelTimerDuration = 2 * time.Second
2465+
2466+// cancelTimerCmd creates a command that expires the cancel timer.
2467+func cancelTimerCmd() tea.Cmd {
2468+ return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2469+ return cancelTimerExpiredMsg{}
2470+ })
2471+}
2472+
2473+// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2474+// and starts a timer. The second press (before the timer expires) actually
2475+// cancels the agent.
2476+func (m *UI) cancelAgent() tea.Cmd {
2477+ if !m.hasSession() {
2478+ return nil
2479+ }
2480+
2481+ coordinator := m.com.App.AgentCoordinator
2482+ if coordinator == nil {
2483+ return nil
2484+ }
2485+
2486+ if m.isCanceling {
2487+ // Second escape press - actually cancel the agent.
2488+ m.isCanceling = false
2489+ coordinator.Cancel(m.session.ID)
2490+ // Stop the spinning todo indicator.
2491+ m.todoIsSpinning = false
2492+ m.renderPills()
2493+ return nil
2494+ }
2495+
2496+ // Check if there are queued prompts - if so, clear the queue.
2497+ if coordinator.QueuedPrompts(m.session.ID) > 0 {
2498+ coordinator.ClearQueue(m.session.ID)
2499+ return nil
2500+ }
2501+
2502+ // First escape press - set canceling state and start timer.
2503+ m.isCanceling = true
2504+ return cancelTimerCmd()
2505+}
2506+
2507+// openDialog opens a dialog by its ID.
2508+func (m *UI) openDialog(id string) tea.Cmd {
2509+ var cmds []tea.Cmd
2510+ switch id {
2511+ case dialog.SessionsID:
2512+ if cmd := m.openSessionsDialog(); cmd != nil {
2513+ cmds = append(cmds, cmd)
2514+ }
2515+ case dialog.ModelsID:
2516+ if cmd := m.openModelsDialog(); cmd != nil {
2517+ cmds = append(cmds, cmd)
2518+ }
2519+ case dialog.CommandsID:
2520+ if cmd := m.openCommandsDialog(); cmd != nil {
2521+ cmds = append(cmds, cmd)
2522+ }
2523+ case dialog.ReasoningID:
2524+ if cmd := m.openReasoningDialog(); cmd != nil {
2525+ cmds = append(cmds, cmd)
2526+ }
2527+ case dialog.QuitID:
2528+ if cmd := m.openQuitDialog(); cmd != nil {
2529+ cmds = append(cmds, cmd)
2530+ }
2531+ default:
2532+ // Unknown dialog
2533+ break
2534+ }
2535+ return tea.Batch(cmds...)
2536+}
2537+
2538+// openQuitDialog opens the quit confirmation dialog.
2539+func (m *UI) openQuitDialog() tea.Cmd {
2540+ if m.dialog.ContainsDialog(dialog.QuitID) {
2541+ // Bring to front
2542+ m.dialog.BringToFront(dialog.QuitID)
2543+ return nil
2544+ }
2545+
2546+ quitDialog := dialog.NewQuit(m.com)
2547+ m.dialog.OpenDialog(quitDialog)
2548+ return nil
2549+}
2550+
2551+// openModelsDialog opens the models dialog.
2552+func (m *UI) openModelsDialog() tea.Cmd {
2553+ if m.dialog.ContainsDialog(dialog.ModelsID) {
2554+ // Bring to front
2555+ m.dialog.BringToFront(dialog.ModelsID)
2556+ return nil
2557+ }
2558+
2559+ modelsDialog, err := dialog.NewModels(m.com)
2560+ if err != nil {
2561+ return uiutil.ReportError(err)
2562+ }
2563+
2564+ m.dialog.OpenDialog(modelsDialog)
2565+
2566+ return nil
2567+}
2568+
2569+// openCommandsDialog opens the commands dialog.
2570+func (m *UI) openCommandsDialog() tea.Cmd {
2571+ if m.dialog.ContainsDialog(dialog.CommandsID) {
2572+ // Bring to front
2573+ m.dialog.BringToFront(dialog.CommandsID)
2574+ return nil
2575+ }
2576+
2577+ sessionID := ""
2578+ if m.session != nil {
2579+ sessionID = m.session.ID
2580+ }
2581+
2582+ commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2583+ if err != nil {
2584+ return uiutil.ReportError(err)
2585+ }
2586+
2587+ m.dialog.OpenDialog(commands)
2588+
2589+ return nil
2590+}
2591+
2592+// openReasoningDialog opens the reasoning effort dialog.
2593+func (m *UI) openReasoningDialog() tea.Cmd {
2594+ if m.dialog.ContainsDialog(dialog.ReasoningID) {
2595+ m.dialog.BringToFront(dialog.ReasoningID)
2596+ return nil
2597+ }
2598+
2599+ reasoningDialog, err := dialog.NewReasoning(m.com)
2600+ if err != nil {
2601+ return uiutil.ReportError(err)
2602+ }
2603+
2604+ m.dialog.OpenDialog(reasoningDialog)
2605+ return nil
2606+}
2607+
2608+// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2609+// it brings it to the front. Otherwise, it will list all the sessions and open
2610+// the dialog.
2611+func (m *UI) openSessionsDialog() tea.Cmd {
2612+ if m.dialog.ContainsDialog(dialog.SessionsID) {
2613+ // Bring to front
2614+ m.dialog.BringToFront(dialog.SessionsID)
2615+ return nil
2616+ }
2617+
2618+ selectedSessionID := ""
2619+ if m.session != nil {
2620+ selectedSessionID = m.session.ID
2621+ }
2622+
2623+ dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2624+ if err != nil {
2625+ return uiutil.ReportError(err)
2626+ }
2627+
2628+ m.dialog.OpenDialog(dialog)
2629+ return nil
2630+}
2631+
2632+// openFilesDialog opens the file picker dialog.
2633+func (m *UI) openFilesDialog() tea.Cmd {
2634+ if m.dialog.ContainsDialog(dialog.FilePickerID) {
2635+ // Bring to front
2636+ m.dialog.BringToFront(dialog.FilePickerID)
2637+ return nil
2638+ }
2639+
2640+ filePicker, cmd := dialog.NewFilePicker(m.com)
2641+ filePicker.SetImageCapabilities(&m.imgCaps)
2642+ m.dialog.OpenDialog(filePicker)
2643+
2644+ return cmd
2645+}
2646+
2647+// openPermissionsDialog opens the permissions dialog for a permission request.
2648+func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2649+ // Close any existing permissions dialog first.
2650+ m.dialog.CloseDialog(dialog.PermissionsID)
2651+
2652+ // Get diff mode from config.
2653+ var opts []dialog.PermissionsOption
2654+ if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2655+ opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2656+ }
2657+
2658+ permDialog := dialog.NewPermissions(m.com, perm, opts...)
2659+ m.dialog.OpenDialog(permDialog)
2660+ return nil
2661+}
2662+
2663+// handlePermissionNotification updates tool items when permission state changes.
2664+func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2665+ toolItem := m.chat.MessageItem(notification.ToolCallID)
2666+ if toolItem == nil {
2667+ return
2668+ }
2669+
2670+ if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2671+ if notification.Granted {
2672+ permItem.SetStatus(chat.ToolStatusRunning)
2673+ } else {
2674+ permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2675+ }
2676+ }
2677+}
2678+
2679+// newSession clears the current session state and prepares for a new session.
2680+// The actual session creation happens when the user sends their first message.
2681+func (m *UI) newSession() {
2682+ if !m.hasSession() {
2683+ return
2684+ }
2685+
2686+ m.session = nil
2687+ m.sessionFiles = nil
2688+ m.state = uiLanding
2689+ m.focus = uiFocusEditor
2690+ m.textarea.Focus()
2691+ m.chat.Blur()
2692+ m.chat.ClearMessages()
2693+ m.pillsExpanded = false
2694+ m.promptQueue = 0
2695+ m.pillsView = ""
2696+}
2697+
2698+// handlePasteMsg handles a paste message.
2699+func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2700+ if m.dialog.HasDialogs() {
2701+ return m.handleDialogMsg(msg)
2702+ }
2703+
2704+ if m.focus != uiFocusEditor {
2705+ return nil
2706+ }
2707+
2708+ if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
2709+ return func() tea.Msg {
2710+ content := []byte(msg.Content)
2711+ if int64(len(content)) > common.MaxAttachmentSize {
2712+ return uiutil.ReportWarn("Paste is too big (>5mb)")
2713+ }
2714+ name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2715+ mimeBufferSize := min(512, len(content))
2716+ mimeType := http.DetectContentType(content[:mimeBufferSize])
2717+ return message.Attachment{
2718+ FileName: name,
2719+ FilePath: name,
2720+ MimeType: mimeType,
2721+ Content: content,
2722+ }
2723+ }
2724+ }
2725+
2726+ var cmd tea.Cmd
2727+ path := strings.ReplaceAll(msg.Content, "\\ ", " ")
2728+ // Try to get an image.
2729+ path, err := filepath.Abs(strings.TrimSpace(path))
2730+ if err != nil {
2731+ m.textarea, cmd = m.textarea.Update(msg)
2732+ return cmd
2733+ }
2734+
2735+ // Check if file has an allowed image extension.
2736+ isAllowedType := false
2737+ lowerPath := strings.ToLower(path)
2738+ for _, ext := range common.AllowedImageTypes {
2739+ if strings.HasSuffix(lowerPath, ext) {
2740+ isAllowedType = true
2741+ break
2742+ }
2743+ }
2744+ if !isAllowedType {
2745+ m.textarea, cmd = m.textarea.Update(msg)
2746+ return cmd
2747+ }
2748+
2749+ return func() tea.Msg {
2750+ fileInfo, err := os.Stat(path)
2751+ if err != nil {
2752+ return uiutil.ReportError(err)
2753+ }
2754+ if fileInfo.Size() > common.MaxAttachmentSize {
2755+ return uiutil.ReportWarn("File is too big (>5mb)")
2756+ }
2757+
2758+ content, err := os.ReadFile(path)
2759+ if err != nil {
2760+ return uiutil.ReportError(err)
2761+ }
2762+
2763+ mimeBufferSize := min(512, len(content))
2764+ mimeType := http.DetectContentType(content[:mimeBufferSize])
2765+ fileName := filepath.Base(path)
2766+ return message.Attachment{
2767+ FilePath: path,
2768+ FileName: fileName,
2769+ MimeType: mimeType,
2770+ Content: content,
2771+ }
2772+ }
2773+}
2774+
2775+var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2776+
2777+func (m *UI) pasteIdx() int {
2778+ result := 0
2779+ for _, at := range m.attachments.List() {
2780+ found := pasteRE.FindStringSubmatch(at.FileName)
2781+ if len(found) == 0 {
2782+ continue
2783+ }
2784+ idx, err := strconv.Atoi(found[1])
2785+ if err == nil {
2786+ result = max(result, idx)
2787+ }
2788+ }
2789+ return result + 1
2790+}
2791+
2792+// drawSessionDetails draws the session details in compact mode.
2793+func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2794+ if m.session == nil {
2795+ return
2796+ }
2797+
2798+ s := m.com.Styles
2799+
2800+ width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2801+ height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2802+
2803+ title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2804+ blocks := []string{
2805+ title,
2806+ "",
2807+ m.modelInfo(width),
2808+ "",
2809+ }
2810+
2811+ detailsHeader := lipgloss.JoinVertical(
2812+ lipgloss.Left,
2813+ blocks...,
2814+ )
2815+
2816+ version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2817+
2818+ remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2819+
2820+ const maxSectionWidth = 50
2821+ sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2822+ maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
2823+
2824+ lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2825+ mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2826+ filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2827+ sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2828+ uv.NewStyledString(
2829+ s.CompactDetails.View.
2830+ Width(area.Dx()).
2831+ Render(
2832+ lipgloss.JoinVertical(
2833+ lipgloss.Left,
2834+ detailsHeader,
2835+ sections,
2836+ version,
2837+ ),
2838+ ),
2839+ ).Draw(scr, area)
2840+}
2841+
2842+func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2843+ load := func() tea.Msg {
2844+ prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2845+ if err != nil {
2846+ // TODO: make this better
2847+ return uiutil.ReportError(err)()
2848+ }
2849+
2850+ if prompt == "" {
2851+ return nil
2852+ }
2853+ return sendMessageMsg{
2854+ Content: prompt,
2855+ }
2856+ }
2857+
2858+ var cmds []tea.Cmd
2859+ if cmd := m.dialog.StartLoading(); cmd != nil {
2860+ cmds = append(cmds, cmd)
2861+ }
2862+ cmds = append(cmds, load, func() tea.Msg {
2863+ return closeDialogMsg{}
2864+ })
2865+
2866+ return tea.Sequence(cmds...)
2867+}
2868+
2869+func (m *UI) copyChatHighlight() tea.Cmd {
2870+ text := m.chat.HighlighContent()
2871+ return tea.Sequence(
2872+ tea.SetClipboard(text),
2873+ func() tea.Msg {
2874+ _ = clipboard.WriteAll(text)
2875+ return nil
2876+ },
2877+ func() tea.Msg {
2878+ m.chat.ClearMouse()
2879+ return nil
2880+ },
2881+ uiutil.ReportInfo("Selected text copied to clipboard"),
2882+ )
2883+}
2884+
2885+// renderLogo renders the Crush logo with the given styles and dimensions.
2886+func renderLogo(t *styles.Styles, compact bool, width int) string {
2887+ return logo.Render(version.Version, compact, logo.Opts{
2888+ FieldColor: t.LogoFieldColor,
2889+ TitleColorA: t.LogoTitleColorA,
2890+ TitleColorB: t.LogoTitleColorB,
2891+ CharmColor: t.LogoCharmColor,
2892+ VersionColor: t.LogoVersionColor,
2893+ Width: width,
2894+ })
2895+}
internal/ui/styles/grad.gonew file+117
@@ -0,0 +1,117 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "strings"
7+
8+ "github.com/lucasb-eyer/go-colorful"
9+ "github.com/rivo/uniseg"
10+)
11+
12+// ForegroundGrad returns a slice of strings representing the input string
13+// rendered with a horizontal gradient foreground from color1 to color2. Each
14+// string in the returned slice corresponds to a grapheme cluster in the input
15+// string. If bold is true, the rendered strings will be bolded.
16+func ForegroundGrad(t *Styles, input string, bold bool, color1, color2 color.Color) []string {
17+ if input == "" {
18+ return []string{""}
19+ }
20+ if len(input) == 1 {
21+ style := t.Base.Foreground(color1)
22+ if bold {
23+ style.Bold(true)
24+ }
25+ return []string{style.Render(input)}
26+ }
27+ var clusters []string
28+ gr := uniseg.NewGraphemes(input)
29+ for gr.Next() {
30+ clusters = append(clusters, string(gr.Runes()))
31+ }
32+
33+ ramp := blendColors(len(clusters), color1, color2)
34+ for i, c := range ramp {
35+ style := t.Base.Foreground(c)
36+ if bold {
37+ style.Bold(true)
38+ }
39+ clusters[i] = style.Render(clusters[i])
40+ }
41+ return clusters
42+}
43+
44+// ApplyForegroundGrad renders a given string with a horizontal gradient
45+// foreground.
46+func ApplyForegroundGrad(t *Styles, input string, color1, color2 color.Color) string {
47+ if input == "" {
48+ return ""
49+ }
50+ var o strings.Builder
51+ clusters := ForegroundGrad(t, input, false, color1, color2)
52+ for _, c := range clusters {
53+ fmt.Fprint(&o, c)
54+ }
55+ return o.String()
56+}
57+
58+// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
59+// foreground.
60+func ApplyBoldForegroundGrad(t *Styles, input string, color1, color2 color.Color) string {
61+ if input == "" {
62+ return ""
63+ }
64+ var o strings.Builder
65+ clusters := ForegroundGrad(t, input, true, color1, color2)
66+ for _, c := range clusters {
67+ fmt.Fprint(&o, c)
68+ }
69+ return o.String()
70+}
71+
72+// blendColors returns a slice of colors blended between the given keys.
73+// Blending is done in Hcl to stay in gamut.
74+func blendColors(size int, stops ...color.Color) []color.Color {
75+ if len(stops) < 2 {
76+ return nil
77+ }
78+
79+ stopsPrime := make([]colorful.Color, len(stops))
80+ for i, k := range stops {
81+ stopsPrime[i], _ = colorful.MakeColor(k)
82+ }
83+
84+ numSegments := len(stopsPrime) - 1
85+ blended := make([]color.Color, 0, size)
86+
87+ // Calculate how many colors each segment should have.
88+ segmentSizes := make([]int, numSegments)
89+ baseSize := size / numSegments
90+ remainder := size % numSegments
91+
92+ // Distribute the remainder across segments.
93+ for i := range numSegments {
94+ segmentSizes[i] = baseSize
95+ if i < remainder {
96+ segmentSizes[i]++
97+ }
98+ }
99+
100+ // Generate colors for each segment.
101+ for i := range numSegments {
102+ c1 := stopsPrime[i]
103+ c2 := stopsPrime[i+1]
104+ segmentSize := segmentSizes[i]
105+
106+ for j := range segmentSize {
107+ var t float64
108+ if segmentSize > 1 {
109+ t = float64(j) / float64(segmentSize-1)
110+ }
111+ c := c1.BlendHcl(c2, t)
112+ blended = append(blended, c)
113+ }
114+ }
115+
116+ return blended
117+}
internal/ui/styles/styles.gonew file+1344
@@ -0,0 +1,1344 @@
1+package styles
2+
3+import (
4+ "image/color"
5+
6+ "charm.land/bubbles/v2/filepicker"
7+ "charm.land/bubbles/v2/help"
8+ "charm.land/bubbles/v2/textarea"
9+ "charm.land/bubbles/v2/textinput"
10+ tea "charm.land/bubbletea/v2"
11+ "charm.land/glamour/v2/ansi"
12+ "charm.land/lipgloss/v2"
13+ "github.com/alecthomas/chroma/v2"
14+ "github.com/charmbracelet/crush/internal/tui/exp/diffview"
15+ "github.com/charmbracelet/x/exp/charmtone"
16+)
17+
18+const (
19+ CheckIcon string = "✓"
20+ ErrorIcon string = "×"
21+ WarningIcon string = "⚠"
22+ InfoIcon string = "ⓘ"
23+ HintIcon string = "∵"
24+ SpinnerIcon string = "⋯"
25+ LoadingIcon string = "⟳"
26+ ModelIcon string = "◇"
27+
28+ ArrowRightIcon string = "→"
29+
30+ ToolPending string = "●"
31+ ToolSuccess string = "✓"
32+ ToolError string = "×"
33+
34+ RadioOn string = "◉"
35+ RadioOff string = "○"
36+
37+ BorderThin string = "│"
38+ BorderThick string = "▌"
39+
40+ SectionSeparator string = "─"
41+
42+ TodoCompletedIcon string = "✓"
43+ TodoPendingIcon string = "•"
44+ TodoInProgressIcon string = "→"
45+
46+ ImageIcon string = "■"
47+ TextIcon string = "≡"
48+
49+ ScrollbarThumb string = "┃"
50+ ScrollbarTrack string = "│"
51+)
52+
53+const (
54+ defaultMargin = 2
55+ defaultListIndent = 2
56+)
57+
58+type Styles struct {
59+ WindowTooSmall lipgloss.Style
60+
61+ // Reusable text styles
62+ Base lipgloss.Style
63+ Muted lipgloss.Style
64+ HalfMuted lipgloss.Style
65+ Subtle lipgloss.Style
66+
67+ // Tags
68+ TagBase lipgloss.Style
69+ TagError lipgloss.Style
70+ TagInfo lipgloss.Style
71+
72+ // Header
73+ Header struct {
74+ Charm lipgloss.Style // Style for "Charm™" label
75+ Diagonals lipgloss.Style // Style for diagonal separators (╱)
76+ Percentage lipgloss.Style // Style for context percentage
77+ Keystroke lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d")
78+ KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close")
79+ WorkingDir lipgloss.Style // Style for current working directory
80+ Separator lipgloss.Style // Style for separator dots (•)
81+ }
82+
83+ CompactDetails struct {
84+ View lipgloss.Style
85+ Version lipgloss.Style
86+ Title lipgloss.Style
87+ }
88+
89+ // Panels
90+ PanelMuted lipgloss.Style
91+ PanelBase lipgloss.Style
92+
93+ // Line numbers for code blocks
94+ LineNumber lipgloss.Style
95+
96+ // Message borders
97+ FocusedMessageBorder lipgloss.Border
98+
99+ // Tool calls
100+ ToolCallPending lipgloss.Style
101+ ToolCallError lipgloss.Style
102+ ToolCallSuccess lipgloss.Style
103+ ToolCallCancelled lipgloss.Style
104+ EarlyStateMessage lipgloss.Style
105+
106+ // Text selection
107+ TextSelection lipgloss.Style
108+
109+ // LSP and MCP status indicators
110+ ItemOfflineIcon lipgloss.Style
111+ ItemBusyIcon lipgloss.Style
112+ ItemErrorIcon lipgloss.Style
113+ ItemOnlineIcon lipgloss.Style
114+
115+ // Markdown & Chroma
116+ Markdown ansi.StyleConfig
117+ PlainMarkdown ansi.StyleConfig
118+
119+ // Inputs
120+ TextInput textinput.Styles
121+ TextArea textarea.Styles
122+
123+ // Help
124+ Help help.Styles
125+
126+ // Diff
127+ Diff diffview.Style
128+
129+ // FilePicker
130+ FilePicker filepicker.Styles
131+
132+ // Buttons
133+ ButtonFocus lipgloss.Style
134+ ButtonBlur lipgloss.Style
135+
136+ // Borders
137+ BorderFocus lipgloss.Style
138+ BorderBlur lipgloss.Style
139+
140+ // Editor
141+ EditorPromptNormalFocused lipgloss.Style
142+ EditorPromptNormalBlurred lipgloss.Style
143+ EditorPromptYoloIconFocused lipgloss.Style
144+ EditorPromptYoloIconBlurred lipgloss.Style
145+ EditorPromptYoloDotsFocused lipgloss.Style
146+ EditorPromptYoloDotsBlurred lipgloss.Style
147+
148+ // Radio
149+ RadioOn lipgloss.Style
150+ RadioOff lipgloss.Style
151+
152+ // Background
153+ Background color.Color
154+
155+ // Logo
156+ LogoFieldColor color.Color
157+ LogoTitleColorA color.Color
158+ LogoTitleColorB color.Color
159+ LogoCharmColor color.Color
160+ LogoVersionColor color.Color
161+
162+ // Colors - semantic colors for tool rendering.
163+ Primary color.Color
164+ Secondary color.Color
165+ Tertiary color.Color
166+ BgBase color.Color
167+ BgBaseLighter color.Color
168+ BgSubtle color.Color
169+ BgOverlay color.Color
170+ FgBase color.Color
171+ FgMuted color.Color
172+ FgHalfMuted color.Color
173+ FgSubtle color.Color
174+ Border color.Color
175+ BorderColor color.Color // Border focus color
176+ Error color.Color
177+ Warning color.Color
178+ Info color.Color
179+ White color.Color
180+ BlueLight color.Color
181+ Blue color.Color
182+ BlueDark color.Color
183+ GreenLight color.Color
184+ Green color.Color
185+ GreenDark color.Color
186+ Red color.Color
187+ RedDark color.Color
188+ Yellow color.Color
189+
190+ // Section Title
191+ Section struct {
192+ Title lipgloss.Style
193+ Line lipgloss.Style
194+ }
195+
196+ // Initialize
197+ Initialize struct {
198+ Header lipgloss.Style
199+ Content lipgloss.Style
200+ Accent lipgloss.Style
201+ }
202+
203+ // LSP
204+ LSP struct {
205+ ErrorDiagnostic lipgloss.Style
206+ WarningDiagnostic lipgloss.Style
207+ HintDiagnostic lipgloss.Style
208+ InfoDiagnostic lipgloss.Style
209+ }
210+
211+ // Files
212+ Files struct {
213+ Path lipgloss.Style
214+ Additions lipgloss.Style
215+ Deletions lipgloss.Style
216+ }
217+
218+ // Chat
219+ Chat struct {
220+ // Message item styles
221+ Message struct {
222+ UserBlurred lipgloss.Style
223+ UserFocused lipgloss.Style
224+ AssistantBlurred lipgloss.Style
225+ AssistantFocused lipgloss.Style
226+ NoContent lipgloss.Style
227+ Thinking lipgloss.Style
228+ ErrorTag lipgloss.Style
229+ ErrorTitle lipgloss.Style
230+ ErrorDetails lipgloss.Style
231+ ToolCallFocused lipgloss.Style
232+ ToolCallCompact lipgloss.Style
233+ ToolCallBlurred lipgloss.Style
234+ SectionHeader lipgloss.Style
235+
236+ // Thinking section styles
237+ ThinkingBox lipgloss.Style // Background for thinking content
238+ ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint
239+ ThinkingFooterTitle lipgloss.Style // "Thought for" text
240+ ThinkingFooterDuration lipgloss.Style // Duration value
241+ AssistantInfoIcon lipgloss.Style
242+ AssistantInfoModel lipgloss.Style
243+ AssistantInfoProvider lipgloss.Style
244+ AssistantInfoDuration lipgloss.Style
245+ }
246+ }
247+
248+ // Tool - styles for tool call rendering
249+ Tool struct {
250+ // Icon styles with tool status
251+ IconPending lipgloss.Style // Pending operation icon
252+ IconSuccess lipgloss.Style // Successful operation icon
253+ IconError lipgloss.Style // Error operation icon
254+ IconCancelled lipgloss.Style // Cancelled operation icon
255+
256+ // Tool name styles
257+ NameNormal lipgloss.Style // Normal tool name
258+ NameNested lipgloss.Style // Nested tool name
259+
260+ // Parameter list styles
261+ ParamMain lipgloss.Style // Main parameter
262+ ParamKey lipgloss.Style // Parameter keys
263+
264+ // Content rendering styles
265+ ContentLine lipgloss.Style // Individual content line with background and width
266+ ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
267+ ContentCodeLine lipgloss.Style // Code line with background and width
268+ ContentCodeTruncation lipgloss.Style // Code truncation message with bgBase
269+ ContentCodeBg color.Color // Background color for syntax highlighting
270+ Body lipgloss.Style // Body content padding (PaddingLeft(2))
271+
272+ // Deprecated - kept for backward compatibility
273+ ContentBg lipgloss.Style // Content background
274+ ContentText lipgloss.Style // Content text
275+ ContentLineNumber lipgloss.Style // Line numbers in code
276+
277+ // State message styles
278+ StateWaiting lipgloss.Style // "Waiting for tool response..."
279+ StateCancelled lipgloss.Style // "Canceled."
280+
281+ // Error styles
282+ ErrorTag lipgloss.Style // ERROR tag
283+ ErrorMessage lipgloss.Style // Error message text
284+
285+ // Diff styles
286+ DiffTruncation lipgloss.Style // Diff truncation message with padding
287+
288+ // Multi-edit note styles
289+ NoteTag lipgloss.Style // NOTE tag (yellow background)
290+ NoteMessage lipgloss.Style // Note message text
291+
292+ // Job header styles (for bash jobs)
293+ JobIconPending lipgloss.Style // Pending job icon (green dark)
294+ JobIconError lipgloss.Style // Error job icon (red dark)
295+ JobIconSuccess lipgloss.Style // Success job icon (green)
296+ JobToolName lipgloss.Style // Job tool name "Bash" (blue)
297+ JobAction lipgloss.Style // Action text (Start, Output, Kill)
298+ JobPID lipgloss.Style // PID text
299+ JobDescription lipgloss.Style // Description text
300+
301+ // Agent task styles
302+ AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
303+ AgentPrompt lipgloss.Style // Agent prompt text
304+
305+ // Agentic fetch styles
306+ AgenticFetchPromptTag lipgloss.Style // Agentic fetch prompt tag (green background, bold)
307+
308+ // Todo styles
309+ TodoRatio lipgloss.Style // Todo ratio (e.g., "2/5")
310+ TodoCompletedIcon lipgloss.Style // Completed todo icon
311+ TodoInProgressIcon lipgloss.Style // In-progress todo icon
312+ TodoPendingIcon lipgloss.Style // Pending todo icon
313+
314+ // MCP tools
315+ MCPName lipgloss.Style // The mcp name
316+ MCPToolName lipgloss.Style // The mcp tool name
317+ MCPArrow lipgloss.Style // The mcp arrow icon
318+ }
319+
320+ // Dialog styles
321+ Dialog struct {
322+ Title lipgloss.Style
323+ TitleText lipgloss.Style
324+ TitleError lipgloss.Style
325+ TitleAccent lipgloss.Style
326+ // View is the main content area style.
327+ View lipgloss.Style
328+ PrimaryText lipgloss.Style
329+ SecondaryText lipgloss.Style
330+ // HelpView is the line that contains the help.
331+ HelpView lipgloss.Style
332+ Help struct {
333+ Ellipsis lipgloss.Style
334+ ShortKey lipgloss.Style
335+ ShortDesc lipgloss.Style
336+ ShortSeparator lipgloss.Style
337+ FullKey lipgloss.Style
338+ FullDesc lipgloss.Style
339+ FullSeparator lipgloss.Style
340+ }
341+ NormalItem lipgloss.Style
342+ SelectedItem lipgloss.Style
343+ InputPrompt lipgloss.Style
344+
345+ List lipgloss.Style
346+
347+ Spinner lipgloss.Style
348+
349+ // ContentPanel is used for content blocks with subtle background.
350+ ContentPanel lipgloss.Style
351+
352+ // Scrollbar styles for scrollable content.
353+ ScrollbarThumb lipgloss.Style
354+ ScrollbarTrack lipgloss.Style
355+
356+ // Arguments
357+ Arguments struct {
358+ Content lipgloss.Style
359+ Description lipgloss.Style
360+ InputLabelBlurred lipgloss.Style
361+ InputLabelFocused lipgloss.Style
362+ InputRequiredMarkBlurred lipgloss.Style
363+ InputRequiredMarkFocused lipgloss.Style
364+ }
365+
366+ Commands struct{}
367+
368+ ImagePreview lipgloss.Style
369+ }
370+
371+ // Status bar and help
372+ Status struct {
373+ Help lipgloss.Style
374+
375+ ErrorIndicator lipgloss.Style
376+ WarnIndicator lipgloss.Style
377+ InfoIndicator lipgloss.Style
378+ UpdateIndicator lipgloss.Style
379+ SuccessIndicator lipgloss.Style
380+
381+ ErrorMessage lipgloss.Style
382+ WarnMessage lipgloss.Style
383+ InfoMessage lipgloss.Style
384+ UpdateMessage lipgloss.Style
385+ SuccessMessage lipgloss.Style
386+ }
387+
388+ // Completions popup styles
389+ Completions struct {
390+ Normal lipgloss.Style
391+ Focused lipgloss.Style
392+ Match lipgloss.Style
393+ }
394+
395+ // Attachments styles
396+ Attachments struct {
397+ Normal lipgloss.Style
398+ Image lipgloss.Style
399+ Text lipgloss.Style
400+ Deleting lipgloss.Style
401+ }
402+
403+ // Pills styles for todo/queue pills
404+ Pills struct {
405+ Base lipgloss.Style // Base pill style with padding
406+ Focused lipgloss.Style // Focused pill with visible border
407+ Blurred lipgloss.Style // Blurred pill with hidden border
408+ QueueItemPrefix lipgloss.Style // Prefix for queue list items
409+ HelpKey lipgloss.Style // Keystroke hint style
410+ HelpText lipgloss.Style // Help action text style
411+ Area lipgloss.Style // Pills area container
412+ TodoSpinner lipgloss.Style // Todo spinner style
413+ }
414+}
415+
416+// ChromaTheme converts the current markdown chroma styles to a chroma
417+// StyleEntries map.
418+func (s *Styles) ChromaTheme() chroma.StyleEntries {
419+ rules := s.Markdown.CodeBlock
420+
421+ return chroma.StyleEntries{
422+ chroma.Text: chromaStyle(rules.Chroma.Text),
423+ chroma.Error: chromaStyle(rules.Chroma.Error),
424+ chroma.Comment: chromaStyle(rules.Chroma.Comment),
425+ chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc),
426+ chroma.Keyword: chromaStyle(rules.Chroma.Keyword),
427+ chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved),
428+ chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace),
429+ chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType),
430+ chroma.Operator: chromaStyle(rules.Chroma.Operator),
431+ chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation),
432+ chroma.Name: chromaStyle(rules.Chroma.Name),
433+ chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin),
434+ chroma.NameTag: chromaStyle(rules.Chroma.NameTag),
435+ chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute),
436+ chroma.NameClass: chromaStyle(rules.Chroma.NameClass),
437+ chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant),
438+ chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator),
439+ chroma.NameException: chromaStyle(rules.Chroma.NameException),
440+ chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction),
441+ chroma.NameOther: chromaStyle(rules.Chroma.NameOther),
442+ chroma.Literal: chromaStyle(rules.Chroma.Literal),
443+ chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber),
444+ chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate),
445+ chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString),
446+ chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
447+ chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted),
448+ chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph),
449+ chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted),
450+ chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong),
451+ chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading),
452+ chroma.Background: chromaStyle(rules.Chroma.Background),
453+ }
454+}
455+
456+// DialogHelpStyles returns the styles for dialog help.
457+func (s *Styles) DialogHelpStyles() help.Styles {
458+ return help.Styles(s.Dialog.Help)
459+}
460+
461+// DefaultStyles returns the default styles for the UI.
462+func DefaultStyles() Styles {
463+ var (
464+ primary = charmtone.Charple
465+ secondary = charmtone.Dolly
466+ tertiary = charmtone.Bok
467+ // accent = charmtone.Zest
468+
469+ // Backgrounds
470+ bgBase = charmtone.Pepper
471+ bgBaseLighter = charmtone.BBQ
472+ bgSubtle = charmtone.Charcoal
473+ bgOverlay = charmtone.Iron
474+
475+ // Foregrounds
476+ fgBase = charmtone.Ash
477+ fgMuted = charmtone.Squid
478+ fgHalfMuted = charmtone.Smoke
479+ fgSubtle = charmtone.Oyster
480+ // fgSelected = charmtone.Salt
481+
482+ // Borders
483+ border = charmtone.Charcoal
484+ borderFocus = charmtone.Charple
485+
486+ // Status
487+ error = charmtone.Sriracha
488+ warning = charmtone.Zest
489+ info = charmtone.Malibu
490+
491+ // Colors
492+ white = charmtone.Butter
493+
494+ blueLight = charmtone.Sardine
495+ blue = charmtone.Malibu
496+ blueDark = charmtone.Damson
497+
498+ // yellow = charmtone.Mustard
499+ yellow = charmtone.Mustard
500+ // citron = charmtone.Citron
501+
502+ greenLight = charmtone.Bok
503+ green = charmtone.Julep
504+ greenDark = charmtone.Guac
505+ // greenLight = charmtone.Bok
506+
507+ red = charmtone.Coral
508+ redDark = charmtone.Sriracha
509+ // redLight = charmtone.Salmon
510+ // cherry = charmtone.Cherry
511+ )
512+
513+ normalBorder := lipgloss.NormalBorder()
514+
515+ base := lipgloss.NewStyle().Foreground(fgBase)
516+
517+ s := Styles{}
518+
519+ s.Background = bgBase
520+
521+ // Populate color fields
522+ s.Primary = primary
523+ s.Secondary = secondary
524+ s.Tertiary = tertiary
525+ s.BgBase = bgBase
526+ s.BgBaseLighter = bgBaseLighter
527+ s.BgSubtle = bgSubtle
528+ s.BgOverlay = bgOverlay
529+ s.FgBase = fgBase
530+ s.FgMuted = fgMuted
531+ s.FgHalfMuted = fgHalfMuted
532+ s.FgSubtle = fgSubtle
533+ s.Border = border
534+ s.BorderColor = borderFocus
535+ s.Error = error
536+ s.Warning = warning
537+ s.Info = info
538+ s.White = white
539+ s.BlueLight = blueLight
540+ s.Blue = blue
541+ s.BlueDark = blueDark
542+ s.GreenLight = greenLight
543+ s.Green = green
544+ s.GreenDark = greenDark
545+ s.Red = red
546+ s.RedDark = redDark
547+ s.Yellow = yellow
548+
549+ s.TextInput = textinput.Styles{
550+ Focused: textinput.StyleState{
551+ Text: base,
552+ Placeholder: base.Foreground(fgSubtle),
553+ Prompt: base.Foreground(tertiary),
554+ Suggestion: base.Foreground(fgSubtle),
555+ },
556+ Blurred: textinput.StyleState{
557+ Text: base.Foreground(fgMuted),
558+ Placeholder: base.Foreground(fgSubtle),
559+ Prompt: base.Foreground(fgMuted),
560+ Suggestion: base.Foreground(fgSubtle),
561+ },
562+ Cursor: textinput.CursorStyle{
563+ Color: secondary,
564+ Shape: tea.CursorBlock,
565+ Blink: true,
566+ },
567+ }
568+
569+ s.TextArea = textarea.Styles{
570+ Focused: textarea.StyleState{
571+ Base: base,
572+ Text: base,
573+ LineNumber: base.Foreground(fgSubtle),
574+ CursorLine: base,
575+ CursorLineNumber: base.Foreground(fgSubtle),
576+ Placeholder: base.Foreground(fgSubtle),
577+ Prompt: base.Foreground(tertiary),
578+ },
579+ Blurred: textarea.StyleState{
580+ Base: base,
581+ Text: base.Foreground(fgMuted),
582+ LineNumber: base.Foreground(fgMuted),
583+ CursorLine: base,
584+ CursorLineNumber: base.Foreground(fgMuted),
585+ Placeholder: base.Foreground(fgSubtle),
586+ Prompt: base.Foreground(fgMuted),
587+ },
588+ Cursor: textarea.CursorStyle{
589+ Color: secondary,
590+ Shape: tea.CursorBlock,
591+ Blink: true,
592+ },
593+ }
594+
595+ s.Markdown = ansi.StyleConfig{
596+ Document: ansi.StyleBlock{
597+ StylePrimitive: ansi.StylePrimitive{
598+ // BlockPrefix: "\n",
599+ // BlockSuffix: "\n",
600+ Color: stringPtr(charmtone.Smoke.Hex()),
601+ },
602+ // Margin: uintPtr(defaultMargin),
603+ },
604+ BlockQuote: ansi.StyleBlock{
605+ StylePrimitive: ansi.StylePrimitive{},
606+ Indent: uintPtr(1),
607+ IndentToken: stringPtr("│ "),
608+ },
609+ List: ansi.StyleList{
610+ LevelIndent: defaultListIndent,
611+ },
612+ Heading: ansi.StyleBlock{
613+ StylePrimitive: ansi.StylePrimitive{
614+ BlockSuffix: "\n",
615+ Color: stringPtr(charmtone.Malibu.Hex()),
616+ Bold: boolPtr(true),
617+ },
618+ },
619+ H1: ansi.StyleBlock{
620+ StylePrimitive: ansi.StylePrimitive{
621+ Prefix: " ",
622+ Suffix: " ",
623+ Color: stringPtr(charmtone.Zest.Hex()),
624+ BackgroundColor: stringPtr(charmtone.Charple.Hex()),
625+ Bold: boolPtr(true),
626+ },
627+ },
628+ H2: ansi.StyleBlock{
629+ StylePrimitive: ansi.StylePrimitive{
630+ Prefix: "## ",
631+ },
632+ },
633+ H3: ansi.StyleBlock{
634+ StylePrimitive: ansi.StylePrimitive{
635+ Prefix: "### ",
636+ },
637+ },
638+ H4: ansi.StyleBlock{
639+ StylePrimitive: ansi.StylePrimitive{
640+ Prefix: "#### ",
641+ },
642+ },
643+ H5: ansi.StyleBlock{
644+ StylePrimitive: ansi.StylePrimitive{
645+ Prefix: "##### ",
646+ },
647+ },
648+ H6: ansi.StyleBlock{
649+ StylePrimitive: ansi.StylePrimitive{
650+ Prefix: "###### ",
651+ Color: stringPtr(charmtone.Guac.Hex()),
652+ Bold: boolPtr(false),
653+ },
654+ },
655+ Strikethrough: ansi.StylePrimitive{
656+ CrossedOut: boolPtr(true),
657+ },
658+ Emph: ansi.StylePrimitive{
659+ Italic: boolPtr(true),
660+ },
661+ Strong: ansi.StylePrimitive{
662+ Bold: boolPtr(true),
663+ },
664+ HorizontalRule: ansi.StylePrimitive{
665+ Color: stringPtr(charmtone.Charcoal.Hex()),
666+ Format: "\n--------\n",
667+ },
668+ Item: ansi.StylePrimitive{
669+ BlockPrefix: "• ",
670+ },
671+ Enumeration: ansi.StylePrimitive{
672+ BlockPrefix: ". ",
673+ },
674+ Task: ansi.StyleTask{
675+ StylePrimitive: ansi.StylePrimitive{},
676+ Ticked: "[✓] ",
677+ Unticked: "[ ] ",
678+ },
679+ Link: ansi.StylePrimitive{
680+ Color: stringPtr(charmtone.Zinc.Hex()),
681+ Underline: boolPtr(true),
682+ },
683+ LinkText: ansi.StylePrimitive{
684+ Color: stringPtr(charmtone.Guac.Hex()),
685+ Bold: boolPtr(true),
686+ },
687+ Image: ansi.StylePrimitive{
688+ Color: stringPtr(charmtone.Cheeky.Hex()),
689+ Underline: boolPtr(true),
690+ },
691+ ImageText: ansi.StylePrimitive{
692+ Color: stringPtr(charmtone.Squid.Hex()),
693+ Format: "Image: {{.text}} →",
694+ },
695+ Code: ansi.StyleBlock{
696+ StylePrimitive: ansi.StylePrimitive{
697+ Prefix: " ",
698+ Suffix: " ",
699+ Color: stringPtr(charmtone.Coral.Hex()),
700+ BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
701+ },
702+ },
703+ CodeBlock: ansi.StyleCodeBlock{
704+ StyleBlock: ansi.StyleBlock{
705+ StylePrimitive: ansi.StylePrimitive{
706+ Color: stringPtr(charmtone.Charcoal.Hex()),
707+ },
708+ Margin: uintPtr(defaultMargin),
709+ },
710+ Chroma: &ansi.Chroma{
711+ Text: ansi.StylePrimitive{
712+ Color: stringPtr(charmtone.Smoke.Hex()),
713+ },
714+ Error: ansi.StylePrimitive{
715+ Color: stringPtr(charmtone.Butter.Hex()),
716+ BackgroundColor: stringPtr(charmtone.Sriracha.Hex()),
717+ },
718+ Comment: ansi.StylePrimitive{
719+ Color: stringPtr(charmtone.Oyster.Hex()),
720+ },
721+ CommentPreproc: ansi.StylePrimitive{
722+ Color: stringPtr(charmtone.Bengal.Hex()),
723+ },
724+ Keyword: ansi.StylePrimitive{
725+ Color: stringPtr(charmtone.Malibu.Hex()),
726+ },
727+ KeywordReserved: ansi.StylePrimitive{
728+ Color: stringPtr(charmtone.Pony.Hex()),
729+ },
730+ KeywordNamespace: ansi.StylePrimitive{
731+ Color: stringPtr(charmtone.Pony.Hex()),
732+ },
733+ KeywordType: ansi.StylePrimitive{
734+ Color: stringPtr(charmtone.Guppy.Hex()),
735+ },
736+ Operator: ansi.StylePrimitive{
737+ Color: stringPtr(charmtone.Salmon.Hex()),
738+ },
739+ Punctuation: ansi.StylePrimitive{
740+ Color: stringPtr(charmtone.Zest.Hex()),
741+ },
742+ Name: ansi.StylePrimitive{
743+ Color: stringPtr(charmtone.Smoke.Hex()),
744+ },
745+ NameBuiltin: ansi.StylePrimitive{
746+ Color: stringPtr(charmtone.Cheeky.Hex()),
747+ },
748+ NameTag: ansi.StylePrimitive{
749+ Color: stringPtr(charmtone.Mauve.Hex()),
750+ },
751+ NameAttribute: ansi.StylePrimitive{
752+ Color: stringPtr(charmtone.Hazy.Hex()),
753+ },
754+ NameClass: ansi.StylePrimitive{
755+ Color: stringPtr(charmtone.Salt.Hex()),
756+ Underline: boolPtr(true),
757+ Bold: boolPtr(true),
758+ },
759+ NameDecorator: ansi.StylePrimitive{
760+ Color: stringPtr(charmtone.Citron.Hex()),
761+ },
762+ NameFunction: ansi.StylePrimitive{
763+ Color: stringPtr(charmtone.Guac.Hex()),
764+ },
765+ LiteralNumber: ansi.StylePrimitive{
766+ Color: stringPtr(charmtone.Julep.Hex()),
767+ },
768+ LiteralString: ansi.StylePrimitive{
769+ Color: stringPtr(charmtone.Cumin.Hex()),
770+ },
771+ LiteralStringEscape: ansi.StylePrimitive{
772+ Color: stringPtr(charmtone.Bok.Hex()),
773+ },
774+ GenericDeleted: ansi.StylePrimitive{
775+ Color: stringPtr(charmtone.Coral.Hex()),
776+ },
777+ GenericEmph: ansi.StylePrimitive{
778+ Italic: boolPtr(true),
779+ },
780+ GenericInserted: ansi.StylePrimitive{
781+ Color: stringPtr(charmtone.Guac.Hex()),
782+ },
783+ GenericStrong: ansi.StylePrimitive{
784+ Bold: boolPtr(true),
785+ },
786+ GenericSubheading: ansi.StylePrimitive{
787+ Color: stringPtr(charmtone.Squid.Hex()),
788+ },
789+ Background: ansi.StylePrimitive{
790+ BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
791+ },
792+ },
793+ },
794+ Table: ansi.StyleTable{
795+ StyleBlock: ansi.StyleBlock{
796+ StylePrimitive: ansi.StylePrimitive{},
797+ },
798+ },
799+ DefinitionDescription: ansi.StylePrimitive{
800+ BlockPrefix: "\n ",
801+ },
802+ }
803+
804+ // PlainMarkdown style - muted colors on subtle background for thinking content.
805+ plainBg := stringPtr(bgBaseLighter.Hex())
806+ plainFg := stringPtr(fgMuted.Hex())
807+ s.PlainMarkdown = ansi.StyleConfig{
808+ Document: ansi.StyleBlock{
809+ StylePrimitive: ansi.StylePrimitive{
810+ Color: plainFg,
811+ BackgroundColor: plainBg,
812+ },
813+ },
814+ BlockQuote: ansi.StyleBlock{
815+ StylePrimitive: ansi.StylePrimitive{
816+ Color: plainFg,
817+ BackgroundColor: plainBg,
818+ },
819+ Indent: uintPtr(1),
820+ IndentToken: stringPtr("│ "),
821+ },
822+ List: ansi.StyleList{
823+ LevelIndent: defaultListIndent,
824+ },
825+ Heading: ansi.StyleBlock{
826+ StylePrimitive: ansi.StylePrimitive{
827+ BlockSuffix: "\n",
828+ Bold: boolPtr(true),
829+ Color: plainFg,
830+ BackgroundColor: plainBg,
831+ },
832+ },
833+ H1: ansi.StyleBlock{
834+ StylePrimitive: ansi.StylePrimitive{
835+ Prefix: " ",
836+ Suffix: " ",
837+ Bold: boolPtr(true),
838+ Color: plainFg,
839+ BackgroundColor: plainBg,
840+ },
841+ },
842+ H2: ansi.StyleBlock{
843+ StylePrimitive: ansi.StylePrimitive{
844+ Prefix: "## ",
845+ Color: plainFg,
846+ BackgroundColor: plainBg,
847+ },
848+ },
849+ H3: ansi.StyleBlock{
850+ StylePrimitive: ansi.StylePrimitive{
851+ Prefix: "### ",
852+ Color: plainFg,
853+ BackgroundColor: plainBg,
854+ },
855+ },
856+ H4: ansi.StyleBlock{
857+ StylePrimitive: ansi.StylePrimitive{
858+ Prefix: "#### ",
859+ Color: plainFg,
860+ BackgroundColor: plainBg,
861+ },
862+ },
863+ H5: ansi.StyleBlock{
864+ StylePrimitive: ansi.StylePrimitive{
865+ Prefix: "##### ",
866+ Color: plainFg,
867+ BackgroundColor: plainBg,
868+ },
869+ },
870+ H6: ansi.StyleBlock{
871+ StylePrimitive: ansi.StylePrimitive{
872+ Prefix: "###### ",
873+ Color: plainFg,
874+ BackgroundColor: plainBg,
875+ },
876+ },
877+ Strikethrough: ansi.StylePrimitive{
878+ CrossedOut: boolPtr(true),
879+ Color: plainFg,
880+ BackgroundColor: plainBg,
881+ },
882+ Emph: ansi.StylePrimitive{
883+ Italic: boolPtr(true),
884+ Color: plainFg,
885+ BackgroundColor: plainBg,
886+ },
887+ Strong: ansi.StylePrimitive{
888+ Bold: boolPtr(true),
889+ Color: plainFg,
890+ BackgroundColor: plainBg,
891+ },
892+ HorizontalRule: ansi.StylePrimitive{
893+ Format: "\n--------\n",
894+ Color: plainFg,
895+ BackgroundColor: plainBg,
896+ },
897+ Item: ansi.StylePrimitive{
898+ BlockPrefix: "• ",
899+ Color: plainFg,
900+ BackgroundColor: plainBg,
901+ },
902+ Enumeration: ansi.StylePrimitive{
903+ BlockPrefix: ". ",
904+ Color: plainFg,
905+ BackgroundColor: plainBg,
906+ },
907+ Task: ansi.StyleTask{
908+ StylePrimitive: ansi.StylePrimitive{
909+ Color: plainFg,
910+ BackgroundColor: plainBg,
911+ },
912+ Ticked: "[✓] ",
913+ Unticked: "[ ] ",
914+ },
915+ Link: ansi.StylePrimitive{
916+ Underline: boolPtr(true),
917+ Color: plainFg,
918+ BackgroundColor: plainBg,
919+ },
920+ LinkText: ansi.StylePrimitive{
921+ Bold: boolPtr(true),
922+ Color: plainFg,
923+ BackgroundColor: plainBg,
924+ },
925+ Image: ansi.StylePrimitive{
926+ Underline: boolPtr(true),
927+ Color: plainFg,
928+ BackgroundColor: plainBg,
929+ },
930+ ImageText: ansi.StylePrimitive{
931+ Format: "Image: {{.text}} →",
932+ Color: plainFg,
933+ BackgroundColor: plainBg,
934+ },
935+ Code: ansi.StyleBlock{
936+ StylePrimitive: ansi.StylePrimitive{
937+ Prefix: " ",
938+ Suffix: " ",
939+ Color: plainFg,
940+ BackgroundColor: plainBg,
941+ },
942+ },
943+ CodeBlock: ansi.StyleCodeBlock{
944+ StyleBlock: ansi.StyleBlock{
945+ StylePrimitive: ansi.StylePrimitive{
946+ Color: plainFg,
947+ BackgroundColor: plainBg,
948+ },
949+ Margin: uintPtr(defaultMargin),
950+ },
951+ },
952+ Table: ansi.StyleTable{
953+ StyleBlock: ansi.StyleBlock{
954+ StylePrimitive: ansi.StylePrimitive{
955+ Color: plainFg,
956+ BackgroundColor: plainBg,
957+ },
958+ },
959+ },
960+ DefinitionDescription: ansi.StylePrimitive{
961+ BlockPrefix: "\n ",
962+ Color: plainFg,
963+ BackgroundColor: plainBg,
964+ },
965+ }
966+
967+ s.Help = help.Styles{
968+ ShortKey: base.Foreground(fgMuted),
969+ ShortDesc: base.Foreground(fgSubtle),
970+ ShortSeparator: base.Foreground(border),
971+ Ellipsis: base.Foreground(border),
972+ FullKey: base.Foreground(fgMuted),
973+ FullDesc: base.Foreground(fgSubtle),
974+ FullSeparator: base.Foreground(border),
975+ }
976+
977+ s.Diff = diffview.Style{
978+ DividerLine: diffview.LineStyle{
979+ LineNumber: lipgloss.NewStyle().
980+ Foreground(fgHalfMuted).
981+ Background(bgBaseLighter),
982+ Code: lipgloss.NewStyle().
983+ Foreground(fgHalfMuted).
984+ Background(bgBaseLighter),
985+ },
986+ MissingLine: diffview.LineStyle{
987+ LineNumber: lipgloss.NewStyle().
988+ Background(bgBaseLighter),
989+ Code: lipgloss.NewStyle().
990+ Background(bgBaseLighter),
991+ },
992+ EqualLine: diffview.LineStyle{
993+ LineNumber: lipgloss.NewStyle().
994+ Foreground(fgMuted).
995+ Background(bgBase),
996+ Code: lipgloss.NewStyle().
997+ Foreground(fgMuted).
998+ Background(bgBase),
999+ },
1000+ InsertLine: diffview.LineStyle{
1001+ LineNumber: lipgloss.NewStyle().
1002+ Foreground(lipgloss.Color("#629657")).
1003+ Background(lipgloss.Color("#2b322a")),
1004+ Symbol: lipgloss.NewStyle().
1005+ Foreground(lipgloss.Color("#629657")).
1006+ Background(lipgloss.Color("#323931")),
1007+ Code: lipgloss.NewStyle().
1008+ Background(lipgloss.Color("#323931")),
1009+ },
1010+ DeleteLine: diffview.LineStyle{
1011+ LineNumber: lipgloss.NewStyle().
1012+ Foreground(lipgloss.Color("#a45c59")).
1013+ Background(lipgloss.Color("#312929")),
1014+ Symbol: lipgloss.NewStyle().
1015+ Foreground(lipgloss.Color("#a45c59")).
1016+ Background(lipgloss.Color("#383030")),
1017+ Code: lipgloss.NewStyle().
1018+ Background(lipgloss.Color("#383030")),
1019+ },
1020+ }
1021+
1022+ s.FilePicker = filepicker.Styles{
1023+ DisabledCursor: base.Foreground(fgMuted),
1024+ Cursor: base.Foreground(fgBase),
1025+ Symlink: base.Foreground(fgSubtle),
1026+ Directory: base.Foreground(primary),
1027+ File: base.Foreground(fgBase),
1028+ DisabledFile: base.Foreground(fgMuted),
1029+ DisabledSelected: base.Background(bgOverlay).Foreground(fgMuted),
1030+ Permission: base.Foreground(fgMuted),
1031+ Selected: base.Background(primary).Foreground(fgBase),
1032+ FileSize: base.Foreground(fgMuted),
1033+ EmptyDirectory: base.Foreground(fgMuted).PaddingLeft(2).SetString("Empty directory"),
1034+ }
1035+
1036+ // borders
1037+ s.FocusedMessageBorder = lipgloss.Border{Left: BorderThick}
1038+
1039+ // text presets
1040+ s.Base = lipgloss.NewStyle().Foreground(fgBase)
1041+ s.Muted = lipgloss.NewStyle().Foreground(fgMuted)
1042+ s.HalfMuted = lipgloss.NewStyle().Foreground(fgHalfMuted)
1043+ s.Subtle = lipgloss.NewStyle().Foreground(fgSubtle)
1044+
1045+ s.WindowTooSmall = s.Muted
1046+
1047+ // tag presets
1048+ s.TagBase = lipgloss.NewStyle().Padding(0, 1).Foreground(white)
1049+ s.TagError = s.TagBase.Background(redDark)
1050+ s.TagInfo = s.TagBase.Background(blueLight)
1051+
1052+ // Compact header styles
1053+ s.Header.Charm = base.Foreground(secondary)
1054+ s.Header.Diagonals = base.Foreground(primary)
1055+ s.Header.Percentage = s.Muted
1056+ s.Header.Keystroke = s.Muted
1057+ s.Header.KeystrokeTip = s.Subtle
1058+ s.Header.WorkingDir = s.Muted
1059+ s.Header.Separator = s.Subtle
1060+
1061+ s.CompactDetails.Title = s.Base
1062+ s.CompactDetails.View = s.Base.Padding(0, 1, 1, 1).Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
1063+ s.CompactDetails.Version = s.Muted
1064+
1065+ // panels
1066+ s.PanelMuted = s.Muted.Background(bgBaseLighter)
1067+ s.PanelBase = lipgloss.NewStyle().Background(bgBase)
1068+
1069+ // code line number
1070+ s.LineNumber = lipgloss.NewStyle().Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1)
1071+
1072+ // Tool calls
1073+ s.ToolCallPending = lipgloss.NewStyle().Foreground(greenDark).SetString(ToolPending)
1074+ s.ToolCallError = lipgloss.NewStyle().Foreground(redDark).SetString(ToolError)
1075+ s.ToolCallSuccess = lipgloss.NewStyle().Foreground(green).SetString(ToolSuccess)
1076+ // Cancelled uses muted tone but same glyph as pending
1077+ s.ToolCallCancelled = s.Muted.SetString(ToolPending)
1078+ s.EarlyStateMessage = s.Subtle.PaddingLeft(2)
1079+
1080+ // Tool rendering styles
1081+ s.Tool.IconPending = base.Foreground(greenDark).SetString(ToolPending)
1082+ s.Tool.IconSuccess = base.Foreground(green).SetString(ToolSuccess)
1083+ s.Tool.IconError = base.Foreground(redDark).SetString(ToolError)
1084+ s.Tool.IconCancelled = s.Muted.SetString(ToolPending)
1085+
1086+ s.Tool.NameNormal = base.Foreground(blue)
1087+ s.Tool.NameNested = base.Foreground(fgHalfMuted)
1088+
1089+ s.Tool.ParamMain = s.Subtle
1090+ s.Tool.ParamKey = s.Subtle
1091+
1092+ // Content rendering - prepared styles that accept width parameter
1093+ s.Tool.ContentLine = s.Muted.Background(bgBaseLighter)
1094+ s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
1095+ s.Tool.ContentCodeLine = s.Base.Background(bgBase)
1096+ s.Tool.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2)
1097+ s.Tool.ContentCodeBg = bgBase
1098+ s.Tool.Body = base.PaddingLeft(2)
1099+
1100+ // Deprecated - kept for backward compatibility
1101+ s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)
1102+ s.Tool.ContentText = s.Muted
1103+ s.Tool.ContentLineNumber = base.Foreground(fgMuted).Background(bgBase).PaddingRight(1).PaddingLeft(1)
1104+
1105+ s.Tool.StateWaiting = base.Foreground(fgSubtle)
1106+ s.Tool.StateCancelled = base.Foreground(fgSubtle)
1107+
1108+ s.Tool.ErrorTag = base.Padding(0, 1).Background(red).Foreground(white)
1109+ s.Tool.ErrorMessage = base.Foreground(fgHalfMuted)
1110+
1111+ // Diff and multi-edit styles
1112+ s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2)
1113+ s.Tool.NoteTag = base.Padding(0, 1).Background(info).Foreground(white)
1114+ s.Tool.NoteMessage = base.Foreground(fgHalfMuted)
1115+
1116+ // Job header styles
1117+ s.Tool.JobIconPending = base.Foreground(greenDark)
1118+ s.Tool.JobIconError = base.Foreground(redDark)
1119+ s.Tool.JobIconSuccess = base.Foreground(green)
1120+ s.Tool.JobToolName = base.Foreground(blue)
1121+ s.Tool.JobAction = base.Foreground(blueDark)
1122+ s.Tool.JobPID = s.Muted
1123+ s.Tool.JobDescription = s.Subtle
1124+
1125+ // Agent task styles
1126+ s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
1127+ s.Tool.AgentPrompt = s.Muted
1128+
1129+ // Agentic fetch styles
1130+ s.Tool.AgenticFetchPromptTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(green).Foreground(border)
1131+
1132+ // Todo styles
1133+ s.Tool.TodoRatio = base.Foreground(blueDark)
1134+ s.Tool.TodoCompletedIcon = base.Foreground(green)
1135+ s.Tool.TodoInProgressIcon = base.Foreground(greenDark)
1136+ s.Tool.TodoPendingIcon = base.Foreground(fgMuted)
1137+
1138+ // MCP styles
1139+ s.Tool.MCPName = base.Foreground(blue)
1140+ s.Tool.MCPToolName = base.Foreground(blueDark)
1141+ s.Tool.MCPArrow = base.Foreground(blue).SetString(ArrowRightIcon)
1142+
1143+ // Buttons
1144+ s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
1145+ s.ButtonBlur = s.Base.Background(bgSubtle)
1146+
1147+ // Borders
1148+ s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2)
1149+
1150+ // Editor
1151+ s.EditorPromptNormalFocused = lipgloss.NewStyle().Foreground(greenDark).SetString("::: ")
1152+ s.EditorPromptNormalBlurred = s.EditorPromptNormalFocused.Foreground(fgMuted)
1153+ s.EditorPromptYoloIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
1154+ s.EditorPromptYoloIconBlurred = s.EditorPromptYoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
1155+ s.EditorPromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(charmtone.Zest).SetString(":::")
1156+ s.EditorPromptYoloDotsBlurred = s.EditorPromptYoloDotsFocused.Foreground(charmtone.Squid)
1157+
1158+ s.RadioOn = s.HalfMuted.SetString(RadioOn)
1159+ s.RadioOff = s.HalfMuted.SetString(RadioOff)
1160+
1161+ // Logo colors
1162+ s.LogoFieldColor = primary
1163+ s.LogoTitleColorA = secondary
1164+ s.LogoTitleColorB = primary
1165+ s.LogoCharmColor = secondary
1166+ s.LogoVersionColor = primary
1167+
1168+ // Section
1169+ s.Section.Title = s.Subtle
1170+ s.Section.Line = s.Base.Foreground(charmtone.Charcoal)
1171+
1172+ // Initialize
1173+ s.Initialize.Header = s.Base
1174+ s.Initialize.Content = s.Muted
1175+ s.Initialize.Accent = s.Base.Foreground(greenDark)
1176+
1177+ // LSP and MCP status.
1178+ s.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
1179+ s.ItemBusyIcon = s.ItemOfflineIcon.Foreground(charmtone.Citron)
1180+ s.ItemErrorIcon = s.ItemOfflineIcon.Foreground(charmtone.Coral)
1181+ s.ItemOnlineIcon = s.ItemOfflineIcon.Foreground(charmtone.Guac)
1182+
1183+ // LSP
1184+ s.LSP.ErrorDiagnostic = s.Base.Foreground(redDark)
1185+ s.LSP.WarningDiagnostic = s.Base.Foreground(warning)
1186+ s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
1187+ s.LSP.InfoDiagnostic = s.Base.Foreground(info)
1188+
1189+ // Files
1190+ s.Files.Path = s.Muted
1191+ s.Files.Additions = s.Base.Foreground(greenDark)
1192+ s.Files.Deletions = s.Base.Foreground(redDark)
1193+
1194+ // Chat
1195+ messageFocussedBorder := lipgloss.Border{
1196+ Left: "▌",
1197+ }
1198+
1199+ s.Chat.Message.NoContent = lipgloss.NewStyle().Foreground(fgBase)
1200+ s.Chat.Message.UserBlurred = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
1201+ BorderForeground(primary).BorderStyle(normalBorder)
1202+ s.Chat.Message.UserFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
1203+ BorderForeground(primary).BorderStyle(messageFocussedBorder)
1204+ s.Chat.Message.AssistantBlurred = s.Chat.Message.NoContent.PaddingLeft(2)
1205+ s.Chat.Message.AssistantFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
1206+ BorderForeground(greenDark).BorderStyle(messageFocussedBorder)
1207+ s.Chat.Message.Thinking = lipgloss.NewStyle().MaxHeight(10)
1208+ s.Chat.Message.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
1209+ Background(red).Foreground(white)
1210+ s.Chat.Message.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
1211+ s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
1212+
1213+ // Message item styles
1214+ s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
1215+ BorderStyle(messageFocussedBorder).
1216+ BorderLeft(true).
1217+ BorderForeground(greenDark)
1218+ s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
1219+ // No padding or border for compact tool calls within messages
1220+ s.Chat.Message.ToolCallCompact = s.Muted
1221+ s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
1222+ s.Chat.Message.AssistantInfoIcon = s.Subtle
1223+ s.Chat.Message.AssistantInfoModel = s.Muted
1224+ s.Chat.Message.AssistantInfoProvider = s.Subtle
1225+ s.Chat.Message.AssistantInfoDuration = s.Subtle
1226+
1227+ // Thinking section styles
1228+ s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter)
1229+ s.Chat.Message.ThinkingTruncationHint = s.Muted
1230+ s.Chat.Message.ThinkingFooterTitle = s.Muted
1231+ s.Chat.Message.ThinkingFooterDuration = s.Subtle
1232+
1233+ // Text selection.
1234+ s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
1235+
1236+ // Dialog styles
1237+ s.Dialog.Title = base.Padding(0, 1).Foreground(primary)
1238+ s.Dialog.TitleText = base.Foreground(primary)
1239+ s.Dialog.TitleError = base.Foreground(red)
1240+ s.Dialog.TitleAccent = base.Foreground(green).Bold(true)
1241+ s.Dialog.View = base.Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
1242+ s.Dialog.PrimaryText = base.Padding(0, 1).Foreground(primary)
1243+ s.Dialog.SecondaryText = base.Padding(0, 1).Foreground(fgSubtle)
1244+ s.Dialog.HelpView = base.Padding(0, 1).AlignHorizontal(lipgloss.Left)
1245+ s.Dialog.Help.ShortKey = base.Foreground(fgMuted)
1246+ s.Dialog.Help.ShortDesc = base.Foreground(fgSubtle)
1247+ s.Dialog.Help.ShortSeparator = base.Foreground(border)
1248+ s.Dialog.Help.Ellipsis = base.Foreground(border)
1249+ s.Dialog.Help.FullKey = base.Foreground(fgMuted)
1250+ s.Dialog.Help.FullDesc = base.Foreground(fgSubtle)
1251+ s.Dialog.Help.FullSeparator = base.Foreground(border)
1252+ s.Dialog.NormalItem = base.Padding(0, 1).Foreground(fgBase)
1253+ s.Dialog.SelectedItem = base.Padding(0, 1).Background(primary).Foreground(fgBase)
1254+ s.Dialog.InputPrompt = base.Margin(1, 1)
1255+
1256+ s.Dialog.List = base.Margin(0, 0, 1, 0)
1257+ s.Dialog.ContentPanel = base.Background(bgSubtle).Foreground(fgBase).Padding(1, 2)
1258+ s.Dialog.Spinner = base.Foreground(secondary)
1259+ s.Dialog.ScrollbarThumb = base.Foreground(secondary)
1260+ s.Dialog.ScrollbarTrack = base.Foreground(border)
1261+
1262+ s.Dialog.ImagePreview = lipgloss.NewStyle().Padding(0, 1).Foreground(fgSubtle)
1263+
1264+ s.Dialog.Arguments.Content = base.Padding(1)
1265+ s.Dialog.Arguments.Description = base.MarginBottom(1).MaxHeight(3)
1266+ s.Dialog.Arguments.InputLabelBlurred = base.Foreground(fgMuted)
1267+ s.Dialog.Arguments.InputLabelFocused = base.Bold(true)
1268+ s.Dialog.Arguments.InputRequiredMarkBlurred = base.Foreground(fgMuted).SetString("*")
1269+ s.Dialog.Arguments.InputRequiredMarkFocused = base.Foreground(primary).Bold(true).SetString("*")
1270+
1271+ s.Status.Help = lipgloss.NewStyle().Padding(0, 1)
1272+ s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!")
1273+ s.Status.InfoIndicator = s.Status.SuccessIndicator
1274+ s.Status.UpdateIndicator = s.Status.SuccessIndicator.SetString("HEY!")
1275+ s.Status.WarnIndicator = s.Status.SuccessIndicator.Foreground(bgOverlay).Background(yellow).SetString("WARNING")
1276+ s.Status.ErrorIndicator = s.Status.SuccessIndicator.Foreground(bgBase).Background(red).SetString("ERROR")
1277+ s.Status.SuccessMessage = base.Foreground(bgSubtle).Background(greenDark).Padding(0, 1)
1278+ s.Status.InfoMessage = s.Status.SuccessMessage
1279+ s.Status.UpdateMessage = s.Status.SuccessMessage
1280+ s.Status.WarnMessage = s.Status.SuccessMessage.Foreground(bgOverlay).Background(warning)
1281+ s.Status.ErrorMessage = s.Status.SuccessMessage.Foreground(white).Background(redDark)
1282+
1283+ // Completions styles
1284+ s.Completions.Normal = base.Background(bgSubtle).Foreground(fgBase)
1285+ s.Completions.Focused = base.Background(primary).Foreground(white)
1286+ s.Completions.Match = base.Underline(true)
1287+
1288+ // Attachments styles
1289+ attachmentIconStyle := base.Foreground(bgSubtle).Background(green).Padding(0, 1)
1290+ s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
1291+ s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
1292+ s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase)
1293+ s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase)
1294+
1295+ // Pills styles
1296+ s.Pills.Base = base.Padding(0, 1)
1297+ s.Pills.Focused = base.Padding(0, 1).BorderStyle(lipgloss.RoundedBorder()).BorderForeground(bgOverlay)
1298+ s.Pills.Blurred = base.Padding(0, 1).BorderStyle(lipgloss.HiddenBorder())
1299+ s.Pills.QueueItemPrefix = s.Muted.SetString(" •")
1300+ s.Pills.HelpKey = s.Muted
1301+ s.Pills.HelpText = s.Subtle
1302+ s.Pills.Area = base
1303+ s.Pills.TodoSpinner = base.Foreground(greenDark)
1304+
1305+ return s
1306+}
1307+
1308+// Helper functions for style pointers
1309+func boolPtr(b bool) *bool { return &b }
1310+func stringPtr(s string) *string { return &s }
1311+func uintPtr(u uint) *uint { return &u }
1312+func chromaStyle(style ansi.StylePrimitive) string {
1313+ var s string
1314+
1315+ if style.Color != nil {
1316+ s = *style.Color
1317+ }
1318+ if style.BackgroundColor != nil {
1319+ if s != "" {
1320+ s += " "
1321+ }
1322+ s += "bg:" + *style.BackgroundColor
1323+ }
1324+ if style.Italic != nil && *style.Italic {
1325+ if s != "" {
1326+ s += " "
1327+ }
1328+ s += "italic"
1329+ }
1330+ if style.Bold != nil && *style.Bold {
1331+ if s != "" {
1332+ s += " "
1333+ }
1334+ s += "bold"
1335+ }
1336+ if style.Underline != nil && *style.Underline {
1337+ if s != "" {
1338+ s += " "
1339+ }
1340+ s += "underline"
1341+ }
1342+
1343+ return s
1344+}
internal/uicmd/uicmd.go+1
@@ -1,6 +1,7 @@
1// Package uicmd provides functionality to load and handle custom commands
1// Package uicmd provides functionality to load and handle custom commands
2// from markdown files and MCP prompts.
2// from markdown files and MCP prompts.
3// TODO: Move this into internal/ui after refactoring.
3// TODO: Move this into internal/ui after refactoring.
4+// TODO: DELETE when we delete the old tui
4package uicmd
5package uicmd
5
6
6import (
7import (
internal/uiutil/uiutil.go+28−10
@@ -26,10 +26,7 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
26
26
27func ReportError(err error) tea.Cmd {
27func ReportError(err error) tea.Cmd {
28 slog.Error("Error reported", "error", err)
28 slog.Error("Error reported", "error", err)
29− return CmdHandler(InfoMsg{
29+ return CmdHandler(NewErrorMsg(err))
30− Type: InfoTypeError,
31− Msg: err.Error(),
32− })
33}
30}
34
31
35type InfoType int
32type InfoType int
@@ -42,18 +39,33 @@ const (
42 InfoTypeUpdate
39 InfoTypeUpdate
43)
40)
44
41
45−func ReportInfo(info string) tea.Cmd {
42+func NewInfoMsg(info string) InfoMsg {
46− return CmdHandler(InfoMsg{
43+ return InfoMsg{
47 Type: InfoTypeInfo,
44 Type: InfoTypeInfo,
48 Msg: info,
45 Msg: info,
49− })
46+ }
50}
47}
51
48
52−func ReportWarn(warn string) tea.Cmd {
49+func NewWarnMsg(warn string) InfoMsg {
53− return CmdHandler(InfoMsg{
50+ return InfoMsg{
54 Type: InfoTypeWarn,
51 Type: InfoTypeWarn,
55 Msg: warn,
52 Msg: warn,
56− })
53+ }
54+}
55+
56+func NewErrorMsg(err error) InfoMsg {
57+ return InfoMsg{
58+ Type: InfoTypeError,
59+ Msg: err.Error(),
60+ }
61+}
62+
63+func ReportInfo(info string) tea.Cmd {
64+ return CmdHandler(NewInfoMsg(info))
65+}
66+
67+func ReportWarn(warn string) tea.Cmd {
68+ return CmdHandler(NewWarnMsg(warn))
57}
69}
58
70
59type (
71type (
@@ -65,6 +77,12 @@ type (
65 ClearStatusMsg struct{}
77 ClearStatusMsg struct{}
66)
78)
67
79
80+// IsEmpty checks if the [InfoMsg] is empty.
81+func (m InfoMsg) IsEmpty() bool {
82+ var zero InfoMsg
83+ return m == zero
84+}
85+
68// ExecShell parses a shell command string and executes it with exec.Command.
86// ExecShell parses a shell command string and executes it with exec.Command.
69// Uses shell.Fields for proper handling of shell syntax like quotes and
87// Uses shell.Fields for proper handling of shell syntax like quotes and
70// arguments while preserving TTY handling for terminal editors.
88// arguments while preserving TTY handling for terminal editors.