Detailed changes
@@ -16,7 +16,6 @@ require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/alecthomas/chroma/v2 v2.23.1
github.com/atotto/clipboard v0.1.4
- github.com/aymanbagabas/go-nativeclipboard v0.1.2
github.com/aymanbagabas/go-udiff v0.3.1
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/charlievieth/fastwalk v1.0.14
@@ -36,7 +35,6 @@ require (
github.com/clipperhouse/displaywidth v0.9.0
github.com/clipperhouse/uax29/v2 v2.5.0
github.com/denisbrodbeck/machineid v1.0.1
- github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/dustin/go-humanize v1.0.1
github.com/google/uuid v1.6.0
@@ -46,9 +44,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0
github.com/mattn/go-isatty v0.0.20
github.com/modelcontextprotocol/go-sdk v1.2.0
- github.com/muesli/termenv v0.16.0
github.com/ncruces/go-sqlite3 v0.30.5
- github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/nxadm/tail v1.4.11
github.com/openai/openai-go/v2 v2.7.1
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
@@ -59,13 +55,10 @@ require (
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sahilm/fuzzy v0.1.1
github.com/spf13/cobra v1.10.2
- github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
- github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/zeebo/xxh3 v1.1.0
- golang.org/x/mod v0.32.0
golang.org/x/net v0.49.0
golang.org/x/sync v0.19.0
golang.org/x/text v0.33.0
@@ -100,7 +93,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
- github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
@@ -111,9 +103,7 @@ require (
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/disintegration/gift v1.1.2 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
- github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
@@ -180,6 +170,7 @@ require (
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/image v0.34.0 // indirect
+ golang.org/x/mod v0.32.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
@@ -80,10 +80,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
-github.com/aymanbagabas/go-nativeclipboard v0.1.2 h1:Z2iVRWQ4IynMLWM6a+lWH2Nk5gPyEtPRMuBIyZ2dECM=
-github.com/aymanbagabas/go-nativeclipboard v0.1.2/go.mod h1:BVJhN7hs5DieCzUB2Atf4Yk9Y9kFe62E95+gOjpJq6Q=
-github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
-github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -148,18 +144,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
-github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
-github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
-github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
-github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff h1:vAcU1VsCRstZ9ty11yD/L0WDyT73S/gVfmuWvcWX5DA=
-github.com/ebitengine/purego v0.10.0-alpha.3.0.20260102153238-200df6041cff/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
@@ -275,16 +265,12 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
-github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
-github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE=
github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
-github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
@@ -331,10 +317,6 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
-github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
-github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
-github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -33,8 +33,8 @@ import (
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/shell"
- "github.com/charmbracelet/crush/internal/tui/components/anim"
- "github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/ui/anim"
+ "github.com/charmbracelet/crush/internal/ui/styles"
"github.com/charmbracelet/crush/internal/update"
"github.com/charmbracelet/crush/internal/version"
"github.com/charmbracelet/x/ansi"
@@ -160,7 +160,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt,
progress = app.config.Options.Progress == nil || *app.config.Options.Progress
if !hideSpinner && stderrTTY {
- t := styles.CurrentTheme()
+ t := styles.DefaultStyles()
// Detect background color to set the appropriate color for the
// spinner's 'Generating...' text. Without this, that text would be
@@ -20,7 +20,6 @@ import (
"github.com/charmbracelet/crush/internal/db"
"github.com/charmbracelet/crush/internal/event"
"github.com/charmbracelet/crush/internal/projects"
- "github.com/charmbracelet/crush/internal/tui"
"github.com/charmbracelet/crush/internal/ui/common"
ui "github.com/charmbracelet/crush/internal/ui/model"
"github.com/charmbracelet/crush/internal/version"
@@ -28,14 +27,10 @@ import (
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/charmtone"
- xstrings "github.com/charmbracelet/x/exp/strings"
"github.com/charmbracelet/x/term"
"github.com/spf13/cobra"
)
-// kittyTerminals defines terminals supporting querying capabilities.
-var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"}
-
func init() {
rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
@@ -93,27 +88,15 @@ crush -y
// Set up the TUI.
var env uv.Environ = os.Environ()
- newUI := true
- if v, err := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); err == nil {
- newUI = v
- }
+ com := common.DefaultCommon(app)
+ model := ui.New(com)
- var model tea.Model
- if newUI {
- slog.Info("New UI in control!")
- com := common.DefaultCommon(app)
- ui := ui.New(com)
- model = ui
- } else {
- ui := tui.New(app)
- ui.QueryVersion = shouldQueryCapabilities(env)
- model = ui
- }
program := tea.NewProgram(
model,
tea.WithEnvironment(env),
tea.WithContext(cmd.Context()),
- tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
+ tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
+ )
go app.Subscribe(program)
if _, err := program.Run(); err != nil {
@@ -313,18 +296,3 @@ func createDotCrushDir(dir string) error {
return nil
}
-
-// TODO: Remove me after dropping the old TUI.
-func shouldQueryCapabilities(env uv.Environ) bool {
- const osVendorTypeApple = "Apple"
- termType := env.Getenv("TERM")
- termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
- _, okSSHTTY := env.LookupEnv("SSH_TTY")
- if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
- return false
- }
- return (!okTermProg && !okSSHTTY) ||
- (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
- // Terminals that do support XTVERSION.
- xstrings.ContainsAnyOf(termType, kittyTerminals...)
-}
@@ -1,160 +0,0 @@
-package cmd
-
-import (
- "strings"
- "testing"
-
- uv "github.com/charmbracelet/ultraviolet"
- xstrings "github.com/charmbracelet/x/exp/strings"
- "github.com/stretchr/testify/require"
-)
-
-type mockEnviron []string
-
-func (m mockEnviron) Getenv(key string) string {
- v, _ := m.LookupEnv(key)
- return v
-}
-
-func (m mockEnviron) LookupEnv(key string) (string, bool) {
- for _, env := range m {
- kv := strings.SplitN(env, "=", 2)
- if len(kv) == 2 && kv[0] == key {
- return kv[1], true
- }
- }
- return "", false
-}
-
-func (m mockEnviron) ExpandEnv(s string) string {
- return s // Not implemented for tests
-}
-
-func (m mockEnviron) Slice() []string {
- return []string(m)
-}
-
-func TestShouldQueryImageCapabilities(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- env mockEnviron
- want bool
- }{
- {
- name: "kitty terminal",
- env: mockEnviron{"TERM=xterm-kitty"},
- want: true,
- },
- {
- name: "wezterm terminal",
- env: mockEnviron{"TERM=xterm-256color"},
- want: true,
- },
- {
- name: "wezterm with WEZTERM env",
- env: mockEnviron{"TERM=xterm-256color", "WEZTERM_EXECUTABLE=/Applications/WezTerm.app/Contents/MacOS/wezterm-gui"},
- want: true, // Not detected via TERM, only via stringext.ContainsAny which checks TERM
- },
- {
- name: "Apple Terminal",
- env: mockEnviron{"TERM_PROGRAM=Apple_Terminal", "TERM=xterm-256color"},
- want: false,
- },
- {
- name: "alacritty",
- env: mockEnviron{"TERM=alacritty"},
- want: true,
- },
- {
- name: "ghostty",
- env: mockEnviron{"TERM=xterm-ghostty"},
- want: true,
- },
- {
- name: "rio",
- env: mockEnviron{"TERM=rio"},
- want: true,
- },
- {
- name: "wezterm (detected via TERM)",
- env: mockEnviron{"TERM=wezterm"},
- want: true,
- },
- {
- name: "SSH session",
- env: mockEnviron{"SSH_TTY=/dev/pts/0", "TERM=xterm-256color"},
- want: false,
- },
- {
- name: "generic terminal",
- env: mockEnviron{"TERM=xterm-256color"},
- want: true,
- },
- {
- name: "kitty over SSH",
- env: mockEnviron{"SSH_TTY=/dev/pts/0", "TERM=xterm-kitty"},
- want: true,
- },
- {
- name: "Apple Terminal with kitty TERM (should still be false due to TERM_PROGRAM)",
- env: mockEnviron{"TERM_PROGRAM=Apple_Terminal", "TERM=xterm-kitty"},
- want: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := shouldQueryCapabilities(uv.Environ(tt.env))
- require.Equal(t, tt.want, got, "shouldQueryImageCapabilities() = %v, want %v", got, tt.want)
- })
- }
-}
-
-// This is a helper to test the underlying logic of stringext.ContainsAny
-// which is used by shouldQueryImageCapabilities
-func TestStringextContainsAny(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- s string
- substr []string
- want bool
- }{
- {
- name: "kitty in TERM",
- s: "xterm-kitty",
- substr: kittyTerminals,
- want: true,
- },
- {
- name: "wezterm in TERM",
- s: "wezterm",
- substr: kittyTerminals,
- want: true,
- },
- {
- name: "alacritty in TERM",
- s: "alacritty",
- substr: kittyTerminals,
- want: true,
- },
- {
- name: "generic terminal not in list",
- s: "xterm-256color",
- substr: kittyTerminals,
- want: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got := xstrings.ContainsAnyOf(tt.s, tt.substr...)
- require.Equal(t, tt.want, got)
- })
- }
-}
@@ -7,7 +7,7 @@ import (
"os"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/tui/components/anim"
+ "github.com/charmbracelet/crush/internal/ui/anim"
"github.com/charmbracelet/x/ansi"
)
@@ -22,8 +22,8 @@ type model struct {
anim *anim.Anim
}
-func (m model) Init() tea.Cmd { return m.anim.Init() }
-func (m model) View() tea.View { return tea.NewView(m.anim.View()) }
+func (m model) Init() tea.Cmd { return m.anim.Start() }
+func (m model) View() tea.View { return tea.NewView(m.anim.Render()) }
// Update implements tea.Model.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -34,10 +34,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cancel()
return m, tea.Quit
}
+ case anim.StepMsg:
+ cmd := m.anim.Animate(msg)
+ return m, cmd
}
- mm, cmd := m.anim.Update(msg)
- m.anim = mm.(*anim.Anim)
- return m, cmd
+ return m, nil
}
// NewSpinner creates a new spinner with the given message
@@ -1,447 +0,0 @@
-// Package anim provides an animated spinner.
-package anim
-
-import (
- "fmt"
- "image/color"
- "math/rand/v2"
- "strings"
- "sync/atomic"
- "time"
-
- "github.com/zeebo/xxh3"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/lucasb-eyer/go-colorful"
-
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const (
- fps = 20
- initialChar = '.'
- labelGap = " "
- labelGapWidth = 1
-
- // Periods of ellipsis animation speed in steps.
- //
- // If the FPS is 20 (50 milliseconds) this means that the ellipsis will
- // change every 8 frames (400 milliseconds).
- ellipsisAnimSpeed = 8
-
- // The maximum amount of time that can pass before a character appears.
- // This is used to create a staggered entrance effect.
- maxBirthOffset = time.Second
-
- // Number of frames to prerender for the animation. After this number
- // of frames, the animation will loop. This only applies when color
- // cycling is disabled.
- prerenderedFrames = 10
-
- // Default number of cycling chars.
- defaultNumCyclingChars = 10
-)
-
-// Default colors for gradient.
-var (
- defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
- defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
- defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
-)
-
-var (
- availableRunes = []rune("0123456789abcdefABCDEF~!@#$Β£β¬%^&*()+=_")
- ellipsisFrames = []string{".", "..", "...", ""}
-)
-
-// Internal ID management. Used during animating to ensure that frame messages
-// are received only by spinner components that sent them.
-var lastID int64
-
-func nextID() int {
- return int(atomic.AddInt64(&lastID, 1))
-}
-
-// Cache for expensive animation calculations
-type animCache struct {
- initialFrames [][]string
- cyclingFrames [][]string
- width int
- labelWidth int
- label []string
- ellipsisFrames []string
-}
-
-var animCacheMap = csync.NewMap[string, *animCache]()
-
-// settingsHash creates a hash key for the settings to use for caching
-func settingsHash(opts Settings) string {
- h := xxh3.New()
- fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
- opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
- return fmt.Sprintf("%x", h.Sum(nil))
-}
-
-// StepMsg is a message type used to trigger the next step in the animation.
-type StepMsg struct{ id int }
-
-// Settings defines settings for the animation.
-type Settings struct {
- Size int
- Label string
- LabelColor color.Color
- GradColorA color.Color
- GradColorB color.Color
- CycleColors bool
-}
-
-// Default settings.
-const ()
-
-// Anim is a Bubble for an animated spinner.
-type Anim struct {
- width int
- cyclingCharWidth int
- label *csync.Slice[string]
- labelWidth int
- labelColor color.Color
- startTime time.Time
- birthOffsets []time.Duration
- initialFrames [][]string // frames for the initial characters
- initialized atomic.Bool
- cyclingFrames [][]string // frames for the cycling characters
- step atomic.Int64 // current main frame step
- ellipsisStep atomic.Int64 // current ellipsis frame step
- ellipsisFrames *csync.Slice[string] // ellipsis animation frames
- id int
-}
-
-// New creates a new Anim instance with the specified width and label.
-func New(opts Settings) *Anim {
- a := &Anim{}
- // Validate settings.
- if opts.Size < 1 {
- opts.Size = defaultNumCyclingChars
- }
- if colorIsUnset(opts.GradColorA) {
- opts.GradColorA = defaultGradColorA
- }
- if colorIsUnset(opts.GradColorB) {
- opts.GradColorB = defaultGradColorB
- }
- if colorIsUnset(opts.LabelColor) {
- opts.LabelColor = defaultLabelColor
- }
-
- a.id = nextID()
- a.startTime = time.Now()
- a.cyclingCharWidth = opts.Size
- a.labelColor = opts.LabelColor
-
- // Check cache first
- cacheKey := settingsHash(opts)
- cached, exists := animCacheMap.Get(cacheKey)
-
- if exists {
- // Use cached values
- a.width = cached.width
- a.labelWidth = cached.labelWidth
- a.label = csync.NewSliceFrom(cached.label)
- a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
- a.initialFrames = cached.initialFrames
- a.cyclingFrames = cached.cyclingFrames
- } else {
- // Generate new values and cache them
- a.labelWidth = lipgloss.Width(opts.Label)
-
- // Total width of anim, in cells.
- a.width = opts.Size
- if opts.Label != "" {
- a.width += labelGapWidth + lipgloss.Width(opts.Label)
- }
-
- // Render the label
- a.renderLabel(opts.Label)
-
- // Pre-generate gradient.
- var ramp []color.Color
- numFrames := prerenderedFrames
- if opts.CycleColors {
- ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
- numFrames = a.width * 2
- } else {
- ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
- }
-
- // Pre-render initial characters.
- a.initialFrames = make([][]string, numFrames)
- offset := 0
- for i := range a.initialFrames {
- a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
- for j := range a.initialFrames[i] {
- if j+offset >= len(ramp) {
- continue // skip if we run out of colors
- }
-
- var c color.Color
- if j <= a.cyclingCharWidth {
- c = ramp[j+offset]
- } else {
- c = opts.LabelColor
- }
-
- // Also prerender the initial character with Lip Gloss to avoid
- // processing in the render loop.
- a.initialFrames[i][j] = lipgloss.NewStyle().
- Foreground(c).
- Render(string(initialChar))
- }
- if opts.CycleColors {
- offset++
- }
- }
-
- // Prerender scrambled rune frames for the animation.
- a.cyclingFrames = make([][]string, numFrames)
- offset = 0
- for i := range a.cyclingFrames {
- a.cyclingFrames[i] = make([]string, a.width)
- for j := range a.cyclingFrames[i] {
- if j+offset >= len(ramp) {
- continue // skip if we run out of colors
- }
-
- // Also prerender the color with Lip Gloss here to avoid processing
- // in the render loop.
- r := availableRunes[rand.IntN(len(availableRunes))]
- a.cyclingFrames[i][j] = lipgloss.NewStyle().
- Foreground(ramp[j+offset]).
- Render(string(r))
- }
- if opts.CycleColors {
- offset++
- }
- }
-
- // Cache the results
- labelSlice := make([]string, a.label.Len())
- for i, v := range a.label.Seq2() {
- labelSlice[i] = v
- }
- ellipsisSlice := make([]string, a.ellipsisFrames.Len())
- for i, v := range a.ellipsisFrames.Seq2() {
- ellipsisSlice[i] = v
- }
- cached = &animCache{
- initialFrames: a.initialFrames,
- cyclingFrames: a.cyclingFrames,
- width: a.width,
- labelWidth: a.labelWidth,
- label: labelSlice,
- ellipsisFrames: ellipsisSlice,
- }
- animCacheMap.Set(cacheKey, cached)
- }
-
- // Random assign a birth to each character for a stagged entrance effect.
- a.birthOffsets = make([]time.Duration, a.width)
- for i := range a.birthOffsets {
- a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
- }
-
- return a
-}
-
-// SetLabel updates the label text and re-renders it.
-func (a *Anim) SetLabel(newLabel string) {
- a.labelWidth = lipgloss.Width(newLabel)
-
- // Update total width
- a.width = a.cyclingCharWidth
- if newLabel != "" {
- a.width += labelGapWidth + a.labelWidth
- }
-
- // Re-render the label
- a.renderLabel(newLabel)
-}
-
-// renderLabel renders the label with the current label color.
-func (a *Anim) renderLabel(label string) {
- if a.labelWidth > 0 {
- // Pre-render the label.
- labelRunes := []rune(label)
- a.label = csync.NewSlice[string]()
- for i := range labelRunes {
- rendered := lipgloss.NewStyle().
- Foreground(a.labelColor).
- Render(string(labelRunes[i]))
- a.label.Append(rendered)
- }
-
- // Pre-render the ellipsis frames which come after the label.
- a.ellipsisFrames = csync.NewSlice[string]()
- for _, frame := range ellipsisFrames {
- rendered := lipgloss.NewStyle().
- Foreground(a.labelColor).
- Render(frame)
- a.ellipsisFrames.Append(rendered)
- }
- } else {
- a.label = csync.NewSlice[string]()
- a.ellipsisFrames = csync.NewSlice[string]()
- }
-}
-
-// Width returns the total width of the animation.
-func (a *Anim) Width() (w int) {
- w = a.width
- if a.labelWidth > 0 {
- w += labelGapWidth + a.labelWidth
-
- var widestEllipsisFrame int
- for _, f := range ellipsisFrames {
- fw := lipgloss.Width(f)
- if fw > widestEllipsisFrame {
- widestEllipsisFrame = fw
- }
- }
- w += widestEllipsisFrame
- }
- return w
-}
-
-// Init starts the animation.
-func (a *Anim) Init() tea.Cmd {
- return a.Step()
-}
-
-// Update processes animation steps (or not).
-func (a *Anim) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case StepMsg:
- if msg.id != a.id {
- // Reject messages that are not for this instance.
- return a, nil
- }
-
- step := a.step.Add(1)
- if int(step) >= len(a.cyclingFrames) {
- a.step.Store(0)
- }
-
- if a.initialized.Load() && a.labelWidth > 0 {
- // Manage the ellipsis animation.
- ellipsisStep := a.ellipsisStep.Add(1)
- if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
- a.ellipsisStep.Store(0)
- }
- } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
- a.initialized.Store(true)
- }
- return a, a.Step()
- default:
- return a, nil
- }
-}
-
-// View renders the current state of the animation.
-func (a *Anim) View() string {
- var b strings.Builder
- step := int(a.step.Load())
- for i := range a.width {
- switch {
- case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
- // Birth offset not reached: render initial character.
- b.WriteString(a.initialFrames[step][i])
- case i < a.cyclingCharWidth:
- // Render a cycling character.
- b.WriteString(a.cyclingFrames[step][i])
- case i == a.cyclingCharWidth:
- // Render label gap.
- b.WriteString(labelGap)
- case i > a.cyclingCharWidth:
- // Label.
- if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
- b.WriteString(labelChar)
- }
- }
- }
- // Render animated ellipsis at the end of the label if all characters
- // have been initialized.
- if a.initialized.Load() && a.labelWidth > 0 {
- ellipsisStep := int(a.ellipsisStep.Load())
- if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
- b.WriteString(ellipsisFrame)
- }
- }
-
- return b.String()
-}
-
-// Step is a command that triggers the next step in the animation.
-func (a *Anim) Step() tea.Cmd {
- return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
- return StepMsg{id: a.id}
- })
-}
-
-// makeGradientRamp() returns a slice of colors blended between the given keys.
-// Blending is done as Hcl to stay in gamut.
-func makeGradientRamp(size int, stops ...color.Color) []color.Color {
- if len(stops) < 2 {
- return nil
- }
-
- points := make([]colorful.Color, len(stops))
- for i, k := range stops {
- points[i], _ = colorful.MakeColor(k)
- }
-
- numSegments := len(stops) - 1
- if numSegments == 0 {
- return nil
- }
- blended := make([]color.Color, 0, size)
-
- // Calculate how many colors each segment should have.
- segmentSizes := make([]int, numSegments)
- baseSize := size / numSegments
- remainder := size % numSegments
-
- // Distribute the remainder across segments.
- for i := range numSegments {
- segmentSizes[i] = baseSize
- if i < remainder {
- segmentSizes[i]++
- }
- }
-
- // Generate colors for each segment.
- for i := range numSegments {
- c1 := points[i]
- c2 := points[i+1]
- segmentSize := segmentSizes[i]
-
- for j := range segmentSize {
- if segmentSize == 0 {
- continue
- }
- t := float64(j) / float64(segmentSize)
- c := c1.BlendHcl(c2, t)
- blended = append(blended, c)
- }
- }
-
- return blended
-}
-
-func colorIsUnset(c color.Color) bool {
- if c == nil {
- return true
- }
- _, _, _, a := c.RGBA()
- return a == 0
-}
@@ -1,782 +0,0 @@
-package chat
-
-import (
- "context"
- "time"
-
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "github.com/atotto/clipboard"
- "github.com/charmbracelet/crush/internal/agent"
- "github.com/charmbracelet/crush/internal/agent/tools"
- "github.com/charmbracelet/crush/internal/app"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/permission"
- "github.com/charmbracelet/crush/internal/pubsub"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type SendMsg struct {
- Text string
- Attachments []message.Attachment
-}
-
-type SessionSelectedMsg = session.Session
-
-type SessionClearedMsg struct{}
-
-type SelectionCopyMsg struct {
- clickCount int
- endSelection bool
- x, y int
-}
-
-const (
- NotFound = -1
-)
-
-// MessageListCmp represents a component that displays a list of chat messages
-// with support for real-time updates and session management.
-type MessageListCmp interface {
- util.Model
- layout.Sizeable
- layout.Focusable
- layout.Help
-
- SetSession(session.Session) tea.Cmd
- GoToBottom() tea.Cmd
- GetSelectedText() string
- CopySelectedText(bool) tea.Cmd
-}
-
-// messageListCmp implements MessageListCmp, providing a virtualized list
-// of chat messages with support for tool calls, real-time updates, and
-// session switching.
-type messageListCmp struct {
- app *app.App
- width, height int
- session session.Session
- listCmp list.List[list.Item]
- previousSelected string // Last selected item index for restoring focus
-
- lastUserMessageTime int64
- defaultListKeyMap list.KeyMap
-
- // Click tracking for double/triple click detection
- lastClickTime time.Time
- lastClickX int
- lastClickY int
- clickCount int
-}
-
-// New creates a new message list component with custom keybindings
-// and reverse ordering (newest messages at bottom).
-func New(app *app.App) MessageListCmp {
- defaultListKeyMap := list.DefaultKeyMap()
- listCmp := list.New(
- []list.Item{},
- list.WithGap(1),
- list.WithDirectionBackward(),
- list.WithFocus(false),
- list.WithKeyMap(defaultListKeyMap),
- list.WithEnableMouse(),
- )
- return &messageListCmp{
- app: app,
- listCmp: listCmp,
- previousSelected: "",
- defaultListKeyMap: defaultListKeyMap,
- }
-}
-
-// Init initializes the component.
-func (m *messageListCmp) Init() tea.Cmd {
- return m.listCmp.Init()
-}
-
-// Update handles incoming messages and updates the component state.
-func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- if m.listCmp.IsFocused() && m.listCmp.HasSelection() {
- switch {
- case key.Matches(msg, messages.CopyKey):
- cmds = append(cmds, m.CopySelectedText(true))
- return m, tea.Batch(cmds...)
- case key.Matches(msg, messages.ClearSelectionKey):
- cmds = append(cmds, m.SelectionClear())
- return m, tea.Batch(cmds...)
- }
- }
- case tea.MouseClickMsg:
- x := msg.X - 1 // Adjust for padding
- y := msg.Y - 1 // Adjust for padding
- if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
- return m, nil // Ignore clicks outside the component
- }
- if msg.Button == tea.MouseLeft {
- cmds = append(cmds, m.handleMouseClick(x, y))
- return m, tea.Batch(cmds...)
- }
- return m, tea.Batch(cmds...)
- case tea.MouseMotionMsg:
- x := msg.X - 1 // Adjust for padding
- y := msg.Y - 1 // Adjust for padding
- if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
- if y < 0 {
- cmds = append(cmds, m.listCmp.MoveUp(1))
- return m, tea.Batch(cmds...)
- }
- if y >= m.height-1 {
- cmds = append(cmds, m.listCmp.MoveDown(1))
- return m, tea.Batch(cmds...)
- }
- return m, nil // Ignore clicks outside the component
- }
- if msg.Button == tea.MouseLeft {
- m.listCmp.EndSelection(x, y)
- }
- return m, tea.Batch(cmds...)
- case tea.MouseReleaseMsg:
- x := msg.X - 1 // Adjust for padding
- y := msg.Y - 1 // Adjust for padding
- if msg.Button == tea.MouseLeft {
- clickCount := m.clickCount
- if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
- tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
- return SelectionCopyMsg{
- clickCount: clickCount,
- endSelection: false,
- }
- })
-
- cmds = append(cmds, tick)
- return m, tea.Batch(cmds...)
- }
- tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
- return SelectionCopyMsg{
- clickCount: clickCount,
- endSelection: true,
- x: x,
- y: y,
- }
- })
- cmds = append(cmds, tick)
- return m, tea.Batch(cmds...)
- }
- return m, nil
- case SelectionCopyMsg:
- if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold {
- // If the click count matches and within threshold, copy selected text
- if msg.endSelection {
- m.listCmp.EndSelection(msg.x, msg.y)
- }
- m.listCmp.SelectionStop()
- cmds = append(cmds, m.CopySelectedText(true))
- return m, tea.Batch(cmds...)
- }
- case pubsub.Event[permission.PermissionNotification]:
- cmds = append(cmds, m.handlePermissionRequest(msg.Payload))
- return m, tea.Batch(cmds...)
- case SessionSelectedMsg:
- if msg.ID != m.session.ID {
- cmds = append(cmds, m.SetSession(msg))
- }
- return m, tea.Batch(cmds...)
- case SessionClearedMsg:
- m.session = session.Session{}
- cmds = append(cmds, m.listCmp.SetItems([]list.Item{}))
- return m, tea.Batch(cmds...)
-
- case pubsub.Event[message.Message]:
- cmds = append(cmds, m.handleMessageEvent(msg))
- return m, tea.Batch(cmds...)
-
- case tea.MouseWheelMsg:
- u, cmd := m.listCmp.Update(msg)
- m.listCmp = u.(list.List[list.Item])
- cmds = append(cmds, cmd)
- return m, tea.Batch(cmds...)
- }
-
- u, cmd := m.listCmp.Update(msg)
- m.listCmp = u.(list.List[list.Item])
- cmds = append(cmds, cmd)
- return m, tea.Batch(cmds...)
-}
-
-// View renders the message list or an initial screen if empty.
-func (m *messageListCmp) View() string {
- t := styles.CurrentTheme()
- return t.S().Base.
- Padding(1, 1, 0, 1).
- Width(m.width).
- Height(m.height).
- Render(m.listCmp.View())
-}
-
-func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
- items := m.listCmp.Items()
- if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
- toolCall := items[toolCallIndex].(messages.ToolCallCmp)
- toolCall.SetPermissionRequested()
- if permission.Granted {
- toolCall.SetPermissionGranted()
- }
- m.listCmp.UpdateItem(toolCall.ID(), toolCall)
- }
- return nil
-}
-
-// handleChildSession handles messages from child sessions (agent tools).
-func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
- var cmds []tea.Cmd
- if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
- return nil
- }
-
- // Check if this is an agent tool session and parse it
- childSessionID := event.Payload.SessionID
- parentMessageID, toolCallID, ok := m.app.Sessions.ParseAgentToolSessionID(childSessionID)
- if !ok {
- return nil
- }
- items := m.listCmp.Items()
- toolCallInx := NotFound
- var toolCall messages.ToolCallCmp
- for i := len(items) - 1; i >= 0; i-- {
- if msg, ok := items[i].(messages.ToolCallCmp); ok {
- if msg.ParentMessageID() == parentMessageID && msg.GetToolCall().ID == toolCallID {
- toolCallInx = i
- toolCall = msg
- }
- }
- }
- if toolCallInx == NotFound {
- return nil
- }
- nestedToolCalls := toolCall.GetNestedToolCalls()
- for _, tc := range event.Payload.ToolCalls() {
- found := false
- for existingInx, existingTC := range nestedToolCalls {
- if existingTC.GetToolCall().ID == tc.ID {
- nestedToolCalls[existingInx].SetToolCall(tc)
- found = true
- break
- }
- }
- if !found {
- nestedCall := messages.NewToolCallCmp(
- event.Payload.ID,
- tc,
- m.app.Permissions,
- messages.WithToolCallNested(true),
- )
- cmds = append(cmds, nestedCall.Init())
- nestedToolCalls = append(
- nestedToolCalls,
- nestedCall,
- )
- }
- }
- for _, tr := range event.Payload.ToolResults() {
- for nestedInx, nestedTC := range nestedToolCalls {
- if nestedTC.GetToolCall().ID == tr.ToolCallID {
- nestedToolCalls[nestedInx].SetToolResult(tr)
- break
- }
- }
- }
-
- toolCall.SetNestedToolCalls(nestedToolCalls)
- m.listCmp.UpdateItem(
- toolCall.ID(),
- toolCall,
- )
- return tea.Batch(cmds...)
-}
-
-// handleMessageEvent processes different types of message events (created/updated).
-func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
- switch event.Type {
- case pubsub.CreatedEvent:
- if event.Payload.SessionID != m.session.ID {
- return m.handleChildSession(event)
- }
- if m.messageExists(event.Payload.ID) {
- return nil
- }
- return m.handleNewMessage(event.Payload)
- case pubsub.DeletedEvent:
- if event.Payload.SessionID != m.session.ID {
- return nil
- }
- return m.handleDeleteMessage(event.Payload)
- case pubsub.UpdatedEvent:
- if event.Payload.SessionID != m.session.ID {
- return m.handleChildSession(event)
- }
- switch event.Payload.Role {
- case message.Assistant:
- return m.handleUpdateAssistantMessage(event.Payload)
- case message.Tool:
- return m.handleToolMessage(event.Payload)
- }
- }
- return nil
-}
-
-// messageExists checks if a message with the given ID already exists in the list.
-func (m *messageListCmp) messageExists(messageID string) bool {
- items := m.listCmp.Items()
- // Search backwards as new messages are more likely to be at the end
- for i := len(items) - 1; i >= 0; i-- {
- if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
- return true
- }
- }
- return false
-}
-
-// handleDeleteMessage removes a message from the list.
-func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd {
- items := m.listCmp.Items()
- for i := len(items) - 1; i >= 0; i-- {
- if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID {
- m.listCmp.DeleteItem(items[i].ID())
- return nil
- }
- }
- return nil
-}
-
-// handleNewMessage routes new messages to appropriate handlers based on role.
-func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
- switch msg.Role {
- case message.User:
- return m.handleNewUserMessage(msg)
- case message.Assistant:
- return m.handleNewAssistantMessage(msg)
- case message.Tool:
- return m.handleToolMessage(msg)
- }
- return nil
-}
-
-// handleNewUserMessage adds a new user message to the list and updates the timestamp.
-func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
- m.lastUserMessageTime = msg.CreatedAt
- return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
-}
-
-// handleToolMessage updates existing tool calls with their results.
-func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
- items := m.listCmp.Items()
- for _, tr := range msg.ToolResults() {
- if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
- toolCall := items[toolCallIndex].(messages.ToolCallCmp)
- toolCall.SetToolResult(tr)
- m.listCmp.UpdateItem(toolCall.ID(), toolCall)
- }
- }
- return nil
-}
-
-// findToolCallByID searches for a tool call with the specified ID.
-// Returns the index if found, NotFound otherwise.
-func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
- // Search backwards as tool calls are more likely to be recent
- for i := len(items) - 1; i >= 0; i-- {
- if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
- return i
- }
- }
- return NotFound
-}
-
-// handleUpdateAssistantMessage processes updates to assistant messages,
-// managing both message content and associated tool calls.
-func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
- var cmds []tea.Cmd
- items := m.listCmp.Items()
-
- // Find existing assistant message and tool calls for this message
- assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
-
- // Handle assistant message content
- if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- // Handle tool calls
- if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- return tea.Batch(cmds...)
-}
-
-// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
-func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
- assistantIndex := NotFound
- toolCalls := make(map[int]messages.ToolCallCmp)
-
- // Search backwards as messages are more likely to be at the end
- for i := len(items) - 1; i >= 0; i-- {
- item := items[i]
- if asMsg, ok := item.(messages.MessageCmp); ok {
- if asMsg.GetMessage().ID == messageID {
- assistantIndex = i
- }
- } else if tc, ok := item.(messages.ToolCallCmp); ok {
- if tc.ParentMessageID() == messageID {
- toolCalls[i] = tc
- }
- }
- }
-
- return assistantIndex, toolCalls
-}
-
-// updateAssistantMessageContent updates or removes the assistant message based on content.
-func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
- if assistantIndex == NotFound {
- return nil
- }
-
- shouldShowMessage := m.shouldShowAssistantMessage(msg)
- hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
-
- var cmd tea.Cmd
- if shouldShowMessage {
- items := m.listCmp.Items()
- uiMsg := items[assistantIndex].(messages.MessageCmp)
- uiMsg.SetMessage(msg)
- m.listCmp.UpdateItem(
- items[assistantIndex].ID(),
- uiMsg,
- )
- if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
- m.listCmp.AppendItem(
- messages.NewAssistantSection(
- msg,
- time.Unix(m.lastUserMessageTime, 0),
- ),
- )
- }
- } else if hasToolCallsOnly {
- items := m.listCmp.Items()
- m.listCmp.DeleteItem(items[assistantIndex].ID())
- }
-
- return cmd
-}
-
-// shouldShowAssistantMessage determines if an assistant message should be displayed.
-func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
- return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
-}
-
-// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
-func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
- var cmds []tea.Cmd
-
- for _, tc := range msg.ToolCalls() {
- if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
-
- return tea.Batch(cmds...)
-}
-
-// updateOrAddToolCall updates an existing tool call or adds a new one.
-func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
- // Try to find existing tool call
- for _, existingTC := range existingToolCalls {
- if tc.ID == existingTC.GetToolCall().ID {
- existingTC.SetToolCall(tc)
- if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
- existingTC.SetCancelled()
- }
- m.listCmp.UpdateItem(tc.ID, existingTC)
- return nil
- }
- }
-
- // Add new tool call if not found
- return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
-}
-
-// handleNewAssistantMessage processes new assistant messages and their tool calls.
-func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
- var cmds []tea.Cmd
-
- // Add assistant message if it should be displayed
- if m.shouldShowAssistantMessage(msg) {
- cmd := m.listCmp.AppendItem(
- messages.NewMessageCmp(
- msg,
- ),
- )
- cmds = append(cmds, cmd)
- }
-
- // Add tool calls
- for _, tc := range msg.ToolCalls() {
- cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
- cmds = append(cmds, cmd)
- }
-
- return tea.Batch(cmds...)
-}
-
-// SetSession loads and displays messages for a new session.
-func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
- if m.session.ID == session.ID {
- return nil
- }
-
- m.session = session
- sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
- if err != nil {
- return util.ReportError(err)
- }
-
- if len(sessionMessages) == 0 {
- return m.listCmp.SetItems([]list.Item{})
- }
-
- // Initialize with first message timestamp
- m.lastUserMessageTime = sessionMessages[0].CreatedAt
-
- // Build tool result map for efficient lookup
- toolResultMap := m.buildToolResultMap(sessionMessages)
-
- // Convert messages to UI components
- uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
-
- return m.listCmp.SetItems(uiMessages)
-}
-
-// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
-func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
- toolResultMap := make(map[string]message.ToolResult)
- for _, msg := range messages {
- for _, tr := range msg.ToolResults() {
- toolResultMap[tr.ToolCallID] = tr
- }
- }
- return toolResultMap
-}
-
-// convertMessagesToUI converts database messages to UI components.
-func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
- uiMessages := make([]list.Item, 0)
-
- for _, msg := range sessionMessages {
- switch msg.Role {
- case message.User:
- m.lastUserMessageTime = msg.CreatedAt
- uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
- case message.Assistant:
- uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
- if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
- uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
- }
- }
- }
-
- return uiMessages
-}
-
-// convertAssistantMessage converts an assistant message and its tool calls to UI components.
-func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
- var uiMessages []list.Item
-
- // Add assistant message if it should be displayed
- if m.shouldShowAssistantMessage(msg) {
- uiMessages = append(
- uiMessages,
- messages.NewMessageCmp(
- msg,
- ),
- )
- }
-
- // Add tool calls with their results and status
- for _, tc := range msg.ToolCalls() {
- options := m.buildToolCallOptions(tc, msg, toolResultMap)
- uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
- // If this tool call is the agent tool or agentic fetch, fetch nested tool calls
- if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
- agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID)
- nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID)
- nestedToolResultMap := m.buildToolResultMap(nestedMessages)
- nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
- nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
- for _, nestedMsg := range nestedUIMessages {
- if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
- toolCall.SetIsNested(true)
- nestedToolCalls = append(nestedToolCalls, toolCall)
- }
- }
- uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
- }
- }
-
- return uiMessages
-}
-
-// buildToolCallOptions creates options for tool call components based on results and status.
-func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
- var options []messages.ToolCallOption
-
- // Add tool result if available
- if tr, ok := toolResultMap[tc.ID]; ok {
- options = append(options, messages.WithToolCallResult(tr))
- }
-
- // Add cancelled status if applicable
- if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
- options = append(options, messages.WithToolCallCancelled())
- }
-
- return options
-}
-
-// GetSize returns the current width and height of the component.
-func (m *messageListCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-// SetSize updates the component dimensions and propagates to the list component.
-func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
- m.width = width
- m.height = height
- return m.listCmp.SetSize(width-2, max(0, height-1)) // for padding
-}
-
-// Blur implements MessageListCmp.
-func (m *messageListCmp) Blur() tea.Cmd {
- return m.listCmp.Blur()
-}
-
-// Focus implements MessageListCmp.
-func (m *messageListCmp) Focus() tea.Cmd {
- return m.listCmp.Focus()
-}
-
-// IsFocused implements MessageListCmp.
-func (m *messageListCmp) IsFocused() bool {
- return m.listCmp.IsFocused()
-}
-
-func (m *messageListCmp) Bindings() []key.Binding {
- return m.defaultListKeyMap.KeyBindings()
-}
-
-func (m *messageListCmp) GoToBottom() tea.Cmd {
- return m.listCmp.GoToBottom()
-}
-
-const (
- doubleClickThreshold = 500 * time.Millisecond
- clickTolerance = 2 // pixels
-)
-
-// handleMouseClick handles mouse click events and detects double/triple clicks.
-func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
- now := time.Now()
-
- // Check if this is a potential multi-click
- if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
- abs(x-m.lastClickX) <= clickTolerance &&
- abs(y-m.lastClickY) <= clickTolerance {
- m.clickCount++
- } else {
- m.clickCount = 1
- }
-
- m.lastClickTime = now
- m.lastClickX = x
- m.lastClickY = y
-
- switch m.clickCount {
- case 1:
- // Single click - start selection
- m.listCmp.StartSelection(x, y)
- case 2:
- // Double click - select word
- m.listCmp.SelectWord(x, y)
- case 3:
- // Triple click - select paragraph
- m.listCmp.SelectParagraph(x, y)
- m.clickCount = 0 // Reset after triple click
- }
-
- return nil
-}
-
-// SelectionClear clears the current selection in the list component.
-func (m *messageListCmp) SelectionClear() tea.Cmd {
- m.listCmp.SelectionClear()
- m.previousSelected = ""
- m.lastClickX, m.lastClickY = 0, 0
- m.lastClickTime = time.Time{}
- m.clickCount = 0
- return nil
-}
-
-// HasSelection checks if there is a selection in the list component.
-func (m *messageListCmp) HasSelection() bool {
- return m.listCmp.HasSelection()
-}
-
-// GetSelectedText returns the currently selected text from the list component.
-func (m *messageListCmp) GetSelectedText() string {
- return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
-}
-
-// CopySelectedText copies the currently selected text to the clipboard. When
-// clear is true, it clears the selection after copying.
-func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
- if !m.listCmp.HasSelection() {
- return nil
- }
-
- selectedText := m.GetSelectedText()
- if selectedText == "" {
- return util.ReportInfo("No text selected")
- }
-
- cmds := []tea.Cmd{
- // We use both OSC 52 and native clipboard for compatibility with different
- // terminal emulators and environments.
- tea.SetClipboard(selectedText),
- func() tea.Msg {
- _ = clipboard.WriteAll(selectedText)
- return nil
- },
- util.ReportInfo("Selected text copied to clipboard"),
- }
- if clear {
- cmds = append(cmds, m.SelectionClear())
- }
-
- return tea.Sequence(cmds...)
-}
-
-// abs returns the absolute value of an integer.
-func abs(x int) int {
- if x < 0 {
- return -x
- }
- return x
-}
@@ -1,8 +0,0 @@
-package editor
-
-type clipboardFormat int
-
-const (
- clipboardFormatText clipboardFormat = iota
- clipboardFormatImage
-)
@@ -1,7 +0,0 @@
-//go:build !(darwin || linux || windows) || arm || 386 || ios || android
-
-package editor
-
-func readClipboard(clipboardFormat) ([]byte, error) {
- return nil, errClipboardPlatformUnsupported
-}
@@ -1,15 +0,0 @@
-//go:build (linux || darwin || windows) && !arm && !386 && !ios && !android
-
-package editor
-
-import "github.com/aymanbagabas/go-nativeclipboard"
-
-func readClipboard(f clipboardFormat) ([]byte, error) {
- switch f {
- case clipboardFormatText:
- return nativeclipboard.Text.Read()
- case clipboardFormatImage:
- return nativeclipboard.Image.Read()
- }
- return nil, errClipboardUnknownFormat
-}
@@ -1,780 +0,0 @@
-package editor
-
-import (
- "context"
- "fmt"
- "math/rand"
- "net/http"
- "os"
- "path/filepath"
- "regexp"
- "slices"
- "strconv"
- "strings"
- "unicode"
-
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/textarea"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/app"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/tui/components/chat"
- "github.com/charmbracelet/crush/internal/tui/components/completions"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/editor"
-)
-
-var (
- errClipboardPlatformUnsupported = fmt.Errorf("clipboard operations are not supported on this platform")
- errClipboardUnknownFormat = fmt.Errorf("unknown clipboard format")
-)
-
-// If pasted text has more than 10 newlines, treat it as a file attachment.
-const pasteLinesThreshold = 10
-
-type Editor interface {
- util.Model
- layout.Sizeable
- layout.Focusable
- layout.Help
- layout.Positional
-
- SetSession(session session.Session) tea.Cmd
- IsCompletionsOpen() bool
- HasAttachments() bool
- IsEmpty() bool
- Cursor() *tea.Cursor
-}
-
-type FileCompletionItem struct {
- Path string // The file path
-}
-
-type editorCmp struct {
- width int
- height int
- x, y int
- app *app.App
- session session.Session
- sessionFileReads []string
- textarea textarea.Model
- attachments []message.Attachment
- deleteMode bool
- readyPlaceholder string
- workingPlaceholder string
-
- keyMap EditorKeyMap
-
- // File path completions
- currentQuery string
- completionsStartIndex int
- isCompletionsOpen bool
-}
-
-var DeleteKeyMaps = DeleteAttachmentKeyMaps{
- AttachmentDeleteMode: key.NewBinding(
- key.WithKeys("ctrl+r"),
- key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel delete mode"),
- ),
- DeleteAllAttachments: key.NewBinding(
- key.WithKeys("r"),
- key.WithHelp("ctrl+r+r", "delete all attachments"),
- ),
-}
-
-const maxFileResults = 25
-
-type OpenEditorMsg struct {
- Text string
-}
-
-func (m *editorCmp) openEditor(value string) tea.Cmd {
- tmpfile, err := os.CreateTemp("", "msg_*.md")
- if err != nil {
- return util.ReportError(err)
- }
- defer tmpfile.Close() //nolint:errcheck
- if _, err := tmpfile.WriteString(value); err != nil {
- return util.ReportError(err)
- }
- cmd, err := editor.Command(
- "crush",
- tmpfile.Name(),
- editor.AtPosition(
- m.textarea.Line()+1,
- m.textarea.Column()+1,
- ),
- )
- if err != nil {
- return util.ReportError(err)
- }
- return tea.ExecProcess(cmd, func(err error) tea.Msg {
- if err != nil {
- return util.ReportError(err)
- }
- content, err := os.ReadFile(tmpfile.Name())
- if err != nil {
- return util.ReportError(err)
- }
- if len(content) == 0 {
- return util.ReportWarn("Message is empty")
- }
- os.Remove(tmpfile.Name())
- return OpenEditorMsg{
- Text: strings.TrimSpace(string(content)),
- }
- })
-}
-
-func (m *editorCmp) Init() tea.Cmd {
- return nil
-}
-
-func (m *editorCmp) send() tea.Cmd {
- value := m.textarea.Value()
- value = strings.TrimSpace(value)
-
- switch value {
- case "exit", "quit":
- m.textarea.Reset()
- return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
- }
-
- attachments := m.attachments
-
- if value == "" && !message.ContainsTextAttachment(attachments) {
- return nil
- }
-
- m.textarea.Reset()
- m.attachments = nil
- // Change the placeholder when sending a new message.
- m.randomizePlaceholders()
-
- return tea.Batch(
- util.CmdHandler(chat.SendMsg{
- Text: value,
- Attachments: attachments,
- }),
- )
-}
-
-func (m *editorCmp) repositionCompletions() tea.Msg {
- x, y := m.completionsPosition()
- return completions.RepositionCompletionsMsg{X: x, Y: y}
-}
-
-func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- var cmd tea.Cmd
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case chat.SessionClearedMsg:
- m.session = session.Session{}
- m.sessionFileReads = nil
- case tea.WindowSizeMsg:
- return m, m.repositionCompletions
- case filepicker.FilePickedMsg:
- m.attachments = append(m.attachments, msg.Attachment)
- return m, nil
- case completions.CompletionsOpenedMsg:
- m.isCompletionsOpen = true
- case completions.CompletionsClosedMsg:
- m.isCompletionsOpen = false
- m.currentQuery = ""
- m.completionsStartIndex = 0
- case completions.SelectCompletionMsg:
- if !m.isCompletionsOpen {
- return m, nil
- }
- if item, ok := msg.Value.(FileCompletionItem); ok {
- word := m.textarea.Word()
- // If the selected item is a file, insert its path into the textarea
- value := m.textarea.Value()
- value = value[:m.completionsStartIndex] + // Remove the current query
- item.Path + // Insert the file path
- value[m.completionsStartIndex+len(word):] // Append the rest of the value
- // XXX: This will always move the cursor to the end of the textarea.
- m.textarea.SetValue(value)
- m.textarea.MoveToEnd()
- if !msg.Insert {
- m.isCompletionsOpen = false
- m.currentQuery = ""
- m.completionsStartIndex = 0
- }
- absPath, _ := filepath.Abs(item.Path)
-
- ctx := context.Background()
-
- // Skip attachment if file was already read and hasn't been modified.
- if m.session.ID != "" {
- lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath)
- if !lastRead.IsZero() {
- if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
- return m, nil
- }
- }
- } else if slices.Contains(m.sessionFileReads, absPath) {
- return m, nil
- }
-
- m.sessionFileReads = append(m.sessionFileReads, absPath)
- content, err := os.ReadFile(item.Path)
- if err != nil {
- // if it fails, let the LLM handle it later.
- return m, nil
- }
- m.attachments = append(m.attachments, message.Attachment{
- FilePath: item.Path,
- FileName: filepath.Base(item.Path),
- MimeType: mimeOf(content),
- Content: content,
- })
- }
-
- case commands.OpenExternalEditorMsg:
- if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
- return m, util.ReportWarn("Agent is working, please wait...")
- }
- return m, m.openEditor(m.textarea.Value())
- case OpenEditorMsg:
- m.textarea.SetValue(msg.Text)
- m.textarea.MoveToEnd()
- case tea.PasteMsg:
- if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
- content := []byte(msg.Content)
- if len(content) > maxAttachmentSize {
- return m, util.ReportWarn("Paste is too big (>5mb)")
- }
- name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
- mimeType := mimeOf(content)
- attachment := message.Attachment{
- FileName: name,
- FilePath: name,
- MimeType: mimeType,
- Content: content,
- }
- return m, util.CmdHandler(filepicker.FilePickedMsg{
- Attachment: attachment,
- })
- }
-
- // Try to parse as a file path.
- content, path, err := filepathToFile(msg.Content)
- if err != nil {
- // Not a file path, just update the textarea normally.
- m.textarea, cmd = m.textarea.Update(msg)
- return m, cmd
- }
-
- if len(content) > maxAttachmentSize {
- return m, util.ReportWarn("File is too big (>5mb)")
- }
-
- mimeType := mimeOf(content)
- attachment := message.Attachment{
- FilePath: path,
- FileName: filepath.Base(path),
- MimeType: mimeType,
- Content: content,
- }
- if !attachment.IsText() && !attachment.IsImage() {
- return m, util.ReportWarn("Invalid file content type: " + mimeType)
- }
- return m, util.CmdHandler(filepicker.FilePickedMsg{
- Attachment: attachment,
- })
-
- case commands.ToggleYoloModeMsg:
- m.setEditorPrompt()
- return m, nil
- case tea.KeyPressMsg:
- cur := m.textarea.Cursor()
- curIdx := m.textarea.Width()*cur.Y + cur.X
- switch {
- // Open command palette when "/" is pressed on empty prompt
- case msg.String() == "/" && m.IsEmpty():
- return m, util.CmdHandler(dialogs.OpenDialogMsg{
- Model: commands.NewCommandDialog(m.session.ID),
- })
- // Completions
- case msg.String() == "@" && !m.isCompletionsOpen &&
- // only show if beginning of prompt, or if previous char is a space or newline:
- (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
- m.isCompletionsOpen = true
- m.currentQuery = ""
- m.completionsStartIndex = curIdx
- cmds = append(cmds, m.startCompletions)
- case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
- cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
- }
- if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
- m.deleteMode = true
- return m, nil
- }
- if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
- m.deleteMode = false
- m.attachments = nil
- return m, nil
- }
- rune := msg.Code
- if m.deleteMode && unicode.IsDigit(rune) {
- num := int(rune - '0')
- m.deleteMode = false
- if num < 10 && len(m.attachments) > num {
- if num == 0 {
- m.attachments = m.attachments[num+1:]
- } else {
- m.attachments = slices.Delete(m.attachments, num, num+1)
- }
- return m, nil
- }
- }
- if key.Matches(msg, m.keyMap.OpenEditor) {
- if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
- return m, util.ReportWarn("Agent is working, please wait...")
- }
- return m, m.openEditor(m.textarea.Value())
- }
- if key.Matches(msg, DeleteKeyMaps.Escape) {
- m.deleteMode = false
- return m, nil
- }
- if key.Matches(msg, m.keyMap.Newline) {
- m.textarea.InsertRune('\n')
- cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
- }
- // Handle image paste from clipboard
- if key.Matches(msg, m.keyMap.PasteImage) {
- imageData, err := readClipboard(clipboardFormatImage)
-
- if err != nil || len(imageData) == 0 {
- // If no image data found, try to get text data (could be file path)
- var textData []byte
- textData, err = readClipboard(clipboardFormatText)
- if err != nil || len(textData) == 0 {
- // If clipboard is empty, show a warning
- return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.")
- }
-
- // Check if the text data is a file path
- textStr := string(textData)
- // First, try to interpret as a file path (existing functionality)
- path := strings.ReplaceAll(textStr, "\\ ", " ")
- path, err = filepath.Abs(strings.TrimSpace(path))
- if err == nil {
- isAllowedType := false
- for _, ext := range filepicker.AllowedTypes {
- if strings.HasSuffix(path, ext) {
- isAllowedType = true
- break
- }
- }
- if isAllowedType {
- tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
- if !tooBig {
- content, err := os.ReadFile(path)
- if err == nil {
- mimeBufferSize := min(512, len(content))
- mimeType := http.DetectContentType(content[:mimeBufferSize])
- fileName := filepath.Base(path)
- attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
- return m, util.CmdHandler(filepicker.FilePickedMsg{
- Attachment: attachment,
- })
- }
- }
- }
- }
-
- // If not a valid file path, show a warning
- return m, util.ReportWarn("No image found in clipboard")
- } else {
- // We have image data from the clipboard
- // Create a temporary file to store the clipboard image data
- tempFile, err := os.CreateTemp("", "clipboard_image_crush_*")
- if err != nil {
- return m, util.ReportError(err)
- }
- defer tempFile.Close()
-
- // Write clipboard content to the temporary file
- _, err = tempFile.Write(imageData)
- if err != nil {
- return m, util.ReportError(err)
- }
-
- // Determine the file extension based on the image data
- mimeBufferSize := min(512, len(imageData))
- mimeType := http.DetectContentType(imageData[:mimeBufferSize])
-
- // Create an attachment from the temporary file
- fileName := filepath.Base(tempFile.Name())
- attachment := message.Attachment{
- FilePath: tempFile.Name(),
- FileName: fileName,
- MimeType: mimeType,
- Content: imageData,
- }
-
- return m, util.CmdHandler(filepicker.FilePickedMsg{
- Attachment: attachment,
- })
- }
- }
- // Handle Enter key
- if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
- value := m.textarea.Value()
- if strings.HasSuffix(value, "\\") {
- // If the last character is a backslash, remove it and add a newline.
- m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
- } else {
- // Otherwise, send the message
- return m, m.send()
- }
- }
- }
-
- m.textarea, cmd = m.textarea.Update(msg)
- cmds = append(cmds, cmd)
-
- if m.textarea.Focused() {
- kp, ok := msg.(tea.KeyPressMsg)
- if ok {
- if kp.String() == "space" || m.textarea.Value() == "" {
- m.isCompletionsOpen = false
- m.currentQuery = ""
- m.completionsStartIndex = 0
- cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
- } else {
- word := m.textarea.Word()
- if strings.HasPrefix(word, "@") {
- // XXX: wont' work if editing in the middle of the field.
- m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
- m.currentQuery = word[1:]
- x, y := m.completionsPosition()
- x -= len(m.currentQuery)
- m.isCompletionsOpen = true
- cmds = append(cmds,
- util.CmdHandler(completions.FilterCompletionsMsg{
- Query: m.currentQuery,
- Reopen: m.isCompletionsOpen,
- X: x,
- Y: y,
- }),
- )
- } else if m.isCompletionsOpen {
- m.isCompletionsOpen = false
- m.currentQuery = ""
- m.completionsStartIndex = 0
- cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
- }
- }
- }
- }
-
- return m, tea.Batch(cmds...)
-}
-
-func (m *editorCmp) setEditorPrompt() {
- if m.app.Permissions.SkipRequests() {
- m.textarea.SetPromptFunc(4, yoloPromptFunc)
- return
- }
- m.textarea.SetPromptFunc(4, normalPromptFunc)
-}
-
-func (m *editorCmp) completionsPosition() (int, int) {
- cur := m.textarea.Cursor()
- if cur == nil {
- return m.x, m.y + 1 // adjust for padding
- }
- x := cur.X + m.x
- y := cur.Y + m.y + 1 // adjust for padding
- return x, y
-}
-
-func (m *editorCmp) Cursor() *tea.Cursor {
- cursor := m.textarea.Cursor()
- if cursor != nil {
- cursor.X = cursor.X + m.x + 1
- cursor.Y = cursor.Y + m.y + 1 // adjust for padding
- }
- return cursor
-}
-
-var readyPlaceholders = [...]string{
- "Ready!",
- "Ready...",
- "Ready?",
- "Ready for instructions",
-}
-
-var workingPlaceholders = [...]string{
- "Working!",
- "Working...",
- "Brrrrr...",
- "Prrrrrrrr...",
- "Processing...",
- "Thinking...",
-}
-
-func (m *editorCmp) randomizePlaceholders() {
- m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
- m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
-}
-
-func (m *editorCmp) View() string {
- t := styles.CurrentTheme()
- // Update placeholder
- if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
- m.textarea.Placeholder = m.workingPlaceholder
- } else {
- m.textarea.Placeholder = m.readyPlaceholder
- }
- if m.app.Permissions.SkipRequests() {
- m.textarea.Placeholder = "Yolo mode!"
- }
- if len(m.attachments) == 0 {
- return t.S().Base.Padding(1).Render(
- m.textarea.View(),
- )
- }
- return t.S().Base.Padding(0, 1, 1, 1).Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- m.attachmentsContent(),
- m.textarea.View(),
- ),
- )
-}
-
-func (m *editorCmp) SetSize(width, height int) tea.Cmd {
- m.width = width
- m.height = height
- m.textarea.SetWidth(width - 2) // adjust for padding
- m.textarea.SetHeight(height - 2) // adjust for padding
- return nil
-}
-
-func (m *editorCmp) GetSize() (int, int) {
- return m.textarea.Width(), m.textarea.Height()
-}
-
-func (m *editorCmp) attachmentsContent() string {
- var styledAttachments []string
- t := styles.CurrentTheme()
- attachmentStyle := t.S().Base.
- Padding(0, 1).
- MarginRight(1).
- Background(t.FgMuted).
- Foreground(t.FgBase).
- Render
- iconStyle := t.S().Base.
- Foreground(t.BgSubtle).
- Background(t.Green).
- Padding(0, 1).
- Bold(true).
- Render
- rmStyle := t.S().Base.
- Padding(0, 1).
- Bold(true).
- Background(t.Red).
- Foreground(t.FgBase).
- Render
- for i, attachment := range m.attachments {
- filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
- icon := styles.ImageIcon
- if attachment.IsText() {
- icon = styles.TextIcon
- }
- if m.deleteMode {
- styledAttachments = append(
- styledAttachments,
- rmStyle(fmt.Sprintf("%d", i)),
- attachmentStyle(filename),
- )
- continue
- }
- styledAttachments = append(
- styledAttachments,
- iconStyle(icon),
- attachmentStyle(filename),
- )
- }
- return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
-}
-
-func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
- m.x = x
- m.y = y
- return nil
-}
-
-func (m *editorCmp) startCompletions() tea.Msg {
- ls := m.app.Config().Options.TUI.Completions
- depth, limit := ls.Limits()
- files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
- slices.Sort(files)
- completionItems := make([]completions.Completion, 0, len(files))
- for _, file := range files {
- file = strings.TrimPrefix(file, "./")
- completionItems = append(completionItems, completions.Completion{
- Title: file,
- Value: FileCompletionItem{
- Path: file,
- },
- })
- }
-
- x, y := m.completionsPosition()
- return completions.OpenCompletionsMsg{
- Completions: completionItems,
- X: x,
- Y: y,
- MaxResults: maxFileResults,
- }
-}
-
-// Blur implements Container.
-func (c *editorCmp) Blur() tea.Cmd {
- c.textarea.Blur()
- return nil
-}
-
-// Focus implements Container.
-func (c *editorCmp) Focus() tea.Cmd {
- return c.textarea.Focus()
-}
-
-// IsFocused implements Container.
-func (c *editorCmp) IsFocused() bool {
- return c.textarea.Focused()
-}
-
-// Bindings implements Container.
-func (c *editorCmp) Bindings() []key.Binding {
- return c.keyMap.KeyBindings()
-}
-
-// TODO: most likely we do not need to have the session here
-// we need to move some functionality to the page level
-func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
- c.session = session
- for _, path := range c.sessionFileReads {
- c.app.FileTracker.RecordRead(context.Background(), session.ID, path)
- }
- return nil
-}
-
-func (c *editorCmp) IsCompletionsOpen() bool {
- return c.isCompletionsOpen
-}
-
-func (c *editorCmp) HasAttachments() bool {
- return len(c.attachments) > 0
-}
-
-func (c *editorCmp) IsEmpty() bool {
- return strings.TrimSpace(c.textarea.Value()) == ""
-}
-
-func normalPromptFunc(info textarea.PromptInfo) string {
- t := styles.CurrentTheme()
- if info.LineNumber == 0 {
- if info.Focused {
- return " > "
- }
- return "::: "
- }
- if info.Focused {
- return t.S().Base.Foreground(t.GreenDark).Render("::: ")
- }
- return t.S().Muted.Render("::: ")
-}
-
-func yoloPromptFunc(info textarea.PromptInfo) string {
- t := styles.CurrentTheme()
- if info.LineNumber == 0 {
- if info.Focused {
- return fmt.Sprintf("%s ", t.YoloIconFocused)
- } else {
- return fmt.Sprintf("%s ", t.YoloIconBlurred)
- }
- }
- if info.Focused {
- return fmt.Sprintf("%s ", t.YoloDotsFocused)
- }
- return fmt.Sprintf("%s ", t.YoloDotsBlurred)
-}
-
-func New(app *app.App) Editor {
- t := styles.CurrentTheme()
- ta := textarea.New()
- ta.SetStyles(t.S().TextArea)
- ta.ShowLineNumbers = false
- ta.CharLimit = -1
- ta.SetVirtualCursor(false)
- ta.Focus()
- e := &editorCmp{
- // TODO: remove the app instance from here
- app: app,
- textarea: ta,
- keyMap: DefaultEditorKeyMap(),
- }
- e.setEditorPrompt()
-
- e.randomizePlaceholders()
- e.textarea.Placeholder = e.readyPlaceholder
-
- return e
-}
-
-var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
-
-var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
-
-func (m *editorCmp) pasteIdx() int {
- result := 0
- for _, at := range m.attachments {
- found := pasteRE.FindStringSubmatch(at.FileName)
- if len(found) == 0 {
- continue
- }
- idx, err := strconv.Atoi(found[1])
- if err == nil {
- result = max(result, idx)
- }
- }
- return result + 1
-}
-
-func filepathToFile(name string) ([]byte, string, error) {
- path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
- if err != nil {
- return nil, "", err
- }
- content, err := os.ReadFile(path)
- if err != nil {
- return nil, "", err
- }
- return content, path, nil
-}
-
-func mimeOf(content []byte) string {
- mimeBufferSize := min(512, len(content))
- return http.DetectContentType(content[:mimeBufferSize])
-}
@@ -1,77 +0,0 @@
-package editor
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-type EditorKeyMap struct {
- AddFile key.Binding
- SendMessage key.Binding
- OpenEditor key.Binding
- Newline key.Binding
- PasteImage key.Binding
-}
-
-func DefaultEditorKeyMap() EditorKeyMap {
- return EditorKeyMap{
- AddFile: key.NewBinding(
- key.WithKeys("/"),
- key.WithHelp("/", "add file"),
- ),
- SendMessage: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "send"),
- ),
- OpenEditor: key.NewBinding(
- key.WithKeys("ctrl+o"),
- key.WithHelp("ctrl+o", "open editor"),
- ),
- Newline: key.NewBinding(
- key.WithKeys("shift+enter", "ctrl+j"),
- // "ctrl+j" is a common keybinding for newline in many editors. If
- // the terminal supports "shift+enter", we substitute the help text
- // to reflect that.
- key.WithHelp("ctrl+j", "newline"),
- ),
- PasteImage: key.NewBinding(
- key.WithKeys("ctrl+v"),
- key.WithHelp("ctrl+v", "paste image from clipboard"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k EditorKeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.AddFile,
- k.SendMessage,
- k.OpenEditor,
- k.Newline,
- k.PasteImage,
- AttachmentsKeyMaps.AttachmentDeleteMode,
- AttachmentsKeyMaps.DeleteAllAttachments,
- AttachmentsKeyMaps.Escape,
- }
-}
-
-type DeleteAttachmentKeyMaps struct {
- AttachmentDeleteMode key.Binding
- Escape key.Binding
- DeleteAllAttachments key.Binding
-}
-
-// TODO: update this to use the new keymap concepts
-var AttachmentsKeyMaps = DeleteAttachmentKeyMaps{
- AttachmentDeleteMode: key.NewBinding(
- key.WithKeys("ctrl+r"),
- key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
- ),
- Escape: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel delete mode"),
- ),
- DeleteAllAttachments: key.NewBinding(
- key.WithKeys("r"),
- key.WithHelp("ctrl+r+r", "delete all attachments"),
- ),
-}
@@ -1,160 +0,0 @@
-package header
-
-import (
- "fmt"
- "strings"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/lsp"
- "github.com/charmbracelet/crush/internal/pubsub"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/x/ansi"
-)
-
-type Header interface {
- util.Model
- SetSession(session session.Session) tea.Cmd
- SetWidth(width int) tea.Cmd
- SetDetailsOpen(open bool)
- ShowingDetails() bool
-}
-
-type header struct {
- width int
- session session.Session
- lspClients *csync.Map[string, *lsp.Client]
- detailsOpen bool
-}
-
-func New(lspClients *csync.Map[string, *lsp.Client]) Header {
- return &header{
- lspClients: lspClients,
- width: 0,
- }
-}
-
-func (h *header) Init() tea.Cmd {
- return nil
-}
-
-func (h *header) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case pubsub.Event[session.Session]:
- if msg.Type == pubsub.UpdatedEvent {
- if h.session.ID == msg.Payload.ID {
- h.session = msg.Payload
- }
- }
- }
- return h, nil
-}
-
-func (h *header) View() string {
- if h.session.ID == "" {
- return ""
- }
-
- const (
- gap = " "
- diag = "β±"
- minDiags = 3
- leftPadding = 1
- rightPadding = 1
- )
-
- t := styles.CurrentTheme()
-
- var b strings.Builder
-
- b.WriteString(t.S().Base.Foreground(t.Secondary).Render("Charmβ’"))
- b.WriteString(gap)
- b.WriteString(styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary))
- b.WriteString(gap)
-
- availDetailWidth := h.width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minDiags
- details := h.details(availDetailWidth)
-
- remainingWidth := h.width -
- lipgloss.Width(b.String()) -
- lipgloss.Width(details) -
- leftPadding -
- rightPadding
-
- if remainingWidth > 0 {
- b.WriteString(t.S().Base.Foreground(t.Primary).Render(
- strings.Repeat(diag, max(minDiags, remainingWidth)),
- ))
- b.WriteString(gap)
- }
-
- b.WriteString(details)
-
- return t.S().Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
-}
-
-func (h *header) details(availWidth int) string {
- s := styles.CurrentTheme().S()
-
- var parts []string
-
- errorCount := 0
- for l := range h.lspClients.Seq() {
- errorCount += l.GetDiagnosticCounts().Error
- }
-
- if errorCount > 0 {
- parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
- }
-
- agentCfg := config.Get().Agents[config.AgentCoder]
- model := config.Get().GetModelByType(agentCfg.Model)
- percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
- formattedPercentage := s.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
- parts = append(parts, formattedPercentage)
-
- const keystroke = "ctrl+d"
- if h.detailsOpen {
- parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" close"))
- } else {
- parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" open "))
- }
-
- dot := s.Subtle.Render(" β’ ")
- metadata := strings.Join(parts, dot)
- metadata = dot + metadata
-
- // Truncate cwd if necessary, and insert it at the beginning.
- const dirTrimLimit = 4
- cwd := fsext.DirTrim(fsext.PrettyPath(config.Get().WorkingDir()), dirTrimLimit)
- cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "β¦")
- cwd = s.Muted.Render(cwd)
-
- return cwd + metadata
-}
-
-func (h *header) SetDetailsOpen(open bool) {
- h.detailsOpen = open
-}
-
-// SetSession implements Header.
-func (h *header) SetSession(session session.Session) tea.Cmd {
- h.session = session
- return nil
-}
-
-// SetWidth implements Header.
-func (h *header) SetWidth(width int) tea.Cmd {
- h.width = width
- return nil
-}
-
-// ShowingDetails implements Header.
-func (h *header) ShowingDetails() bool {
- return h.detailsOpen
-}
@@ -1,461 +0,0 @@
-package messages
-
-import (
- "fmt"
- "path/filepath"
- "strings"
- "time"
-
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/viewport"
- tea "charm.land/bubbletea/v2"
- "charm.land/catwalk/pkg/catwalk"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/exp/ordered"
- "github.com/google/uuid"
-
- "github.com/atotto/clipboard"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/tui/components/anim"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-// CopyKey is the key binding for copying message content to the clipboard.
-var CopyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy"))
-
-// ClearSelectionKey is the key binding for clearing the current selection in the chat interface.
-var ClearSelectionKey = key.NewBinding(key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "clear selection"))
-
-// MessageCmp defines the interface for message components in the chat interface.
-// It combines standard UI model interfaces with message-specific functionality.
-type MessageCmp interface {
- util.Model // Basic Bubble util.Model interface
- layout.Sizeable // Width/height management
- layout.Focusable // Focus state management
- GetMessage() message.Message // Access to underlying message data
- SetMessage(msg message.Message) // Update the message content
- Spinning() bool // Animation state for loading messages
- ID() string
-}
-
-// messageCmp implements the MessageCmp interface for displaying chat messages.
-// It handles rendering of user and assistant messages with proper styling,
-// animations, and state management.
-type messageCmp struct {
- width int // Component width for text wrapping
- focused bool // Focus state for border styling
-
- // Core message data and state
- message message.Message // The underlying message content
- spinning bool // Whether to show loading animation
- anim *anim.Anim // Animation component for loading states
-
- // Thinking viewport for displaying reasoning content
- thinkingViewport viewport.Model
-}
-
-var focusedMessageBorder = lipgloss.Border{
- Left: "β",
-}
-
-// NewMessageCmp creates a new message component with the given message and options
-func NewMessageCmp(msg message.Message) MessageCmp {
- t := styles.CurrentTheme()
-
- thinkingViewport := viewport.New()
- thinkingViewport.SetHeight(1)
- thinkingViewport.KeyMap = viewport.KeyMap{}
-
- m := &messageCmp{
- message: msg,
- anim: anim.New(anim.Settings{
- Size: 15,
- GradColorA: t.Primary,
- GradColorB: t.Secondary,
- CycleColors: true,
- }),
- thinkingViewport: thinkingViewport,
- }
- return m
-}
-
-// Init initializes the message component and starts animations if needed.
-// Returns a command to start the animation for spinning messages.
-func (m *messageCmp) Init() tea.Cmd {
- m.spinning = m.shouldSpin()
- return m.anim.Init()
-}
-
-// Update handles incoming messages and updates the component state.
-// Manages animation updates for spinning messages and stops animation when appropriate.
-func (m *messageCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case anim.StepMsg:
- m.spinning = m.shouldSpin()
- if m.spinning {
- u, cmd := m.anim.Update(msg)
- m.anim = u.(*anim.Anim)
- return m, cmd
- }
- case tea.KeyPressMsg:
- if key.Matches(msg, CopyKey) {
- return m, tea.Sequence(
- tea.SetClipboard(m.message.Content().Text),
- func() tea.Msg {
- _ = clipboard.WriteAll(m.message.Content().Text)
- return nil
- },
- util.ReportInfo("Message copied to clipboard"),
- )
- }
- }
- return m, nil
-}
-
-// View renders the message component based on its current state.
-// Returns different views for spinning, user, and assistant messages.
-func (m *messageCmp) View() string {
- if m.spinning && m.message.ReasoningContent().Thinking == "" {
- if m.message.IsSummaryMessage {
- m.anim.SetLabel("Summarizing")
- }
- return m.style().PaddingLeft(1).Render(m.anim.View())
- }
- if m.message.ID != "" {
- // this is a user or assistant message
- switch m.message.Role {
- case message.User:
- return m.renderUserMessage()
- default:
- return m.renderAssistantMessage()
- }
- }
- return m.style().Render("No message content")
-}
-
-// GetMessage returns the underlying message data
-func (m *messageCmp) GetMessage() message.Message {
- return m.message
-}
-
-func (m *messageCmp) SetMessage(msg message.Message) {
- m.message = msg
-}
-
-// textWidth calculates the available width for text content,
-// accounting for borders and padding
-func (m *messageCmp) textWidth() int {
- return m.width - 2 // take into account the border and/or padding
-}
-
-// style returns the lipgloss style for the message component.
-// Applies different border colors and styles based on message role and focus state.
-func (msg *messageCmp) style() lipgloss.Style {
- t := styles.CurrentTheme()
- borderStyle := lipgloss.NormalBorder()
- if msg.focused {
- borderStyle = focusedMessageBorder
- }
-
- style := t.S().Text
- if msg.message.Role == message.User {
- style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.Primary)
- } else {
- if msg.focused {
- style = style.PaddingLeft(1).BorderLeft(true).BorderStyle(borderStyle).BorderForeground(t.GreenDark)
- } else {
- style = style.PaddingLeft(2)
- }
- }
- return style
-}
-
-// renderAssistantMessage renders assistant messages with optional footer information.
-// Shows model name, response time, and finish reason when the message is complete.
-func (m *messageCmp) renderAssistantMessage() string {
- t := styles.CurrentTheme()
- parts := []string{}
- content := strings.TrimSpace(m.message.Content().String())
- thinking := m.message.IsThinking()
- thinkingContent := strings.TrimSpace(m.message.ReasoningContent().Thinking)
- finished := m.message.IsFinished()
- finishedData := m.message.FinishPart()
-
- if thinking || thinkingContent != "" {
- m.anim.SetLabel("Thinking")
- thinkingContent = m.renderThinkingContent()
- } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
- // Don't render empty assistant messages with EndTurn
- return ""
- } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
- content = "*Canceled*"
- } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
- errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
- truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
- title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
- details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
- errorContent := fmt.Sprintf("%s\n\n%s", title, details)
- return m.style().Render(errorContent)
- }
-
- if thinkingContent != "" {
- parts = append(parts, thinkingContent)
- }
-
- if content != "" {
- if thinkingContent != "" {
- parts = append(parts, "")
- }
- parts = append(parts, m.toMarkdown(content))
- }
-
- joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
- return m.style().Render(joined)
-}
-
-// renderUserMessage renders user messages with file attachments. It displays
-// message content and any attached files with appropriate icons.
-func (m *messageCmp) renderUserMessage() string {
- t := styles.CurrentTheme()
- var parts []string
-
- if s := m.message.Content().String(); s != "" {
- parts = append(parts, m.toMarkdown(s))
- }
-
- attachmentStyle := t.S().Base.
- Padding(0, 1).
- MarginRight(1).
- Background(t.FgMuted).
- Foreground(t.FgBase).
- Render
- iconStyle := t.S().Base.
- Foreground(t.BgSubtle).
- Background(t.Green).
- Padding(0, 1).
- Bold(true).
- Render
-
- attachments := make([]string, len(m.message.BinaryContent()))
- for i, attachment := range m.message.BinaryContent() {
- const maxFilenameWidth = 10
- filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...")
- icon := styles.ImageIcon
- if strings.HasPrefix(attachment.MIMEType, "text/") {
- icon = styles.TextIcon
- }
- attachments[i] = lipgloss.JoinHorizontal(
- lipgloss.Left,
- iconStyle(icon),
- attachmentStyle(filename),
- )
- }
-
- if len(attachments) > 0 {
- parts = append(parts, strings.Join(attachments, ""))
- }
-
- joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
- return m.style().Render(joined)
-}
-
-// toMarkdown converts text content to rendered markdown using the configured renderer
-func (m *messageCmp) toMarkdown(content string) string {
- r := styles.GetMarkdownRenderer(m.textWidth())
- rendered, _ := r.Render(content)
- return strings.TrimSuffix(rendered, "\n")
-}
-
-func (m *messageCmp) renderThinkingContent() string {
- t := styles.CurrentTheme()
- reasoningContent := m.message.ReasoningContent()
- if strings.TrimSpace(reasoningContent.Thinking) == "" {
- return ""
- }
-
- width := m.textWidth() - 2
- width = min(width, 120)
-
- renderer := styles.GetPlainMarkdownRenderer(width - 1)
- rendered, err := renderer.Render(reasoningContent.Thinking)
- if err != nil {
- lines := strings.Split(reasoningContent.Thinking, "\n")
- var content strings.Builder
- lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
- for i, line := range lines {
- if line == "" {
- continue
- }
- content.WriteString(lineStyle.Width(width).Render(line))
- if i < len(lines)-1 {
- content.WriteString("\n")
- }
- }
- rendered = content.String()
- }
-
- fullContent := strings.TrimSpace(rendered)
- height := ordered.Clamp(lipgloss.Height(fullContent), 1, 10)
- m.thinkingViewport.SetHeight(height)
- m.thinkingViewport.SetWidth(m.textWidth())
- m.thinkingViewport.SetContent(fullContent)
- m.thinkingViewport.GotoBottom()
- finishReason := m.message.FinishPart()
- var footer string
- if reasoningContent.StartedAt > 0 {
- duration := m.message.ThinkingDuration()
- if reasoningContent.FinishedAt > 0 {
- m.anim.SetLabel("")
- opts := core.StatusOpts{
- Title: "Thought for",
- Description: duration.String(),
- }
- if duration.String() != "0s" {
- footer = t.S().Base.PaddingLeft(1).Render(core.Status(opts, m.textWidth()-1))
- }
- } else if finishReason != nil && finishReason.Reason == message.FinishReasonCanceled {
- footer = t.S().Base.PaddingLeft(1).Render(m.toMarkdown("*Canceled*"))
- } else {
- footer = m.anim.View()
- }
- }
- lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
- result := lineStyle.Width(m.textWidth()).Padding(0, 1, 0, 0).Render(m.thinkingViewport.View())
- if footer != "" {
- result += "\n\n" + footer
- }
- return result
-}
-
-// shouldSpin determines whether the message should show a loading animation.
-// Only assistant messages without content that aren't finished should spin.
-func (m *messageCmp) shouldSpin() bool {
- if m.message.Role != message.Assistant {
- return false
- }
-
- if m.message.IsFinished() {
- return false
- }
-
- if strings.TrimSpace(m.message.Content().Text) != "" {
- return false
- }
- if len(m.message.ToolCalls()) > 0 {
- return false
- }
- return true
-}
-
-// Blur removes focus from the message component
-func (m *messageCmp) Blur() tea.Cmd {
- m.focused = false
- return nil
-}
-
-// Focus sets focus on the message component
-func (m *messageCmp) Focus() tea.Cmd {
- m.focused = true
- return nil
-}
-
-// IsFocused returns whether the message component is currently focused
-func (m *messageCmp) IsFocused() bool {
- return m.focused
-}
-
-// Size management methods
-
-// GetSize returns the current dimensions of the message component
-func (m *messageCmp) GetSize() (int, int) {
- return m.width, 0
-}
-
-// SetSize updates the width of the message component for text wrapping
-func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
- m.width = ordered.Clamp(width, 1, 120)
- m.thinkingViewport.SetWidth(m.width - 4)
- return nil
-}
-
-// Spinning returns whether the message is currently showing a loading animation
-func (m *messageCmp) Spinning() bool {
- return m.spinning
-}
-
-type AssistantSection interface {
- list.Item
- layout.Sizeable
-}
-type assistantSectionModel struct {
- width int
- id string
- message message.Message
- lastUserMessageTime time.Time
-}
-
-// ID implements AssistantSection.
-func (m *assistantSectionModel) ID() string {
- return m.id
-}
-
-func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
- return &assistantSectionModel{
- width: 0,
- id: uuid.NewString(),
- message: message,
- lastUserMessageTime: lastUserMessageTime,
- }
-}
-
-func (m *assistantSectionModel) Init() tea.Cmd {
- return nil
-}
-
-func (m *assistantSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
- return m, nil
-}
-
-func (m *assistantSectionModel) View() string {
- t := styles.CurrentTheme()
- finishData := m.message.FinishPart()
- finishTime := time.Unix(finishData.Time, 0)
- duration := finishTime.Sub(m.lastUserMessageTime)
- infoMsg := t.S().Subtle.Render(duration.String())
- icon := t.S().Subtle.Render(styles.ModelIcon)
- model := config.Get().GetModel(m.message.Provider, m.message.Model)
- if model == nil {
- // This means the model is not configured anymore
- model = &catwalk.Model{
- Name: "Unknown Model",
- }
- }
- modelFormatted := t.S().Muted.Render(model.Name)
- assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
- return t.S().Base.PaddingLeft(2).Render(
- core.Section(assistant, m.width-2),
- )
-}
-
-func (m *assistantSectionModel) GetSize() (int, int) {
- return m.width, 1
-}
-
-func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
- m.width = width
- return nil
-}
-
-func (m *assistantSectionModel) IsSectionHeader() bool {
- return true
-}
-
-func (m *messageCmp) ID() string {
- return m.message.ID
-}
@@ -1,1403 +0,0 @@
-package messages
-
-import (
- "cmp"
- "encoding/json"
- "fmt"
- "strings"
- "time"
-
- "charm.land/lipgloss/v2"
- "charm.land/lipgloss/v2/tree"
- "github.com/charmbracelet/crush/internal/agent"
- "github.com/charmbracelet/crush/internal/agent/tools"
- "github.com/charmbracelet/crush/internal/ansiext"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/tui/components/chat/todos"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/highlight"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/x/ansi"
-)
-
-// responseContextHeight limits the number of lines displayed in tool output
-const responseContextHeight = 10
-
-// renderer defines the interface for tool-specific rendering implementations
-type renderer interface {
- // Render returns the complete (already styled) toolβcall view, not
- // including the outer border.
- Render(v *toolCallCmp) string
-}
-
-// rendererFactory creates new renderer instances
-type rendererFactory func() renderer
-
-// renderRegistry manages the mapping of tool names to their renderers
-type renderRegistry map[string]rendererFactory
-
-// register adds a new renderer factory to the registry
-func (rr renderRegistry) register(name string, f rendererFactory) { rr[name] = f }
-
-// lookup retrieves a renderer for the given tool name, falling back to generic renderer
-func (rr renderRegistry) lookup(name string) renderer {
- if f, ok := rr[name]; ok {
- return f()
- }
- return genericRenderer{} // sensible fallback
-}
-
-// registry holds all registered tool renderers
-var registry = renderRegistry{}
-
-// baseRenderer provides common functionality for all tool renderers
-type baseRenderer struct{}
-
-func (br baseRenderer) Render(v *toolCallCmp) string {
- if v.result.Data != "" {
- if strings.HasPrefix(v.result.MIMEType, "image/") {
- return br.renderWithParams(v, v.call.Name, nil, func() string {
- return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
- })
- }
- return br.renderWithParams(v, v.call.Name, nil, func() string {
- return renderMediaContent(v, v.result.MIMEType, v.result.Content)
- })
- }
-
- return br.renderWithParams(v, v.call.Name, nil, func() string {
- return renderPlainContent(v, v.result.Content)
- })
-}
-
-// paramBuilder helps construct parameter lists for tool headers
-type paramBuilder struct {
- args []string
-}
-
-// newParamBuilder creates a new parameter builder
-func newParamBuilder() *paramBuilder {
- return ¶mBuilder{args: make([]string, 0)}
-}
-
-// addMain adds the main parameter (first argument)
-func (pb *paramBuilder) addMain(value string) *paramBuilder {
- if value != "" {
- pb.args = append(pb.args, value)
- }
- return pb
-}
-
-// addKeyValue adds a key-value pair parameter
-func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
- if value != "" {
- pb.args = append(pb.args, key, value)
- }
- return pb
-}
-
-// addFlag adds a boolean flag parameter
-func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
- if value {
- pb.args = append(pb.args, key, "true")
- }
- return pb
-}
-
-// build returns the final parameter list
-func (pb *paramBuilder) build() []string {
- return pb.args
-}
-
-// renderWithParams provides a common rendering pattern for tools with parameters
-func (br baseRenderer) renderWithParams(v *toolCallCmp, toolName string, args []string, contentRenderer func() string) string {
- width := v.textWidth()
- if v.isNested {
- width -= 4 // Adjust for nested tool call indentation
- }
- header := br.makeHeader(v, toolName, width, args...)
- if v.isNested {
- return v.style().Render(header)
- }
- if res, done := earlyState(header, v); done {
- return res
- }
- body := contentRenderer()
- return joinHeaderBody(header, body)
-}
-
-// unmarshalParams safely unmarshal JSON parameters
-func (br baseRenderer) unmarshalParams(input string, target any) error {
- return json.Unmarshal([]byte(input), target)
-}
-
-// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
-func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
- t := styles.CurrentTheme()
- icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
- if v.result.ToolCallID != "" {
- if v.result.IsError {
- icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
- } else {
- icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
- }
- } else if v.cancelled {
- icon = t.S().Muted.Render(styles.ToolPending)
- }
- tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool)
- prefix := fmt.Sprintf("%s %s ", icon, tool)
- return prefix + renderParamList(true, width-lipgloss.Width(prefix), params...)
-}
-
-// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
-func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
- if v.isNested {
- return br.makeNestedHeader(v, tool, width, params...)
- }
- t := styles.CurrentTheme()
- icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
- if v.result.ToolCallID != "" {
- if v.result.IsError {
- icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
- } else {
- icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
- }
- } else if v.cancelled {
- icon = t.S().Muted.Render(styles.ToolPending)
- }
- tool = t.S().Base.Foreground(t.Blue).Render(tool)
- prefix := fmt.Sprintf("%s %s ", icon, tool)
- return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
-}
-
-// renderError provides consistent error rendering
-func (br baseRenderer) renderError(v *toolCallCmp, message string) string {
- t := styles.CurrentTheme()
- header := br.makeHeader(v, prettifyToolName(v.call.Name), v.textWidth(), "")
- errorTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
- message = t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(message, v.textWidth()-3-lipgloss.Width(errorTag))) // -2 for padding and space
- return joinHeaderBody(header, errorTag+" "+message)
-}
-
-// Register tool renderers
-func init() {
- registry.register(tools.BashToolName, func() renderer { return bashRenderer{} })
- registry.register(tools.JobOutputToolName, func() renderer { return bashOutputRenderer{} })
- registry.register(tools.JobKillToolName, func() renderer { return bashKillRenderer{} })
- registry.register(tools.DownloadToolName, func() renderer { return downloadRenderer{} })
- registry.register(tools.ViewToolName, func() renderer { return viewRenderer{} })
- registry.register(tools.EditToolName, func() renderer { return editRenderer{} })
- registry.register(tools.MultiEditToolName, func() renderer { return multiEditRenderer{} })
- registry.register(tools.WriteToolName, func() renderer { return writeRenderer{} })
- registry.register(tools.FetchToolName, func() renderer { return simpleFetchRenderer{} })
- registry.register(tools.AgenticFetchToolName, func() renderer { return agenticFetchRenderer{} })
- registry.register(tools.WebFetchToolName, func() renderer { return webFetchRenderer{} })
- registry.register(tools.WebSearchToolName, func() renderer { return webSearchRenderer{} })
- registry.register(tools.GlobToolName, func() renderer { return globRenderer{} })
- registry.register(tools.GrepToolName, func() renderer { return grepRenderer{} })
- registry.register(tools.LSToolName, func() renderer { return lsRenderer{} })
- registry.register(tools.SourcegraphToolName, func() renderer { return sourcegraphRenderer{} })
- registry.register(tools.DiagnosticsToolName, func() renderer { return diagnosticsRenderer{} })
- registry.register(tools.TodosToolName, func() renderer { return todosRenderer{} })
- registry.register(agent.AgentToolName, func() renderer { return agentRenderer{} })
-}
-
-// -----------------------------------------------------------------------------
-// Generic renderer
-// -----------------------------------------------------------------------------
-
-// genericRenderer handles unknown tool types with basic parameter display
-type genericRenderer struct {
- baseRenderer
-}
-
-func (gr genericRenderer) Render(v *toolCallCmp) string {
- if v.result.Data != "" {
- if strings.HasPrefix(v.result.MIMEType, "image/") {
- return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
- return renderImageContent(v, v.result.Data, v.result.MIMEType, v.result.Content)
- })
- }
- return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
- return renderMediaContent(v, v.result.MIMEType, v.result.Content)
- })
- }
-
- return gr.renderWithParams(v, prettifyToolName(v.call.Name), []string{v.call.Input}, func() string {
- return renderPlainContent(v, v.result.Content)
- })
-}
-
-// -----------------------------------------------------------------------------
-// Bash renderer
-// -----------------------------------------------------------------------------
-
-// bashRenderer handles bash command execution display
-type bashRenderer struct {
- baseRenderer
-}
-
-// Render displays the bash command with sanitized newlines and plain output
-func (br bashRenderer) Render(v *toolCallCmp) string {
- var params tools.BashParams
- if err := br.unmarshalParams(v.call.Input, ¶ms); err != nil {
- return br.renderError(v, "Invalid bash parameters")
- }
-
- cmd := strings.ReplaceAll(params.Command, "\n", " ")
- cmd = strings.ReplaceAll(cmd, "\t", " ")
- args := newParamBuilder().
- addMain(cmd).
- addFlag("background", params.RunInBackground).
- build()
- if v.call.Finished {
- var meta tools.BashResponseMetadata
- _ = br.unmarshalParams(v.result.Metadata, &meta)
- if meta.Background {
- description := cmp.Or(meta.Description, params.Command)
- width := v.textWidth()
- if v.isNested {
- width -= 4 // Adjust for nested tool call indentation
- }
- header := makeJobHeader(v, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
- if v.isNested {
- return v.style().Render(header)
- }
- if res, done := earlyState(header, v); done {
- return res
- }
- content := "Command: " + params.Command + "\n" + v.result.Content
- body := renderPlainContent(v, content)
- return joinHeaderBody(header, body)
- }
- }
-
- return br.renderWithParams(v, "Bash", args, func() string {
- var meta tools.BashResponseMetadata
- if err := br.unmarshalParams(v.result.Metadata, &meta); err != nil {
- return renderPlainContent(v, v.result.Content)
- }
- // for backwards compatibility with older tool calls.
- if meta.Output == "" && v.result.Content != tools.BashNoOutput {
- meta.Output = v.result.Content
- }
-
- if meta.Output == "" {
- return ""
- }
- return renderPlainContent(v, meta.Output)
- })
-}
-
-// -----------------------------------------------------------------------------
-// Bash Output renderer
-// -----------------------------------------------------------------------------
-
-func makeJobHeader(v *toolCallCmp, subcommand, pid, description string, width int) string {
- t := styles.CurrentTheme()
- icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
- if v.result.ToolCallID != "" {
- if v.result.IsError {
- icon = t.S().Base.Foreground(t.RedDark).Render(styles.ToolError)
- } else {
- icon = t.S().Base.Foreground(t.Green).Render(styles.ToolSuccess)
- }
- } else if v.cancelled {
- icon = t.S().Muted.Render(styles.ToolPending)
- }
-
- jobPart := t.S().Base.Foreground(t.Blue).Render("Job")
- subcommandPart := t.S().Base.Foreground(t.BlueDark).Render("(" + subcommand + ")")
- pidPart := t.S().Muted.Render(pid)
- descPart := ""
- if description != "" {
- descPart = " " + t.S().Subtle.Render(description)
- }
-
- // Build the complete header
- prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, subcommandPart, pidPart)
- fullHeader := prefix + descPart
-
- // Truncate if needed
- if lipgloss.Width(fullHeader) > width {
- availableWidth := width - lipgloss.Width(prefix) - 1 // -1 for space
- if availableWidth < 10 {
- // Not enough space for description, just show prefix
- return prefix
- }
- descPart = " " + t.S().Subtle.Render(ansi.Truncate(description, availableWidth, "β¦"))
- fullHeader = prefix + descPart
- }
-
- return fullHeader
-}
-
-// bashOutputRenderer handles bash output retrieval display
-type bashOutputRenderer struct {
- baseRenderer
-}
-
-// Render displays the shell ID and output from a background shell
-func (bor bashOutputRenderer) Render(v *toolCallCmp) string {
- var params tools.JobOutputParams
- if err := bor.unmarshalParams(v.call.Input, ¶ms); err != nil {
- return bor.renderError(v, "Invalid job_output parameters")
- }
-
- var meta tools.JobOutputResponseMetadata
- var description string
- if v.result.Metadata != "" {
- if err := bor.unmarshalParams(v.result.Metadata, &meta); err == nil {
- if meta.Description != "" {
- description = meta.Description
- } else {
- description = meta.Command
- }
- }
- }
-
- width := v.textWidth()
- if v.isNested {
- width -= 4 // Adjust for nested tool call indentation
- }
- header := makeJobHeader(v, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
- if v.isNested {
- return v.style().Render(header)
- }
- if res, done := earlyState(header, v); done {
- return res
- }
- body := renderPlainContent(v, v.result.Content)
- return joinHeaderBody(header, body)
-}
-
-// -----------------------------------------------------------------------------
-// Bash Kill renderer
-// -----------------------------------------------------------------------------
-
-// bashKillRenderer handles bash process termination display
-type bashKillRenderer struct {
- baseRenderer
-}
-
-// Render displays the shell ID being terminated
-func (bkr bashKillRenderer) Render(v *toolCallCmp) string {
- var params tools.JobKillParams
- if err := bkr.unmarshalParams(v.call.Input, ¶ms); err != nil {
- return bkr.renderError(v, "Invalid job_kill parameters")
- }
-
- var meta tools.JobKillResponseMetadata
- var description string
- if v.result.Metadata != "" {
- if err := bkr.unmarshalParams(v.result.Metadata, &meta); err == nil {
- if meta.Description != "" {
- description = meta.Description
- } else {
- description = meta.Command
- }
- }
- }
-
- width := v.textWidth()
- if v.isNested {
- width -= 4 // Adjust for nested tool call indentation
- }
- header := makeJobHeader(v, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
- if v.isNested {
- return v.style().Render(header)
- }
- if res, done := earlyState(header, v); done {
- return res
- }
- body := renderPlainContent(v, v.result.Content)
- return joinHeaderBody(header, body)
-}
-
-// -----------------------------------------------------------------------------
-// View renderer
-// -----------------------------------------------------------------------------
-
-// viewRenderer handles file viewing with syntax highlighting and line numbers
-type viewRenderer struct {
- baseRenderer
-}
-
-// Render displays file content with optional limit and offset parameters
-func (vr viewRenderer) Render(v *toolCallCmp) string {
- var params tools.ViewParams
- if err := vr.unmarshalParams(v.call.Input, ¶ms); err != nil {
- return vr.renderError(v, "Invalid view parameters")
- }
-
- file := fsext.PrettyPath(params.FilePath)
- args := newParamBuilder().
- addMain(file).
- addKeyValue("limit", formatNonZero(params.Limit)).
- addKeyValue("offset", formatNonZero(params.Offset)).
- build()
-
- return vr.renderWithParams(v, "View", args, func() string {
- if v.result.Data != "" && strings.HasPrefix(v.result.MIMEType, "image/") {
- return renderImageContent(v, v.result.Data, v.result.MIMEType, "")
- }
-
- var meta tools.ViewResponseMetadata
- if err := vr.unmarshalParams(v.result.Metadata, &meta); err != nil {
- return renderPlainContent(v, v.result.Content)
- }
- return renderCodeContent(v, meta.FilePath, meta.Content, params.Offset)
- })
-}
-
-// formatNonZero returns string representation of non-zero integers, empty string for zero
-func formatNonZero(value int) string {
- if value == 0 {
- return ""
- }
- return fmt.Sprintf("%d", value)
-}
-
-// -----------------------------------------------------------------------------
-// Edit renderer
-// -----------------------------------------------------------------------------
-
-// editRenderer handles file editing with diff visualization
-type editRenderer struct {
- baseRenderer
-}
-
-// Render displays the edited file with a formatted diff of changes
-func (er editRenderer) Render(v *toolCallCmp) string {
- t := styles.CurrentTheme()
- var params tools.EditParams
- var args []string
- if err := er.unmarshalParams(v.call.Input, ¶ms); err == nil {
- file := fsext.PrettyPath(params.FilePath)
- args = newParamBuilder().addMain(file).build()
- }
-
- return er.renderWithParams(v, "Edit", args, func() string {
- var meta tools.EditResponseMetadata
- if err := er.unmarshalParams(v.result.Metadata, &meta); err != nil {
- return renderPlainContent(v, v.result.Content)
- }
-
- formatter := core.DiffFormatter().
- Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
- After(fsext.PrettyPath(params.FilePath), meta.NewContent).
- Width(v.textWidth() - 2) // -2 for padding
- if v.textWidth() > 120 {
- formatter = formatter.Split()
- }
- // add a message to the bottom if the content was truncated
- formatted := formatter.String()
- if lipgloss.Height(formatted) > responseContextHeight {
- contentLines := strings.Split(formatted, "\n")
- truncateMessage := t.S().Muted.
- Background(t.BgBaseLighter).
- PaddingLeft(2).
- Width(v.textWidth() - 2).
- Render(fmt.Sprintf("β¦ (%d lines)", len(contentLines)-responseContextHeight))
- formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
- }
- return formatted
- })
-}
-
-// -----------------------------------------------------------------------------
-// Multi-Edit renderer
-// -----------------------------------------------------------------------------
-
-// multiEditRenderer handles multiple file edits with diff visualization
-type multiEditRenderer struct {
- baseRenderer
-}
-
-// Render displays the multi-edited file with a formatted diff of changes
-func (mer multiEditRenderer) Render(v *toolCallCmp) string {
- t := styles.CurrentTheme()
- var params tools.MultiEditParams
- var args []string
- if err := mer.unmarshalParams(v.call.Input, ¶ms); err == nil {
- file := fsext.PrettyPath(params.FilePath)
- editsCount := len(params.Edits)
- args = newParamBuilder().
- addMain(file).
- addKeyValue("edits", fmt.Sprintf("%d", editsCount)).
- build()
- }
-
- return mer.renderWithParams(v, "Multi-Edit", args, func() string {
- var meta tools.MultiEditResponseMetadata
- if err := mer.unmarshalParams(v.result.Metadata, &meta); err != nil {
- return renderPlainContent(v, v.result.Content)
- }
-
- formatter := core.DiffFormatter().
- Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
- After(fsext.PrettyPath(params.FilePath), meta.NewContent).
- Width(v.textWidth() - 2) // -2 for padding
- if v.textWidth() > 120 {
- formatter = formatter.Split()
- }
- // add a message to the bottom if the content was truncated
- formatted := formatter.String()
- if lipgloss.Height(formatted) > responseContextHeight {
- contentLines := strings.Split(formatted, "\n")
- truncateMessage := t.S().Muted.
- Background(t.BgBaseLighter).
- PaddingLeft(2).
- Width(v.textWidth() - 4).
- Render(fmt.Sprintf("β¦ (%d lines)", len(contentLines)-responseContextHeight))
- formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
- }
-
- // Add failed edits warning if any exist
- if len(meta.EditsFailed) > 0 {
- noteTag := t.S().Base.Padding(0, 2).Background(t.Info).Foreground(t.White).Render("Note")
- noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, len(params.Edits))
- note := t.S().Base.
- Width(v.textWidth() - 2).
- Render(fmt.Sprintf("%s %s", noteTag, t.S().Muted.Render(noteMsg)))
- formatted = lipgloss.JoinVertical(lipgloss.Left, formatted, "", note)
- }
-
- return formatted
- })
-}
-
-// -----------------------------------------------------------------------------
-// Write renderer
-// -----------------------------------------------------------------------------
-
-// writeRenderer handles file writing with syntax-highlighted content preview
-type writeRenderer struct {
- baseRenderer
-}
-
-// Render displays the file being written with syntax highlighting
-func (wr writeRenderer) Render(v *toolCallCmp) string {
- var params tools.WriteParams
- var args []string
- var file string
- if err := wr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- file = fsext.PrettyPath(params.FilePath)
- args = newParamBuilder().addMain(file).build()
- }
-
- return wr.renderWithParams(v, "Write", args, func() string {
- return renderCodeContent(v, file, params.Content, 0)
- })
-}
-
-// -----------------------------------------------------------------------------
-// Fetch renderer
-// -----------------------------------------------------------------------------
-
-// simpleFetchRenderer handles URL fetching with format-specific content display
-type simpleFetchRenderer struct {
- baseRenderer
-}
-
-// Render displays the fetched URL with format and timeout parameters
-func (fr simpleFetchRenderer) Render(v *toolCallCmp) string {
- var params tools.FetchParams
- var args []string
- if err := fr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- args = newParamBuilder().
- addMain(params.URL).
- addKeyValue("format", params.Format).
- addKeyValue("timeout", formatTimeout(params.Timeout)).
- build()
- }
-
- return fr.renderWithParams(v, "Fetch", args, func() string {
- file := fr.getFileExtension(params.Format)
- return renderCodeContent(v, file, v.result.Content, 0)
- })
-}
-
-// getFileExtension returns appropriate file extension for syntax highlighting
-func (fr simpleFetchRenderer) getFileExtension(format string) string {
- switch format {
- case "text":
- return "fetch.txt"
- case "html":
- return "fetch.html"
- default:
- return "fetch.md"
- }
-}
-
-// -----------------------------------------------------------------------------
-// Agentic fetch renderer
-// -----------------------------------------------------------------------------
-
-// agenticFetchRenderer handles URL fetching with prompt parameter and nested tool calls
-type agenticFetchRenderer struct {
- baseRenderer
-}
-
-// Render displays the fetched URL or web search with prompt parameter and nested tool calls
-func (fr agenticFetchRenderer) Render(v *toolCallCmp) string {
- t := styles.CurrentTheme()
- var params tools.AgenticFetchParams
- var args []string
- if err := fr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- if params.URL != "" {
- args = newParamBuilder().
- addMain(params.URL).
- build()
- }
- }
-
- prompt := params.Prompt
- prompt = strings.ReplaceAll(prompt, "\n", " ")
-
- header := fr.makeHeader(v, "Agentic Fetch", v.textWidth(), args...)
- if res, done := earlyState(header, v); v.cancelled && done {
- return res
- }
-
- taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.GreenLight).Foreground(t.Border).Render("Prompt")
- remainingWidth := v.textWidth() - (lipgloss.Width(taskTag) + 1)
- remainingWidth = min(remainingWidth, 120-(lipgloss.Width(taskTag)+1))
- prompt = t.S().Base.Width(remainingWidth).Render(prompt)
- header = lipgloss.JoinVertical(
- lipgloss.Left,
- header,
- "",
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- taskTag,
- " ",
- prompt,
- ),
- )
- childTools := tree.Root(header)
-
- for _, call := range v.nestedToolCalls {
- call.SetSize(remainingWidth, 1)
- childTools.Child(call.View())
- }
- parts := []string{
- childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
- }
-
- if v.result.ToolCallID == "" {
- v.spinning = true
- parts = append(parts, "", v.anim.View())
- } else {
- v.spinning = false
- }
-
- header = lipgloss.JoinVertical(
- lipgloss.Left,
- parts...,
- )
-
- if v.result.ToolCallID == "" {
- return header
- }
- body := renderMarkdownContent(v, v.result.Content)
- return joinHeaderBody(header, body)
-}
-
-// formatTimeout converts timeout seconds to duration string
-func formatTimeout(timeout int) string {
- if timeout == 0 {
- return ""
- }
- return (time.Duration(timeout) * time.Second).String()
-}
-
-// -----------------------------------------------------------------------------
-// Web fetch renderer
-// -----------------------------------------------------------------------------
-
-// webFetchRenderer handles web page fetching with simplified URL display
-type webFetchRenderer struct {
- baseRenderer
-}
-
-// Render displays a compact view of web_fetch with just the URL in a link style
-func (wfr webFetchRenderer) Render(v *toolCallCmp) string {
- var params tools.WebFetchParams
- var args []string
- if err := wfr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- args = newParamBuilder().
- addMain(params.URL).
- build()
- }
-
- return wfr.renderWithParams(v, "Fetch", args, func() string {
- return renderMarkdownContent(v, v.result.Content)
- })
-}
-
-// -----------------------------------------------------------------------------
-// Web search renderer
-// -----------------------------------------------------------------------------
-
-// webSearchRenderer handles web search with query display
-type webSearchRenderer struct {
- baseRenderer
-}
-
-// Render displays a compact view of web_search with just the query
-func (wsr webSearchRenderer) Render(v *toolCallCmp) string {
- var params tools.WebSearchParams
- var args []string
- if err := wsr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- args = newParamBuilder().
- addMain(params.Query).
- build()
- }
-
- return wsr.renderWithParams(v, "Search", args, func() string {
- return renderMarkdownContent(v, v.result.Content)
- })
-}
-
-// -----------------------------------------------------------------------------
-// Download renderer
-// -----------------------------------------------------------------------------
-
-// downloadRenderer handles file downloading with URL and file path display
-type downloadRenderer struct {
- baseRenderer
-}
-
-// Render displays the download URL and destination file path with timeout parameter
-func (dr downloadRenderer) Render(v *toolCallCmp) string {
- var params tools.DownloadParams
- var args []string
- if err := dr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- args = newParamBuilder().
- addMain(params.URL).
- addKeyValue("file_path", fsext.PrettyPath(params.FilePath)).
- addKeyValue("timeout", formatTimeout(params.Timeout)).
- build()
- }
-
- return dr.renderWithParams(v, "Download", args, func() string {
- return renderPlainContent(v, v.result.Content)
- })
-}
-
-// -----------------------------------------------------------------------------
-// Glob renderer
-// -----------------------------------------------------------------------------
-
-// globRenderer handles file pattern matching with path filtering
-type globRenderer struct {
- baseRenderer
-}
-
-// Render displays the glob pattern with optional path parameter
-func (gr globRenderer) Render(v *toolCallCmp) string {
- var params tools.GlobParams
- var args []string
- if err := gr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- args = newParamBuilder().
- addMain(params.Pattern).
- addKeyValue("path", params.Path).
- build()
- }
-
- return gr.renderWithParams(v, "Glob", args, func() string {
- return renderPlainContent(v, v.result.Content)
- })
-}
-
-// -----------------------------------------------------------------------------
-// Grep renderer
-// -----------------------------------------------------------------------------
-
-// grepRenderer handles content searching with pattern matching options
-type grepRenderer struct {
- baseRenderer
-}
-
-// Render displays the search pattern with path, include, and literal text options
-func (gr grepRenderer) Render(v *toolCallCmp) string {
- var params tools.GrepParams
- var args []string
- if err := gr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- args = newParamBuilder().
- addMain(params.Pattern).
- addKeyValue("path", params.Path).
- addKeyValue("include", params.Include).
- addFlag("literal", params.LiteralText).
- build()
- }
-
- return gr.renderWithParams(v, "Grep", args, func() string {
- return renderPlainContent(v, v.result.Content)
- })
-}
-
-// -----------------------------------------------------------------------------
-// LS renderer
-// -----------------------------------------------------------------------------
-
-// lsRenderer handles directory listing with default path handling
-type lsRenderer struct {
- baseRenderer
-}
-
-// Render displays the directory path, defaulting to current directory
-func (lr lsRenderer) Render(v *toolCallCmp) string {
- var params tools.LSParams
- var args []string
- if err := lr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- path := params.Path
- if path == "" {
- path = "."
- }
- path = fsext.PrettyPath(path)
-
- args = newParamBuilder().addMain(path).build()
- }
-
- return lr.renderWithParams(v, "List", args, func() string {
- return renderPlainContent(v, v.result.Content)
- })
-}
-
-// -----------------------------------------------------------------------------
-// Sourcegraph renderer
-// -----------------------------------------------------------------------------
-
-// sourcegraphRenderer handles code search with count and context options
-type sourcegraphRenderer struct {
- baseRenderer
-}
-
-// Render displays the search query with optional count and context window parameters
-func (sr sourcegraphRenderer) Render(v *toolCallCmp) string {
- var params tools.SourcegraphParams
- var args []string
- if err := sr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- args = newParamBuilder().
- addMain(params.Query).
- addKeyValue("count", formatNonZero(params.Count)).
- addKeyValue("context", formatNonZero(params.ContextWindow)).
- build()
- }
-
- return sr.renderWithParams(v, "Sourcegraph", args, func() string {
- return renderPlainContent(v, v.result.Content)
- })
-}
-
-// -----------------------------------------------------------------------------
-// Diagnostics renderer
-// -----------------------------------------------------------------------------
-
-// diagnosticsRenderer handles project-wide diagnostic information
-type diagnosticsRenderer struct {
- baseRenderer
-}
-
-// Render displays project diagnostics with plain content formatting
-func (dr diagnosticsRenderer) Render(v *toolCallCmp) string {
- args := newParamBuilder().addMain("project").build()
-
- return dr.renderWithParams(v, "Diagnostics", args, func() string {
- return renderPlainContent(v, v.result.Content)
- })
-}
-
-// -----------------------------------------------------------------------------
-// Task renderer
-// -----------------------------------------------------------------------------
-
-// agentRenderer handles project-wide diagnostic information
-type agentRenderer struct {
- baseRenderer
-}
-
-func RoundedEnumeratorWithWidth(lPadding, width int) tree.Enumerator {
- if width == 0 {
- width = 2
- }
- if lPadding == 0 {
- lPadding = 1
- }
- return func(children tree.Children, index int) string {
- line := strings.Repeat("β", width)
- padding := strings.Repeat(" ", lPadding)
- if children.Length()-1 == index {
- return padding + "β°" + line
- }
- return padding + "β" + line
- }
-}
-
-// Render displays agent task parameters and result content
-func (tr agentRenderer) Render(v *toolCallCmp) string {
- t := styles.CurrentTheme()
- var params agent.AgentParams
- tr.unmarshalParams(v.call.Input, ¶ms)
-
- prompt := params.Prompt
- prompt = strings.ReplaceAll(prompt, "\n", " ")
-
- header := tr.makeHeader(v, "Agent", v.textWidth())
- if res, done := earlyState(header, v); v.cancelled && done {
- return res
- }
- taskTag := t.S().Base.Bold(true).Padding(0, 1).MarginLeft(2).Background(t.BlueLight).Foreground(t.White).Render("Task")
- remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
- remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
- prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
- header = lipgloss.JoinVertical(
- lipgloss.Left,
- header,
- "",
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- taskTag,
- " ",
- prompt,
- ),
- )
- childTools := tree.Root(header)
-
- for _, call := range v.nestedToolCalls {
- call.SetSize(remainingWidth, 1)
- childTools.Child(call.View())
- }
- parts := []string{
- childTools.Enumerator(RoundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
- }
-
- if v.result.ToolCallID == "" {
- v.spinning = true
- parts = append(parts, "", v.anim.View())
- } else {
- v.spinning = false
- }
-
- header = lipgloss.JoinVertical(
- lipgloss.Left,
- parts...,
- )
-
- if v.result.ToolCallID == "" {
- return header
- }
-
- body := renderMarkdownContent(v, v.result.Content)
- return joinHeaderBody(header, body)
-}
-
-// renderParamList renders params, params[0] (params[1]=params[2] ....)
-func renderParamList(nested bool, paramsWidth int, params ...string) string {
- t := styles.CurrentTheme()
- if len(params) == 0 {
- return ""
- }
- mainParam := params[0]
- if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
- mainParam = ansi.Truncate(mainParam, paramsWidth, "β¦")
- }
-
- if len(params) == 1 {
- return t.S().Subtle.Render(mainParam)
- }
- otherParams := params[1:]
- // create pairs of key/value
- // if odd number of params, the last one is a key without value
- if len(otherParams)%2 != 0 {
- otherParams = append(otherParams, "")
- }
- parts := make([]string, 0, len(otherParams)/2)
- for i := 0; i < len(otherParams); i += 2 {
- key := otherParams[i]
- value := otherParams[i+1]
- if value == "" {
- continue
- }
- parts = append(parts, fmt.Sprintf("%s=%s", key, value))
- }
-
- partsRendered := strings.Join(parts, ", ")
- remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
- if remainingWidth < 30 {
- // No space for the params, just show the main
- return t.S().Subtle.Render(mainParam)
- }
-
- if len(parts) > 0 {
- mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
- }
-
- return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "β¦"))
-}
-
-// earlyState returns immediatelyβrendered error/cancelled/ongoing states.
-func earlyState(header string, v *toolCallCmp) (string, bool) {
- t := styles.CurrentTheme()
- message := ""
- switch {
- case v.result.IsError:
- message = v.renderToolError()
- case v.cancelled:
- message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.")
- case v.result.ToolCallID == "":
- if v.permissionRequested && !v.permissionGranted {
- message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting permission...")
- } else {
- message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...")
- }
- default:
- return "", false
- }
-
- message = t.S().Base.PaddingLeft(2).Render(message)
- return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
-}
-
-func joinHeaderBody(header, body string) string {
- t := styles.CurrentTheme()
- if body == "" {
- return header
- }
- body = t.S().Base.PaddingLeft(2).Render(body)
- return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
-}
-
-func renderPlainContent(v *toolCallCmp, content string) string {
- t := styles.CurrentTheme()
- content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
- content = strings.ReplaceAll(content, "\t", " ") // Replace tabs with spaces
- content = strings.TrimSpace(content)
- lines := strings.Split(content, "\n")
-
- width := v.textWidth() - 2
- var out []string
- for i, ln := range lines {
- if i >= responseContextHeight {
- break
- }
- ln = ansiext.Escape(ln)
- ln = " " + ln
- if lipgloss.Width(ln) > width {
- ln = v.fit(ln, width)
- }
- out = append(out, t.S().Muted.
- Width(width).
- Background(t.BgBaseLighter).
- Render(ln))
- }
-
- if len(lines) > responseContextHeight {
- out = append(out, t.S().Muted.
- Background(t.BgBaseLighter).
- Width(width).
- Render(fmt.Sprintf("β¦ (%d lines)", len(lines)-responseContextHeight)))
- }
-
- return strings.Join(out, "\n")
-}
-
-func renderMarkdownContent(v *toolCallCmp, content string) string {
- t := styles.CurrentTheme()
- content = strings.ReplaceAll(content, "\r\n", "\n")
- content = strings.ReplaceAll(content, "\t", " ")
- content = strings.TrimSpace(content)
-
- width := v.textWidth() - 2
- width = min(width, 120)
-
- renderer := styles.GetPlainMarkdownRenderer(width)
- rendered, err := renderer.Render(content)
- if err != nil {
- return renderPlainContent(v, content)
- }
-
- lines := strings.Split(rendered, "\n")
-
- var out []string
- for i, ln := range lines {
- if i >= responseContextHeight {
- break
- }
- out = append(out, ln)
- }
-
- style := t.S().Muted.Background(t.BgBaseLighter)
- if len(lines) > responseContextHeight {
- out = append(out, style.
- Width(width-2).
- Render(fmt.Sprintf("β¦ (%d lines)", len(lines)-responseContextHeight)))
- }
-
- return style.Render(strings.Join(out, "\n"))
-}
-
-func getDigits(n int) int {
- if n == 0 {
- return 1
- }
- if n < 0 {
- n = -n
- }
-
- digits := 0
- for n > 0 {
- n /= 10
- digits++
- }
-
- return digits
-}
-
-func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
- t := styles.CurrentTheme()
- content = strings.ReplaceAll(content, "\r\n", "\n") // Normalize line endings
- content = strings.ReplaceAll(content, "\t", " ") // Replace tabs with spaces
- truncated := truncateHeight(content, responseContextHeight)
-
- lines := strings.Split(truncated, "\n")
- for i, ln := range lines {
- lines[i] = ansiext.Escape(ln)
- }
-
- bg := t.BgBase
- highlighted, _ := highlight.SyntaxHighlight(strings.Join(lines, "\n"), path, bg)
- lines = strings.Split(highlighted, "\n")
-
- if len(strings.Split(content, "\n")) > responseContextHeight {
- lines = append(lines, t.S().Muted.
- Background(bg).
- Render(fmt.Sprintf(" β¦(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
- }
-
- maxLineNumber := len(lines) + offset
- maxDigits := getDigits(maxLineNumber)
- numFmt := fmt.Sprintf("%%%dd", maxDigits)
- const numPR, numPL, codePR, codePL = 1, 1, 1, 2
- w := v.textWidth() - maxDigits - numPL - numPR - 2 // -2 for left padding
- for i, ln := range lines {
- num := t.S().Base.
- Foreground(t.FgMuted).
- Background(t.BgBase).
- PaddingRight(1).
- PaddingLeft(1).
- Render(fmt.Sprintf(numFmt, i+1+offset))
- lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
- num,
- t.S().Base.
- Width(w).
- Background(bg).
- PaddingRight(1).
- PaddingLeft(2).
- Render(v.fit(ln, w-codePL-codePR)),
- )
- }
-
- return lipgloss.JoinVertical(lipgloss.Left, lines...)
-}
-
-// renderImageContent renders image data with optional text content (for MCP tools).
-func renderImageContent(v *toolCallCmp, data, mediaType, textContent string) string {
- t := styles.CurrentTheme()
-
- dataSize := len(data) * 3 / 4
- sizeStr := formatSize(dataSize)
-
- loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
- arrow := t.S().Base.Foreground(t.GreenDark).Render("β")
- typeStyled := t.S().Base.Render(mediaType)
- sizeStyled := t.S().Subtle.Render(sizeStr)
-
- imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)
- if strings.TrimSpace(textContent) != "" {
- textDisplay := renderPlainContent(v, textContent)
- return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay)
- }
-
- return imageDisplay
-}
-
-// renderMediaContent renders non-image media content.
-func renderMediaContent(v *toolCallCmp, mediaType, textContent string) string {
- t := styles.CurrentTheme()
-
- loaded := t.S().Base.Foreground(t.Green).Render("Loaded")
- arrow := t.S().Base.Foreground(t.GreenDark).Render("β")
- typeStyled := t.S().Base.Render(mediaType)
- mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled)
-
- if strings.TrimSpace(textContent) != "" {
- textDisplay := renderPlainContent(v, textContent)
- return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay)
- }
-
- return mediaDisplay
-}
-
-// formatSize formats byte count as human-readable size.
-func formatSize(bytes int) string {
- if bytes < 1024 {
- return fmt.Sprintf("%d B", bytes)
- }
- if bytes < 1024*1024 {
- return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
- }
- return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
-}
-
-func (v *toolCallCmp) renderToolError() string {
- t := styles.CurrentTheme()
- err := strings.ReplaceAll(v.result.Content, "\n", " ")
- errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
- err = fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(v.fit(err, v.textWidth()-2-lipgloss.Width(errTag))))
- return err
-}
-
-func truncateHeight(s string, h int) string {
- lines := strings.Split(s, "\n")
- if len(lines) > h {
- return strings.Join(lines[:h], "\n")
- }
- return s
-}
-
-func prettifyToolName(name string) string {
- switch name {
- case agent.AgentToolName:
- return "Agent"
- case tools.BashToolName:
- return "Bash"
- case tools.JobOutputToolName:
- return "Job: Output"
- case tools.JobKillToolName:
- return "Job: Kill"
- case tools.DownloadToolName:
- return "Download"
- case tools.EditToolName:
- return "Edit"
- case tools.MultiEditToolName:
- return "Multi-Edit"
- case tools.FetchToolName:
- return "Fetch"
- case tools.AgenticFetchToolName:
- return "Agentic Fetch"
- case tools.WebFetchToolName:
- return "Fetch"
- case tools.WebSearchToolName:
- return "Search"
- case tools.GlobToolName:
- return "Glob"
- case tools.GrepToolName:
- return "Grep"
- case tools.LSToolName:
- return "List"
- case tools.SourcegraphToolName:
- return "Sourcegraph"
- case tools.TodosToolName:
- return "To-Do"
- case tools.ViewToolName:
- return "View"
- case tools.WriteToolName:
- return "Write"
- default:
- return name
- }
-}
-
-// -----------------------------------------------------------------------------
-// Todos renderer
-// -----------------------------------------------------------------------------
-
-type todosRenderer struct {
- baseRenderer
-}
-
-func (tr todosRenderer) Render(v *toolCallCmp) string {
- t := styles.CurrentTheme()
- var params tools.TodosParams
- var meta tools.TodosResponseMetadata
- var headerText string
- var body string
-
- // Parse params for pending state (before result is available).
- if err := tr.unmarshalParams(v.call.Input, ¶ms); err == nil {
- completedCount := 0
- inProgressTask := ""
- for _, todo := range params.Todos {
- if todo.Status == "completed" {
- completedCount++
- }
- if todo.Status == "in_progress" {
- if todo.ActiveForm != "" {
- inProgressTask = todo.ActiveForm
- } else {
- inProgressTask = todo.Content
- }
- }
- }
-
- // Default display from params (used when pending or no metadata).
- ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
- headerText = ratio
- if inProgressTask != "" {
- headerText = fmt.Sprintf("%s Β· %s", ratio, inProgressTask)
- }
-
- // If we have metadata, use it for richer display.
- if v.result.Metadata != "" {
- if err := tr.unmarshalParams(v.result.Metadata, &meta); err == nil {
- if meta.IsNew {
- if meta.JustStarted != "" {
- headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
- } else {
- headerText = fmt.Sprintf("created %d todos", meta.Total)
- }
- body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
- } else {
- // Build header based on what changed.
- hasCompleted := len(meta.JustCompleted) > 0
- hasStarted := meta.JustStarted != ""
- allCompleted := meta.Completed == meta.Total
-
- ratio := t.S().Base.Foreground(t.BlueDark).Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
- if hasCompleted && hasStarted {
- text := t.S().Subtle.Render(fmt.Sprintf(" Β· completed %d, starting next", len(meta.JustCompleted)))
- headerText = fmt.Sprintf("%s%s", ratio, text)
- } else if hasCompleted {
- text := t.S().Subtle.Render(fmt.Sprintf(" Β· completed %d", len(meta.JustCompleted)))
- if allCompleted {
- text = t.S().Subtle.Render(" Β· completed all")
- }
- headerText = fmt.Sprintf("%s%s", ratio, text)
- } else if hasStarted {
- headerText = fmt.Sprintf("%s%s", ratio, t.S().Subtle.Render(" Β· starting task"))
- } else {
- headerText = ratio
- }
-
- // Build body with details.
- if allCompleted {
- // Show all todos when all are completed, like when created
- body = todos.FormatTodosList(meta.Todos, styles.ArrowRightIcon, t, v.textWidth())
- } else if meta.JustStarted != "" {
- body = t.S().Base.Foreground(t.GreenDark).Render(styles.ArrowRightIcon+" ") +
- t.S().Base.Foreground(t.FgBase).Render(meta.JustStarted)
- }
- }
- }
- }
- }
-
- args := newParamBuilder().addMain(headerText).build()
-
- return tr.renderWithParams(v, "To-Do", args, func() string {
- return body
- })
-}
@@ -1,877 +0,0 @@
-package messages
-
-import (
- "encoding/json"
- "fmt"
- "path/filepath"
- "strings"
- "time"
-
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/atotto/clipboard"
- "github.com/charmbracelet/crush/internal/agent"
- "github.com/charmbracelet/crush/internal/agent/tools"
- "github.com/charmbracelet/crush/internal/diff"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/permission"
- "github.com/charmbracelet/crush/internal/tui/components/anim"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/x/ansi"
-)
-
-// ToolCallCmp defines the interface for tool call components in the chat interface.
-// It manages the display of tool execution including pending states, results, and errors.
-type ToolCallCmp interface {
- util.Model // Basic Bubble util.Model interface
- layout.Sizeable // Width/height management
- layout.Focusable // Focus state management
- GetToolCall() message.ToolCall // Access to tool call data
- GetToolResult() message.ToolResult // Access to tool result data
- SetToolResult(message.ToolResult) // Update tool result
- SetToolCall(message.ToolCall) // Update tool call
- SetCancelled() // Mark as cancelled
- ParentMessageID() string // Get parent message ID
- Spinning() bool // Animation state for pending tools
- GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
- SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
- SetIsNested(bool) // Set whether this tool call is nested
- ID() string
- SetPermissionRequested() // Mark permission request
- SetPermissionGranted() // Mark permission granted
-}
-
-// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
-// It handles rendering of tool execution states including pending, completed, and error states.
-type toolCallCmp struct {
- width int // Component width for text wrapping
- focused bool // Focus state for border styling
- isNested bool // Whether this tool call is nested within another
-
- // Tool call data and state
- parentMessageID string // ID of the message that initiated this tool call
- call message.ToolCall // The tool call being executed
- result message.ToolResult // The result of the tool execution
- cancelled bool // Whether the tool call was cancelled
- permissionRequested bool
- permissionGranted bool
-
- // Animation state for pending tool calls
- spinning bool // Whether to show loading animation
- anim util.Model // Animation component for pending states
-
- nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
-}
-
-// ToolCallOption provides functional options for configuring tool call components
-type ToolCallOption func(*toolCallCmp)
-
-// WithToolCallCancelled marks the tool call as cancelled
-func WithToolCallCancelled() ToolCallOption {
- return func(m *toolCallCmp) {
- m.cancelled = true
- }
-}
-
-// WithToolCallResult sets the initial tool result
-func WithToolCallResult(result message.ToolResult) ToolCallOption {
- return func(m *toolCallCmp) {
- m.result = result
- }
-}
-
-func WithToolCallNested(isNested bool) ToolCallOption {
- return func(m *toolCallCmp) {
- m.isNested = isNested
- }
-}
-
-func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
- return func(m *toolCallCmp) {
- m.nestedToolCalls = calls
- }
-}
-
-func WithToolPermissionRequested() ToolCallOption {
- return func(m *toolCallCmp) {
- m.permissionRequested = true
- }
-}
-
-func WithToolPermissionGranted() ToolCallOption {
- return func(m *toolCallCmp) {
- m.permissionGranted = true
- }
-}
-
-// NewToolCallCmp creates a new tool call component with the given parent message ID,
-// tool call, and optional configuration
-func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions permission.Service, opts ...ToolCallOption) ToolCallCmp {
- m := &toolCallCmp{
- call: tc,
- parentMessageID: parentMessageID,
- }
- for _, opt := range opts {
- opt(m)
- }
- t := styles.CurrentTheme()
- m.anim = anim.New(anim.Settings{
- Size: 15,
- Label: "Working",
- GradColorA: t.Primary,
- GradColorB: t.Secondary,
- LabelColor: t.FgBase,
- CycleColors: true,
- })
- if m.isNested {
- m.anim = anim.New(anim.Settings{
- Size: 10,
- GradColorA: t.Primary,
- GradColorB: t.Secondary,
- CycleColors: true,
- })
- }
- return m
-}
-
-// Init initializes the tool call component and starts animations if needed.
-// Returns a command to start the animation for pending tool calls.
-func (m *toolCallCmp) Init() tea.Cmd {
- m.spinning = m.shouldSpin()
- return m.anim.Init()
-}
-
-// Update handles incoming messages and updates the component state.
-// Manages animation updates for pending tool calls.
-func (m *toolCallCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case anim.StepMsg:
- var cmds []tea.Cmd
- for i, nested := range m.nestedToolCalls {
- if nested.Spinning() {
- u, cmd := nested.Update(msg)
- m.nestedToolCalls[i] = u.(ToolCallCmp)
- cmds = append(cmds, cmd)
- }
- }
- if m.spinning {
- u, cmd := m.anim.Update(msg)
- m.anim = u
- cmds = append(cmds, cmd)
- }
- return m, tea.Batch(cmds...)
- case tea.KeyPressMsg:
- if key.Matches(msg, CopyKey) {
- return m, m.copyTool()
- }
- }
- return m, nil
-}
-
-// View renders the tool call component based on its current state.
-// Shows either a pending animation or the tool-specific rendered result.
-func (m *toolCallCmp) View() string {
- box := m.style()
-
- if !m.call.Finished && !m.cancelled {
- return box.Render(m.renderPending())
- }
-
- r := registry.lookup(m.call.Name)
-
- if m.isNested {
- return box.Render(r.Render(m))
- }
- return box.Render(r.Render(m))
-}
-
-// State management methods
-
-// SetCancelled marks the tool call as cancelled
-func (m *toolCallCmp) SetCancelled() {
- m.cancelled = true
-}
-
-func (m *toolCallCmp) copyTool() tea.Cmd {
- content := m.formatToolForCopy()
- return tea.Sequence(
- tea.SetClipboard(content),
- func() tea.Msg {
- _ = clipboard.WriteAll(content)
- return nil
- },
- util.ReportInfo("Tool content copied to clipboard"),
- )
-}
-
-func (m *toolCallCmp) formatToolForCopy() string {
- var parts []string
-
- toolName := prettifyToolName(m.call.Name)
- parts = append(parts, fmt.Sprintf("## %s Tool Call", toolName))
-
- if m.call.Input != "" {
- params := m.formatParametersForCopy()
- if params != "" {
- parts = append(parts, "### Parameters:")
- parts = append(parts, params)
- }
- }
-
- if m.result.ToolCallID != "" {
- if m.result.IsError {
- parts = append(parts, "### Error:")
- parts = append(parts, m.result.Content)
- } else {
- parts = append(parts, "### Result:")
- content := m.formatResultForCopy()
- if content != "" {
- parts = append(parts, content)
- }
- }
- } else if m.cancelled {
- parts = append(parts, "### Status:")
- parts = append(parts, "Cancelled")
- } else {
- parts = append(parts, "### Status:")
- parts = append(parts, "Pending...")
- }
-
- return strings.Join(parts, "\n\n")
-}
-
-func (m *toolCallCmp) formatParametersForCopy() string {
- switch m.call.Name {
- case tools.BashToolName:
- var params tools.BashParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- cmd := strings.ReplaceAll(params.Command, "\n", " ")
- cmd = strings.ReplaceAll(cmd, "\t", " ")
- return fmt.Sprintf("**Command:** %s", cmd)
- }
- case tools.ViewToolName:
- var params tools.ViewParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
- if params.Limit > 0 {
- parts = append(parts, fmt.Sprintf("**Limit:** %d", params.Limit))
- }
- if params.Offset > 0 {
- parts = append(parts, fmt.Sprintf("**Offset:** %d", params.Offset))
- }
- return strings.Join(parts, "\n")
- }
- case tools.EditToolName:
- var params tools.EditParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
- }
- case tools.MultiEditToolName:
- var params tools.MultiEditParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath)))
- parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
- return strings.Join(parts, "\n")
- }
- case tools.WriteToolName:
- var params tools.WriteParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
- }
- case tools.FetchToolName:
- var params tools.FetchParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
- if params.Format != "" {
- parts = append(parts, fmt.Sprintf("**Format:** %s", params.Format))
- }
- if params.Timeout > 0 {
- parts = append(parts, fmt.Sprintf("**Timeout:** %ds", params.Timeout))
- }
- return strings.Join(parts, "\n")
- }
- case tools.AgenticFetchToolName:
- var params tools.AgenticFetchParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- var parts []string
- if params.URL != "" {
- parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
- }
- if params.Prompt != "" {
- parts = append(parts, fmt.Sprintf("**Prompt:** %s", params.Prompt))
- }
- return strings.Join(parts, "\n")
- }
- case tools.WebFetchToolName:
- var params tools.WebFetchParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- return fmt.Sprintf("**URL:** %s", params.URL)
- }
- case tools.GrepToolName:
- var params tools.GrepParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
- if params.Path != "" {
- parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
- }
- if params.Include != "" {
- parts = append(parts, fmt.Sprintf("**Include:** %s", params.Include))
- }
- if params.LiteralText {
- parts = append(parts, "**Literal:** true")
- }
- return strings.Join(parts, "\n")
- }
- case tools.GlobToolName:
- var params tools.GlobParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**Pattern:** %s", params.Pattern))
- if params.Path != "" {
- parts = append(parts, fmt.Sprintf("**Path:** %s", params.Path))
- }
- return strings.Join(parts, "\n")
- }
- case tools.LSToolName:
- var params tools.LSParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- path := params.Path
- if path == "" {
- path = "."
- }
- return fmt.Sprintf("**Path:** %s", fsext.PrettyPath(path))
- }
- case tools.DownloadToolName:
- var params tools.DownloadParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**URL:** %s", params.URL))
- parts = append(parts, fmt.Sprintf("**File Path:** %s", fsext.PrettyPath(params.FilePath)))
- if params.Timeout > 0 {
- parts = append(parts, fmt.Sprintf("**Timeout:** %s", (time.Duration(params.Timeout)*time.Second).String()))
- }
- return strings.Join(parts, "\n")
- }
- case tools.SourcegraphToolName:
- var params tools.SourcegraphParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- var parts []string
- parts = append(parts, fmt.Sprintf("**Query:** %s", params.Query))
- if params.Count > 0 {
- parts = append(parts, fmt.Sprintf("**Count:** %d", params.Count))
- }
- if params.ContextWindow > 0 {
- parts = append(parts, fmt.Sprintf("**Context:** %d", params.ContextWindow))
- }
- return strings.Join(parts, "\n")
- }
- case tools.DiagnosticsToolName:
- return "**Project:** diagnostics"
- case agent.AgentToolName:
- var params agent.AgentParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- return fmt.Sprintf("**Task:**\n%s", params.Prompt)
- }
- }
-
- var params map[string]any
- if json.Unmarshal([]byte(m.call.Input), ¶ms) == nil {
- var parts []string
- for key, value := range params {
- displayKey := strings.ReplaceAll(key, "_", " ")
- if len(displayKey) > 0 {
- displayKey = strings.ToUpper(displayKey[:1]) + displayKey[1:]
- }
- parts = append(parts, fmt.Sprintf("**%s:** %v", displayKey, value))
- }
- return strings.Join(parts, "\n")
- }
-
- return ""
-}
-
-func (m *toolCallCmp) formatResultForCopy() string {
- if m.result.Data != "" {
- if strings.HasPrefix(m.result.MIMEType, "image/") {
- return fmt.Sprintf("[Image: %s]", m.result.MIMEType)
- }
- return fmt.Sprintf("[Media: %s]", m.result.MIMEType)
- }
-
- switch m.call.Name {
- case tools.BashToolName:
- return m.formatBashResultForCopy()
- case tools.ViewToolName:
- return m.formatViewResultForCopy()
- case tools.EditToolName:
- return m.formatEditResultForCopy()
- case tools.MultiEditToolName:
- return m.formatMultiEditResultForCopy()
- case tools.WriteToolName:
- return m.formatWriteResultForCopy()
- case tools.FetchToolName:
- return m.formatFetchResultForCopy()
- case tools.AgenticFetchToolName:
- return m.formatAgenticFetchResultForCopy()
- case tools.WebFetchToolName:
- return m.formatWebFetchResultForCopy()
- case agent.AgentToolName:
- return m.formatAgentResultForCopy()
- case tools.DownloadToolName, tools.GrepToolName, tools.GlobToolName, tools.LSToolName, tools.SourcegraphToolName, tools.DiagnosticsToolName, tools.TodosToolName:
- return fmt.Sprintf("```\n%s\n```", m.result.Content)
- default:
- return m.result.Content
- }
-}
-
-func (m *toolCallCmp) formatBashResultForCopy() string {
- var meta tools.BashResponseMetadata
- if m.result.Metadata != "" {
- json.Unmarshal([]byte(m.result.Metadata), &meta)
- }
-
- output := meta.Output
- if output == "" && m.result.Content != tools.BashNoOutput {
- output = m.result.Content
- }
-
- if output == "" {
- return ""
- }
-
- return fmt.Sprintf("```bash\n%s\n```", output)
-}
-
-func (m *toolCallCmp) formatViewResultForCopy() string {
- var meta tools.ViewResponseMetadata
- if m.result.Metadata != "" {
- json.Unmarshal([]byte(m.result.Metadata), &meta)
- }
-
- if meta.Content == "" {
- return m.result.Content
- }
-
- lang := ""
- if meta.FilePath != "" {
- ext := strings.ToLower(filepath.Ext(meta.FilePath))
- switch ext {
- case ".go":
- lang = "go"
- case ".js", ".mjs":
- lang = "javascript"
- case ".ts":
- lang = "typescript"
- case ".py":
- lang = "python"
- case ".rs":
- lang = "rust"
- case ".java":
- lang = "java"
- case ".c":
- lang = "c"
- case ".cpp", ".cc", ".cxx":
- lang = "cpp"
- case ".sh", ".bash":
- lang = "bash"
- case ".json":
- lang = "json"
- case ".yaml", ".yml":
- lang = "yaml"
- case ".xml":
- lang = "xml"
- case ".html":
- lang = "html"
- case ".css":
- lang = "css"
- case ".md":
- lang = "markdown"
- }
- }
-
- var result strings.Builder
- if lang != "" {
- result.WriteString(fmt.Sprintf("```%s\n", lang))
- } else {
- result.WriteString("```\n")
- }
- result.WriteString(meta.Content)
- result.WriteString("\n```")
-
- return result.String()
-}
-
-func (m *toolCallCmp) formatEditResultForCopy() string {
- var meta tools.EditResponseMetadata
- if m.result.Metadata == "" {
- return m.result.Content
- }
-
- if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
- return m.result.Content
- }
-
- var params tools.EditParams
- json.Unmarshal([]byte(m.call.Input), ¶ms)
-
- var result strings.Builder
-
- if meta.OldContent != "" || meta.NewContent != "" {
- fileName := params.FilePath
- if fileName != "" {
- fileName = fsext.PrettyPath(fileName)
- }
- diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
-
- result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
- result.WriteString("```diff\n")
- result.WriteString(diffContent)
- result.WriteString("\n```")
- }
-
- return result.String()
-}
-
-func (m *toolCallCmp) formatMultiEditResultForCopy() string {
- var meta tools.MultiEditResponseMetadata
- if m.result.Metadata == "" {
- return m.result.Content
- }
-
- if json.Unmarshal([]byte(m.result.Metadata), &meta) != nil {
- return m.result.Content
- }
-
- var params tools.MultiEditParams
- json.Unmarshal([]byte(m.call.Input), ¶ms)
-
- var result strings.Builder
- if meta.OldContent != "" || meta.NewContent != "" {
- fileName := params.FilePath
- if fileName != "" {
- fileName = fsext.PrettyPath(fileName)
- }
- diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName)
-
- result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals))
- result.WriteString("```diff\n")
- result.WriteString(diffContent)
- result.WriteString("\n```")
- }
-
- return result.String()
-}
-
-func (m *toolCallCmp) formatWriteResultForCopy() string {
- var params tools.WriteParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil {
- return m.result.Content
- }
-
- lang := ""
- if params.FilePath != "" {
- ext := strings.ToLower(filepath.Ext(params.FilePath))
- switch ext {
- case ".go":
- lang = "go"
- case ".js", ".mjs":
- lang = "javascript"
- case ".ts":
- lang = "typescript"
- case ".py":
- lang = "python"
- case ".rs":
- lang = "rust"
- case ".java":
- lang = "java"
- case ".c":
- lang = "c"
- case ".cpp", ".cc", ".cxx":
- lang = "cpp"
- case ".sh", ".bash":
- lang = "bash"
- case ".json":
- lang = "json"
- case ".yaml", ".yml":
- lang = "yaml"
- case ".xml":
- lang = "xml"
- case ".html":
- lang = "html"
- case ".css":
- lang = "css"
- case ".md":
- lang = "markdown"
- }
- }
-
- var result strings.Builder
- result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath)))
- if lang != "" {
- result.WriteString(fmt.Sprintf("```%s\n", lang))
- } else {
- result.WriteString("```\n")
- }
- result.WriteString(params.Content)
- result.WriteString("\n```")
-
- return result.String()
-}
-
-func (m *toolCallCmp) formatFetchResultForCopy() string {
- var params tools.FetchParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil {
- return m.result.Content
- }
-
- var result strings.Builder
- if params.URL != "" {
- result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
- }
- if params.Format != "" {
- result.WriteString(fmt.Sprintf("Format: %s\n", params.Format))
- }
- if params.Timeout > 0 {
- result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout))
- }
- result.WriteString("\n")
-
- result.WriteString(m.result.Content)
-
- return result.String()
-}
-
-func (m *toolCallCmp) formatAgenticFetchResultForCopy() string {
- var params tools.AgenticFetchParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil {
- return m.result.Content
- }
-
- var result strings.Builder
- if params.URL != "" {
- result.WriteString(fmt.Sprintf("URL: %s\n", params.URL))
- }
- if params.Prompt != "" {
- result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt))
- }
-
- result.WriteString("```markdown\n")
- result.WriteString(m.result.Content)
- result.WriteString("\n```")
-
- return result.String()
-}
-
-func (m *toolCallCmp) formatWebFetchResultForCopy() string {
- var params tools.WebFetchParams
- if json.Unmarshal([]byte(m.call.Input), ¶ms) != nil {
- return m.result.Content
- }
-
- var result strings.Builder
- result.WriteString(fmt.Sprintf("URL: %s\n\n", params.URL))
- result.WriteString("```markdown\n")
- result.WriteString(m.result.Content)
- result.WriteString("\n```")
-
- return result.String()
-}
-
-func (m *toolCallCmp) formatAgentResultForCopy() string {
- var result strings.Builder
-
- if len(m.nestedToolCalls) > 0 {
- result.WriteString("### Nested Tool Calls:\n")
- for i, nestedCall := range m.nestedToolCalls {
- nestedContent := nestedCall.(*toolCallCmp).formatToolForCopy()
- indentedContent := strings.ReplaceAll(nestedContent, "\n", "\n ")
- result.WriteString(fmt.Sprintf("%d. %s\n", i+1, indentedContent))
- if i < len(m.nestedToolCalls)-1 {
- result.WriteString("\n")
- }
- }
-
- if m.result.Content != "" {
- result.WriteString("\n### Final Result:\n")
- }
- }
-
- if m.result.Content != "" {
- result.WriteString(fmt.Sprintf("```markdown\n%s\n```", m.result.Content))
- }
-
- return result.String()
-}
-
-// SetToolCall updates the tool call data and stops spinning if finished
-func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
- m.call = call
- if m.call.Finished {
- m.spinning = false
- }
-}
-
-// ParentMessageID returns the ID of the message that initiated this tool call
-func (m *toolCallCmp) ParentMessageID() string {
- return m.parentMessageID
-}
-
-// SetToolResult updates the tool result and stops the spinning animation
-func (m *toolCallCmp) SetToolResult(result message.ToolResult) {
- m.result = result
- m.spinning = false
-}
-
-// GetToolCall returns the current tool call data
-func (m *toolCallCmp) GetToolCall() message.ToolCall {
- return m.call
-}
-
-// GetToolResult returns the current tool result data
-func (m *toolCallCmp) GetToolResult() message.ToolResult {
- return m.result
-}
-
-// GetNestedToolCalls returns the nested tool calls
-func (m *toolCallCmp) GetNestedToolCalls() []ToolCallCmp {
- return m.nestedToolCalls
-}
-
-// SetNestedToolCalls sets the nested tool calls
-func (m *toolCallCmp) SetNestedToolCalls(calls []ToolCallCmp) {
- m.nestedToolCalls = calls
- for _, nested := range m.nestedToolCalls {
- nested.SetSize(m.width, 0)
- }
-}
-
-// SetIsNested sets whether this tool call is nested within another
-func (m *toolCallCmp) SetIsNested(isNested bool) {
- m.isNested = isNested
-}
-
-// Rendering methods
-
-// renderPending displays the tool name with a loading animation for pending tool calls
-func (m *toolCallCmp) renderPending() string {
- t := styles.CurrentTheme()
- icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
- if m.isNested {
- tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
- return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
- }
- tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
- return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
-}
-
-// style returns the lipgloss style for the tool call component.
-// Applies muted colors and focus-dependent border styles.
-func (m *toolCallCmp) style() lipgloss.Style {
- t := styles.CurrentTheme()
-
- if m.isNested {
- return t.S().Muted
- }
- style := t.S().Muted.PaddingLeft(2)
-
- if m.focused {
- style = style.PaddingLeft(1).BorderStyle(focusedMessageBorder).BorderLeft(true).BorderForeground(t.GreenDark)
- }
- return style
-}
-
-// textWidth calculates the available width for text content,
-// accounting for borders and padding
-func (m *toolCallCmp) textWidth() int {
- if m.isNested {
- return m.width - 6
- }
- return m.width - 5 // take into account the border and PaddingLeft
-}
-
-// fit truncates content to fit within the specified width with ellipsis
-func (m *toolCallCmp) fit(content string, width int) string {
- if lipgloss.Width(content) <= width {
- return content
- }
- t := styles.CurrentTheme()
- lineStyle := t.S().Muted
- dots := lineStyle.Render("β¦")
- return ansi.Truncate(content, width, dots)
-}
-
-// Focus management methods
-
-// Blur removes focus from the tool call component
-func (m *toolCallCmp) Blur() tea.Cmd {
- m.focused = false
- return nil
-}
-
-// Focus sets focus on the tool call component
-func (m *toolCallCmp) Focus() tea.Cmd {
- m.focused = true
- return nil
-}
-
-// IsFocused returns whether the tool call component is currently focused
-func (m *toolCallCmp) IsFocused() bool {
- return m.focused
-}
-
-// Size management methods
-
-// GetSize returns the current dimensions of the tool call component
-func (m *toolCallCmp) GetSize() (int, int) {
- return m.width, 0
-}
-
-// SetSize updates the width of the tool call component for text wrapping
-func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
- m.width = width
- for _, nested := range m.nestedToolCalls {
- nested.SetSize(width, height)
- }
- return nil
-}
-
-// shouldSpin determines whether the tool call should show a loading animation.
-// Returns true if the tool call is not finished or if the result doesn't match the call ID.
-func (m *toolCallCmp) shouldSpin() bool {
- return !m.call.Finished && !m.cancelled
-}
-
-// Spinning returns whether the tool call is currently showing a loading animation
-func (m *toolCallCmp) Spinning() bool {
- if m.spinning {
- return true
- }
- for _, nested := range m.nestedToolCalls {
- if nested.Spinning() {
- return true
- }
- }
- return m.spinning
-}
-
-func (m *toolCallCmp) ID() string {
- return m.call.ID
-}
-
-// SetPermissionRequested marks that a permission request was made for this tool call
-func (m *toolCallCmp) SetPermissionRequested() {
- m.permissionRequested = true
-}
-
-// SetPermissionGranted marks that permission was granted for this tool call
-func (m *toolCallCmp) SetPermissionGranted() {
- m.permissionGranted = true
-}
@@ -1,608 +0,0 @@
-package sidebar
-
-import (
- "context"
- "fmt"
- "slices"
- "strings"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/diff"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/history"
- "github.com/charmbracelet/crush/internal/home"
- "github.com/charmbracelet/crush/internal/lsp"
- "github.com/charmbracelet/crush/internal/pubsub"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/tui/components/chat"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/components/files"
- "github.com/charmbracelet/crush/internal/tui/components/logo"
- lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
- "github.com/charmbracelet/crush/internal/tui/components/mcp"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/crush/internal/version"
- "golang.org/x/text/cases"
- "golang.org/x/text/language"
-)
-
-type FileHistory struct {
- initialVersion history.File
- latestVersion history.File
-}
-
-const LogoHeightBreakpoint = 30
-
-// Default maximum number of items to show in each section
-const (
- DefaultMaxFilesShown = 10
- DefaultMaxLSPsShown = 8
- DefaultMaxMCPsShown = 8
- MinItemsPerSection = 2 // Minimum items to show per section
-)
-
-type SessionFile struct {
- History FileHistory
- FilePath string
- Additions int
- Deletions int
-}
-type SessionFilesMsg struct {
- Files []SessionFile
-}
-
-type Sidebar interface {
- util.Model
- layout.Sizeable
- SetSession(session session.Session) tea.Cmd
- SetCompactMode(bool)
-}
-
-type sidebarCmp struct {
- width, height int
- session session.Session
- logo string
- cwd string
- lspClients *csync.Map[string, *lsp.Client]
- compactMode bool
- history history.Service
- files *csync.Map[string, SessionFile]
-}
-
-func New(history history.Service, lspClients *csync.Map[string, *lsp.Client], compact bool) Sidebar {
- return &sidebarCmp{
- lspClients: lspClients,
- history: history,
- compactMode: compact,
- files: csync.NewMap[string, SessionFile](),
- }
-}
-
-func (m *sidebarCmp) Init() tea.Cmd {
- return nil
-}
-
-func (m *sidebarCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case SessionFilesMsg:
- m.files = csync.NewMap[string, SessionFile]()
- for _, file := range msg.Files {
- m.files.Set(file.FilePath, file)
- }
- return m, nil
-
- case chat.SessionClearedMsg:
- m.session = session.Session{}
- case pubsub.Event[history.File]:
- return m, m.handleFileHistoryEvent(msg)
- case pubsub.Event[session.Session]:
- if msg.Type == pubsub.UpdatedEvent {
- if m.session.ID == msg.Payload.ID {
- m.session = msg.Payload
- }
- }
- }
- return m, nil
-}
-
-func (m *sidebarCmp) View() string {
- t := styles.CurrentTheme()
- parts := []string{}
-
- style := t.S().Base.
- Width(m.width).
- Height(m.height).
- Padding(1)
- if m.compactMode {
- style = style.PaddingTop(0)
- }
-
- if !m.compactMode {
- if m.height > LogoHeightBreakpoint {
- parts = append(parts, m.logo)
- } else {
- // Use a smaller logo for smaller screens
- parts = append(parts,
- logo.SmallRender(m.width-style.GetHorizontalFrameSize()),
- "")
- }
- }
-
- if !m.compactMode && m.session.ID != "" {
- parts = append(parts, t.S().Muted.Render(m.session.Title), "")
- } else if m.session.ID != "" {
- parts = append(parts, t.S().Text.Render(m.session.Title), "")
- }
-
- if !m.compactMode {
- parts = append(parts,
- m.cwd,
- "",
- )
- }
- parts = append(parts,
- m.currentModelBlock(),
- )
-
- // Check if we should use horizontal layout for sections
- if m.compactMode && m.width > m.height {
- // Horizontal layout for compact mode when width > height
- sectionsContent := m.renderSectionsHorizontal()
- if sectionsContent != "" {
- parts = append(parts, "", sectionsContent)
- }
- } else {
- // Vertical layout (default)
- if m.session.ID != "" {
- parts = append(parts, "", m.filesBlock())
- }
- parts = append(parts,
- "",
- m.lspBlock(),
- "",
- m.mcpBlock(),
- )
- }
-
- return style.Render(
- lipgloss.JoinVertical(lipgloss.Left, parts...),
- )
-}
-
-func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) tea.Cmd {
- return func() tea.Msg {
- file := event.Payload
- found := false
- for existing := range m.files.Seq() {
- if existing.FilePath != file.Path {
- continue
- }
- if existing.History.latestVersion.Version < file.Version {
- existing.History.latestVersion = file
- } else if file.Version == 0 {
- existing.History.initialVersion = file
- } else {
- // If the version is not greater than the latest, we ignore it
- continue
- }
- before, _ := fsext.ToUnixLineEndings(existing.History.initialVersion.Content)
- after, _ := fsext.ToUnixLineEndings(existing.History.latestVersion.Content)
- path := existing.History.initialVersion.Path
- cwd := config.Get().WorkingDir()
- path = strings.TrimPrefix(path, cwd)
- _, additions, deletions := diff.GenerateDiff(before, after, path)
- existing.Additions = additions
- existing.Deletions = deletions
- m.files.Set(file.Path, existing)
- found = true
- break
- }
- if found {
- return nil
- }
- sf := SessionFile{
- History: FileHistory{
- initialVersion: file,
- latestVersion: file,
- },
- FilePath: file.Path,
- Additions: 0,
- Deletions: 0,
- }
- m.files.Set(file.Path, sf)
- return nil
- }
-}
-
-func (m *sidebarCmp) loadSessionFiles() tea.Msg {
- files, err := m.history.ListBySession(context.Background(), m.session.ID)
- if err != nil {
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: err.Error(),
- }
- }
-
- fileMap := make(map[string]FileHistory)
-
- for _, file := range files {
- if existing, ok := fileMap[file.Path]; ok {
- // Update the latest version
- existing.latestVersion = file
- fileMap[file.Path] = existing
- } else {
- // Add the initial version
- fileMap[file.Path] = FileHistory{
- initialVersion: file,
- latestVersion: file,
- }
- }
- }
-
- sessionFiles := make([]SessionFile, 0, len(fileMap))
- for path, fh := range fileMap {
- cwd := config.Get().WorkingDir()
- path = strings.TrimPrefix(path, cwd)
- before, _ := fsext.ToUnixLineEndings(fh.initialVersion.Content)
- after, _ := fsext.ToUnixLineEndings(fh.latestVersion.Content)
- _, additions, deletions := diff.GenerateDiff(before, after, path)
- sessionFiles = append(sessionFiles, SessionFile{
- History: fh,
- FilePath: path,
- Additions: additions,
- Deletions: deletions,
- })
- }
-
- return SessionFilesMsg{
- Files: sessionFiles,
- }
-}
-
-func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
- m.logo = m.logoBlock()
- m.cwd = cwd()
- m.width = width
- m.height = height
- return nil
-}
-
-func (m *sidebarCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func (m *sidebarCmp) logoBlock() string {
- t := styles.CurrentTheme()
- return logo.Render(version.Version, true, logo.Opts{
- FieldColor: t.Primary,
- TitleColorA: t.Secondary,
- TitleColorB: t.Primary,
- CharmColor: t.Secondary,
- VersionColor: t.Primary,
- Width: m.width - 2,
- })
-}
-
-func (m *sidebarCmp) getMaxWidth() int {
- return min(m.width-2, 58) // -2 for padding
-}
-
-// calculateAvailableHeight estimates how much height is available for dynamic content
-func (m *sidebarCmp) calculateAvailableHeight() int {
- usedHeight := 0
-
- if !m.compactMode {
- if m.height > LogoHeightBreakpoint {
- usedHeight += 7 // Approximate logo height
- } else {
- usedHeight += 2 // Smaller logo height
- }
- usedHeight += 1 // Empty line after logo
- }
-
- if m.session.ID != "" {
- usedHeight += 1 // Title line
- usedHeight += 1 // Empty line after title
- }
-
- if !m.compactMode {
- usedHeight += 1 // CWD line
- usedHeight += 1 // Empty line after CWD
- }
-
- usedHeight += 2 // Model info
-
- usedHeight += 6 // 3 sections Γ 2 lines each (header + empty line)
-
- // Base padding
- usedHeight += 2 // Top and bottom padding
-
- return max(0, m.height-usedHeight)
-}
-
-// getDynamicLimits calculates how many items to show in each section based on available height
-func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
- availableHeight := m.calculateAvailableHeight()
-
- // If we have very little space, use minimum values
- if availableHeight < 10 {
- return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
- }
-
- // Distribute available height among the three sections
- // Give priority to files, then LSPs, then MCPs
- totalSections := 3
- heightPerSection := availableHeight / totalSections
-
- // Calculate limits for each section, ensuring minimums
- maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
- maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
- maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
-
- // If we have extra space, give it to files first
- remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
- if remainingHeight > 0 {
- extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
- maxFiles += extraForFiles
- remainingHeight -= extraForFiles
-
- if remainingHeight > 0 {
- extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
- maxLSPs += extraForLSPs
- remainingHeight -= extraForLSPs
-
- if remainingHeight > 0 {
- maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
- }
- }
- }
-
- return maxFiles, maxLSPs, maxMCPs
-}
-
-// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
-func (m *sidebarCmp) renderSectionsHorizontal() string {
- // Calculate available width for each section
- totalWidth := m.width - 4 // Account for padding and spacing
- sectionWidth := min(50, totalWidth/3)
-
- // Get the sections content with limited height
- var filesContent, lspContent, mcpContent string
-
- filesContent = m.filesBlockCompact(sectionWidth)
- lspContent = m.lspBlockCompact(sectionWidth)
- mcpContent = m.mcpBlockCompact(sectionWidth)
-
- return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
-}
-
-// filesBlockCompact renders the files block with limited width and height for horizontal layout
-func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
- // Convert map to slice and handle type conversion
- sessionFiles := slices.Collect(m.files.Seq())
- fileSlice := make([]files.SessionFile, len(sessionFiles))
- for i, sf := range sessionFiles {
- fileSlice[i] = files.SessionFile{
- History: files.FileHistory{
- InitialVersion: sf.History.initialVersion,
- LatestVersion: sf.History.latestVersion,
- },
- FilePath: sf.FilePath,
- Additions: sf.Additions,
- Deletions: sf.Deletions,
- }
- }
-
- // Limit items for horizontal layout
- maxItems := min(5, len(fileSlice))
- availableHeight := m.height - 8 // Reserve space for header and other content
- if availableHeight > 0 {
- maxItems = min(maxItems, availableHeight)
- }
-
- return files.RenderFileBlock(fileSlice, files.RenderOptions{
- MaxWidth: maxWidth,
- MaxItems: maxItems,
- ShowSection: true,
- SectionName: "Modified Files",
- }, true)
-}
-
-// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
-func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
- // Limit items for horizontal layout
- lspConfigs := config.Get().LSP.Sorted()
- maxItems := min(5, len(lspConfigs))
- availableHeight := m.height - 8
- if availableHeight > 0 {
- maxItems = min(maxItems, availableHeight)
- }
-
- return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
- MaxWidth: maxWidth,
- MaxItems: maxItems,
- ShowSection: true,
- SectionName: "LSPs",
- }, true)
-}
-
-// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
-func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
- // Limit items for horizontal layout
- maxItems := min(5, len(config.Get().MCP.Sorted()))
- availableHeight := m.height - 8
- if availableHeight > 0 {
- maxItems = min(maxItems, availableHeight)
- }
-
- return mcp.RenderMCPBlock(mcp.RenderOptions{
- MaxWidth: maxWidth,
- MaxItems: maxItems,
- ShowSection: true,
- SectionName: "MCPs",
- }, true)
-}
-
-func (m *sidebarCmp) filesBlock() string {
- // Convert map to slice and handle type conversion
- sessionFiles := slices.Collect(m.files.Seq())
- fileSlice := make([]files.SessionFile, len(sessionFiles))
- for i, sf := range sessionFiles {
- fileSlice[i] = files.SessionFile{
- History: files.FileHistory{
- InitialVersion: sf.History.initialVersion,
- LatestVersion: sf.History.latestVersion,
- },
- FilePath: sf.FilePath,
- Additions: sf.Additions,
- Deletions: sf.Deletions,
- }
- }
-
- // Limit the number of files shown
- maxFiles, _, _ := m.getDynamicLimits()
- maxFiles = min(len(fileSlice), maxFiles)
-
- return files.RenderFileBlock(fileSlice, files.RenderOptions{
- MaxWidth: m.getMaxWidth(),
- MaxItems: maxFiles,
- ShowSection: true,
- SectionName: core.Section("Modified Files", m.getMaxWidth()),
- }, true)
-}
-
-func (m *sidebarCmp) lspBlock() string {
- // Limit the number of LSPs shown
- _, maxLSPs, _ := m.getDynamicLimits()
-
- return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{
- MaxWidth: m.getMaxWidth(),
- MaxItems: maxLSPs,
- ShowSection: true,
- SectionName: core.Section("LSPs", m.getMaxWidth()),
- }, true)
-}
-
-func (m *sidebarCmp) mcpBlock() string {
- // Limit the number of MCPs shown
- _, _, maxMCPs := m.getDynamicLimits()
- mcps := config.Get().MCP.Sorted()
- maxMCPs = min(len(mcps), maxMCPs)
-
- return mcp.RenderMCPBlock(mcp.RenderOptions{
- MaxWidth: m.getMaxWidth(),
- MaxItems: maxMCPs,
- ShowSection: true,
- SectionName: core.Section("MCPs", m.getMaxWidth()),
- }, true)
-}
-
-func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
- t := styles.CurrentTheme()
- // Format tokens in human-readable format (e.g., 110K, 1.2M)
- var formattedTokens string
- switch {
- case tokens >= 1_000_000:
- formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
- case tokens >= 1_000:
- formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
- default:
- formattedTokens = fmt.Sprintf("%d", tokens)
- }
-
- // Remove .0 suffix if present
- if strings.HasSuffix(formattedTokens, ".0K") {
- formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
- }
- if strings.HasSuffix(formattedTokens, ".0M") {
- formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
- }
-
- percentage := (float64(tokens) / float64(contextWindow)) * 100
-
- baseStyle := t.S().Base
-
- formattedCost := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("$%.2f", cost))
-
- formattedTokens = baseStyle.Foreground(t.FgSubtle).Render(fmt.Sprintf("(%s)", formattedTokens))
- formattedPercentage := baseStyle.Foreground(t.FgMuted).Render(fmt.Sprintf("%d%%", int(percentage)))
- formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
- if percentage > 80 {
- // add the warning icon
- formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
- }
-
- return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
-}
-
-func (s *sidebarCmp) currentModelBlock() string {
- cfg := config.Get()
- agentCfg := cfg.Agents[config.AgentCoder]
-
- selectedModel := cfg.Models[agentCfg.Model]
-
- model := config.Get().GetModelByType(agentCfg.Model)
-
- t := styles.CurrentTheme()
-
- modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
- modelName := t.S().Text.Render(model.Name)
- modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
- parts := []string{
- modelInfo,
- }
- if model.CanReason {
- reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
- if len(model.ReasoningLevels) == 0 {
- formatter := cases.Title(language.English, cases.NoLower)
- if selectedModel.Think {
- parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
- } else {
- parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off")))
- }
- } else {
- reasoningEffort := model.DefaultReasoningEffort
- if selectedModel.ReasoningEffort != "" {
- reasoningEffort = selectedModel.ReasoningEffort
- }
- formatter := cases.Title(language.English, cases.NoLower)
- parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
- }
- }
- if s.session.ID != "" {
- parts = append(
- parts,
- " "+formatTokensAndCost(
- s.session.CompletionTokens+s.session.PromptTokens,
- model.ContextWindow,
- s.session.Cost,
- ),
- )
- }
- return lipgloss.JoinVertical(
- lipgloss.Left,
- parts...,
- )
-}
-
-// SetSession implements Sidebar.
-func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
- m.session = session
- return m.loadSessionFiles
-}
-
-// SetCompactMode sets the compact mode for the sidebar.
-func (m *sidebarCmp) SetCompactMode(compact bool) {
- m.compactMode = compact
-}
-
-func cwd() string {
- cwd := config.Get().WorkingDir()
- t := styles.CurrentTheme()
- return t.S().Muted.Render(home.Short(cwd))
-}
@@ -1,58 +0,0 @@
-package splash
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
- Select,
- Next,
- Previous,
- Yes,
- No,
- Tab,
- LeftRight,
- Back,
- Copy key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- Select: key.NewBinding(
- key.WithKeys("enter", "ctrl+y"),
- key.WithHelp("enter", "confirm"),
- ),
- Next: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("β", "next item"),
- ),
- Previous: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("β", "previous item"),
- ),
- Yes: key.NewBinding(
- key.WithKeys("y", "Y"),
- key.WithHelp("y", "yes"),
- ),
- No: key.NewBinding(
- key.WithKeys("n", "N"),
- key.WithHelp("n", "no"),
- ),
- Tab: key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "switch"),
- ),
- LeftRight: key.NewBinding(
- key.WithKeys("left", "right"),
- key.WithHelp("β/β", "switch"),
- ),
- Back: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "back"),
- ),
- Copy: key.NewBinding(
- key.WithKeys("c"),
- key.WithHelp("c", "copy url"),
- ),
- }
-}
@@ -1,874 +0,0 @@
-package splash
-
-import (
- "fmt"
- "strings"
- "time"
-
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/spinner"
- tea "charm.land/bubbletea/v2"
- "charm.land/catwalk/pkg/catwalk"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/agent"
- hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/home"
- "github.com/charmbracelet/crush/internal/tui/components/chat"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
- "github.com/charmbracelet/crush/internal/tui/components/logo"
- lspcomponent "github.com/charmbracelet/crush/internal/tui/components/lsp"
- "github.com/charmbracelet/crush/internal/tui/components/mcp"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/crush/internal/version"
-)
-
-type Splash interface {
- util.Model
- layout.Sizeable
- layout.Help
- Cursor() *tea.Cursor
- // SetOnboarding controls whether the splash shows model selection UI
- SetOnboarding(bool)
- // SetProjectInit controls whether the splash shows project initialization prompt
- SetProjectInit(bool)
-
- // Showing API key input
- IsShowingAPIKey() bool
-
- // IsAPIKeyValid returns whether the API key is valid
- IsAPIKeyValid() bool
-
- // IsShowingClaudeOAuth2 returns whether showing Hyper OAuth2 flow
- IsShowingHyperOAuth2() bool
-
- // IsShowingClaudeOAuth2 returns whether showing GitHub Copilot OAuth2 flow
- IsShowingCopilotOAuth2() bool
-}
-
-const (
- SplashScreenPaddingY = 1 // Padding Y for the splash screen
-
- LogoGap = 6
-)
-
-// OnboardingCompleteMsg is sent when onboarding is complete
-type (
- OnboardingCompleteMsg struct{}
- SubmitAPIKeyMsg struct{}
-)
-
-type splashCmp struct {
- width, height int
- keyMap KeyMap
- logoRendered string
-
- // State
- isOnboarding bool
- needsProjectInit bool
- needsAPIKey bool
- selectedNo bool
-
- listHeight int
- modelList *models.ModelListComponent
- apiKeyInput *models.APIKeyInput
- selectedModel *models.ModelOption
- isAPIKeyValid bool
- apiKeyValue string
-
- // Hyper device flow state
- hyperDeviceFlow *hyper.DeviceFlow
- showHyperDeviceFlow bool
-
- // Copilot device flow state
- copilotDeviceFlow *copilot.DeviceFlow
- showCopilotDeviceFlow bool
-}
-
-func New() Splash {
- keyMap := DefaultKeyMap()
- listKeyMap := list.DefaultKeyMap()
- listKeyMap.Down.SetEnabled(false)
- listKeyMap.Up.SetEnabled(false)
- listKeyMap.HalfPageDown.SetEnabled(false)
- listKeyMap.HalfPageUp.SetEnabled(false)
- listKeyMap.Home.SetEnabled(false)
- listKeyMap.End.SetEnabled(false)
- listKeyMap.DownOneItem = keyMap.Next
- listKeyMap.UpOneItem = keyMap.Previous
-
- modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
- apiKeyInput := models.NewAPIKeyInput()
-
- return &splashCmp{
- width: 0,
- height: 0,
- keyMap: keyMap,
- logoRendered: "",
- modelList: modelList,
- apiKeyInput: apiKeyInput,
- selectedNo: false,
- }
-}
-
-func (s *splashCmp) SetOnboarding(onboarding bool) {
- s.isOnboarding = onboarding
-}
-
-func (s *splashCmp) SetProjectInit(needsInit bool) {
- s.needsProjectInit = needsInit
-}
-
-// GetSize implements SplashPage.
-func (s *splashCmp) GetSize() (int, int) {
- return s.width, s.height
-}
-
-// Init implements SplashPage.
-func (s *splashCmp) Init() tea.Cmd {
- return tea.Batch(
- s.modelList.Init(),
- s.apiKeyInput.Init(),
- )
-}
-
-// SetSize implements SplashPage.
-func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
- wasSmallScreen := s.isSmallScreen()
- rerenderLogo := width != s.width
- s.height = height
- s.width = width
- if rerenderLogo || wasSmallScreen != s.isSmallScreen() {
- s.logoRendered = s.logoBlock()
- }
- // remove padding, logo height, gap, title space
- s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
- listWidth := min(60, width)
- s.apiKeyInput.SetWidth(width - 2)
- return s.modelList.SetSize(listWidth, s.listHeight)
-}
-
-// Update implements SplashPage.
-func (s *splashCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- return s, s.SetSize(msg.Width, msg.Height)
- case hyper.DeviceFlowCompletedMsg:
- s.showHyperDeviceFlow = false
- return s, s.saveAPIKeyAndContinue(msg.Token, true)
- case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
- if s.hyperDeviceFlow != nil {
- u, cmd := s.hyperDeviceFlow.Update(msg)
- s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
- return s, cmd
- }
- return s, nil
- case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
- if s.copilotDeviceFlow != nil {
- u, cmd := s.copilotDeviceFlow.Update(msg)
- s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
- return s, cmd
- }
- return s, nil
- case copilot.DeviceFlowCompletedMsg:
- s.showCopilotDeviceFlow = false
- return s, s.saveAPIKeyAndContinue(msg.Token, true)
- case models.APIKeyStateChangeMsg:
- u, cmd := s.apiKeyInput.Update(msg)
- s.apiKeyInput = u.(*models.APIKeyInput)
- if msg.State == models.APIKeyInputStateVerified {
- return s, tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
- return SubmitAPIKeyMsg{}
- })
- }
- return s, cmd
- case SubmitAPIKeyMsg:
- if s.isAPIKeyValid {
- return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
- }
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, s.keyMap.Copy) && s.showHyperDeviceFlow:
- return s, s.hyperDeviceFlow.CopyCode()
- case key.Matches(msg, s.keyMap.Copy) && s.showCopilotDeviceFlow:
- return s, s.copilotDeviceFlow.CopyCode()
- case key.Matches(msg, s.keyMap.Back):
- switch {
- case s.showHyperDeviceFlow:
- s.hyperDeviceFlow = nil
- s.showHyperDeviceFlow = false
- return s, nil
- case s.showCopilotDeviceFlow:
- s.copilotDeviceFlow = nil
- s.showCopilotDeviceFlow = false
- return s, nil
- case s.isAPIKeyValid:
- return s, nil
- case s.needsAPIKey:
- s.needsAPIKey = false
- s.selectedModel = nil
- s.isAPIKeyValid = false
- s.apiKeyValue = ""
- s.apiKeyInput.Reset()
- return s, nil
- }
- case key.Matches(msg, s.keyMap.Select):
- switch {
- case s.showHyperDeviceFlow:
- return s, s.hyperDeviceFlow.CopyCodeAndOpenURL()
- case s.showCopilotDeviceFlow:
- return s, s.copilotDeviceFlow.CopyCodeAndOpenURL()
- case s.isAPIKeyValid:
- return s, s.saveAPIKeyAndContinue(s.apiKeyValue, true)
- case s.isOnboarding && !s.needsAPIKey:
- selectedItem := s.modelList.SelectedModel()
- if selectedItem == nil {
- return s, nil
- }
- if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
- cmd := s.setPreferredModel(*selectedItem)
- s.isOnboarding = false
- return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
- } else {
- switch selectedItem.Provider.ID {
- case hyperp.Name:
- s.selectedModel = selectedItem
- s.showHyperDeviceFlow = true
- s.hyperDeviceFlow = hyper.NewDeviceFlow()
- s.hyperDeviceFlow.SetWidth(min(s.width-2, 60))
- return s, s.hyperDeviceFlow.Init()
- case catwalk.InferenceProviderCopilot:
- if token, ok := config.Get().ImportCopilot(); ok {
- s.selectedModel = selectedItem
- return s, s.saveAPIKeyAndContinue(token, true)
- }
- s.selectedModel = selectedItem
- s.showCopilotDeviceFlow = true
- s.copilotDeviceFlow = copilot.NewDeviceFlow()
- s.copilotDeviceFlow.SetWidth(min(s.width-2, 60))
- return s, s.copilotDeviceFlow.Init()
- }
- // Provider not configured, show API key input
- s.needsAPIKey = true
- s.selectedModel = selectedItem
- s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
- return s, nil
- }
- case s.needsAPIKey:
- // Handle API key submission
- s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
- if s.apiKeyValue == "" {
- return s, nil
- }
-
- provider, err := s.getProvider(s.selectedModel.Provider.ID)
- if err != nil || provider == nil {
- return s, util.ReportError(fmt.Errorf("provider %s not found", s.selectedModel.Provider.ID))
- }
- providerConfig := config.ProviderConfig{
- ID: string(s.selectedModel.Provider.ID),
- Name: s.selectedModel.Provider.Name,
- APIKey: s.apiKeyValue,
- Type: provider.Type,
- BaseURL: provider.APIEndpoint,
- }
- return s, tea.Sequence(
- util.CmdHandler(models.APIKeyStateChangeMsg{
- State: models.APIKeyInputStateVerifying,
- }),
- func() tea.Msg {
- start := time.Now()
- err := providerConfig.TestConnection(config.Get().Resolver())
- // intentionally wait for at least 750ms to make sure the user sees the spinner
- elapsed := time.Since(start)
- if elapsed < 750*time.Millisecond {
- time.Sleep(750*time.Millisecond - elapsed)
- }
- if err == nil {
- s.isAPIKeyValid = true
- return models.APIKeyStateChangeMsg{
- State: models.APIKeyInputStateVerified,
- }
- }
- return models.APIKeyStateChangeMsg{
- State: models.APIKeyInputStateError,
- }
- },
- )
- case s.needsProjectInit:
- return s, s.initializeProject()
- }
- case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
- if s.needsAPIKey {
- u, cmd := s.apiKeyInput.Update(msg)
- s.apiKeyInput = u.(*models.APIKeyInput)
- return s, cmd
- }
- if s.needsProjectInit {
- s.selectedNo = !s.selectedNo
- return s, nil
- }
- case key.Matches(msg, s.keyMap.Yes):
- if s.needsAPIKey {
- u, cmd := s.apiKeyInput.Update(msg)
- s.apiKeyInput = u.(*models.APIKeyInput)
- return s, cmd
- }
- if s.isOnboarding {
- u, cmd := s.modelList.Update(msg)
- s.modelList = u
- return s, cmd
- }
- if s.needsProjectInit {
- s.selectedNo = false
- return s, s.initializeProject()
- }
- case key.Matches(msg, s.keyMap.No):
- if s.needsAPIKey {
- u, cmd := s.apiKeyInput.Update(msg)
- s.apiKeyInput = u.(*models.APIKeyInput)
- return s, cmd
- }
- if s.isOnboarding {
- u, cmd := s.modelList.Update(msg)
- s.modelList = u
- return s, cmd
- }
- if s.needsProjectInit {
- s.selectedNo = true
- return s, s.initializeProject()
- }
- default:
- switch {
- case s.showHyperDeviceFlow:
- u, cmd := s.hyperDeviceFlow.Update(msg)
- s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
- return s, cmd
- case s.showCopilotDeviceFlow:
- u, cmd := s.copilotDeviceFlow.Update(msg)
- s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
- return s, cmd
- case s.needsAPIKey:
- u, cmd := s.apiKeyInput.Update(msg)
- s.apiKeyInput = u.(*models.APIKeyInput)
- return s, cmd
- case s.isOnboarding:
- u, cmd := s.modelList.Update(msg)
- s.modelList = u
- return s, cmd
- }
- }
- case tea.PasteMsg:
- switch {
- case s.showHyperDeviceFlow:
- u, cmd := s.hyperDeviceFlow.Update(msg)
- s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
- return s, cmd
- case s.showCopilotDeviceFlow:
- u, cmd := s.copilotDeviceFlow.Update(msg)
- s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
- return s, cmd
- case s.needsAPIKey:
- u, cmd := s.apiKeyInput.Update(msg)
- s.apiKeyInput = u.(*models.APIKeyInput)
- return s, cmd
- case s.isOnboarding:
- var cmd tea.Cmd
- s.modelList, cmd = s.modelList.Update(msg)
- return s, cmd
- }
- case spinner.TickMsg:
- switch {
- case s.showHyperDeviceFlow:
- u, cmd := s.hyperDeviceFlow.Update(msg)
- s.hyperDeviceFlow = u.(*hyper.DeviceFlow)
- return s, cmd
- case s.showCopilotDeviceFlow:
- u, cmd := s.copilotDeviceFlow.Update(msg)
- s.copilotDeviceFlow = u.(*copilot.DeviceFlow)
- return s, cmd
- default:
- u, cmd := s.apiKeyInput.Update(msg)
- s.apiKeyInput = u.(*models.APIKeyInput)
- return s, cmd
- }
- }
- return s, nil
-}
-
-func (s *splashCmp) saveAPIKeyAndContinue(apiKey any, close bool) tea.Cmd {
- if s.selectedModel == nil {
- return nil
- }
-
- cfg := config.Get()
- err := cfg.SetProviderAPIKey(string(s.selectedModel.Provider.ID), apiKey)
- if err != nil {
- return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
- }
-
- // Reset API key state and continue with model selection
- s.needsAPIKey = false
- cmd := s.setPreferredModel(*s.selectedModel)
- s.isOnboarding = false
- s.selectedModel = nil
- s.isAPIKeyValid = false
-
- if close {
- return tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
- }
- return cmd
-}
-
-func (s *splashCmp) initializeProject() tea.Cmd {
- s.needsProjectInit = false
-
- if err := config.MarkProjectInitialized(); err != nil {
- return util.ReportError(err)
- }
- var cmds []tea.Cmd
-
- cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
- if !s.selectedNo {
- initPrompt, err := agent.InitializePrompt(*config.Get())
- if err != nil {
- return util.ReportError(err)
- }
- cmds = append(cmds,
- util.CmdHandler(chat.SessionClearedMsg{}),
- util.CmdHandler(chat.SendMsg{
- Text: initPrompt,
- }),
- )
- }
- return tea.Sequence(cmds...)
-}
-
-func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
- cfg := config.Get()
- model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
- if model == nil {
- return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
- }
-
- selectedModel := config.SelectedModel{
- Model: selectedItem.Model.ID,
- Provider: string(selectedItem.Provider.ID),
- ReasoningEffort: model.DefaultReasoningEffort,
- MaxTokens: model.DefaultMaxTokens,
- }
-
- err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
- if err != nil {
- return util.ReportError(err)
- }
-
- // Now lets automatically setup the small model
- knownProvider, err := s.getProvider(selectedItem.Provider.ID)
- if err != nil {
- return util.ReportError(err)
- }
- if knownProvider == nil {
- // for local provider we just use the same model
- err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
- if err != nil {
- return util.ReportError(err)
- }
- } else {
- smallModel := knownProvider.DefaultSmallModelID
- model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
- // should never happen
- if model == nil {
- err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
- if err != nil {
- return util.ReportError(err)
- }
- return nil
- }
- smallSelectedModel := config.SelectedModel{
- Model: smallModel,
- Provider: string(selectedItem.Provider.ID),
- ReasoningEffort: model.DefaultReasoningEffort,
- MaxTokens: model.DefaultMaxTokens,
- }
- err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
- if err != nil {
- return util.ReportError(err)
- }
- }
- cfg.SetupAgents()
- return nil
-}
-
-func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
- cfg := config.Get()
- providers, err := config.Providers(cfg)
- if err != nil {
- return nil, err
- }
- for _, p := range providers {
- if p.ID == providerID {
- return &p, nil
- }
- }
- return nil, nil
-}
-
-func (s *splashCmp) isProviderConfigured(providerID string) bool {
- cfg := config.Get()
- if _, ok := cfg.Providers.Get(providerID); ok {
- return true
- }
- return false
-}
-
-func (s *splashCmp) View() string {
- t := styles.CurrentTheme()
- var content string
-
- switch {
- case s.showHyperDeviceFlow:
- remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
- hyperView := s.hyperDeviceFlow.View()
- hyperSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth Hyper"),
- hyperView,
- ),
- )
- content = lipgloss.JoinVertical(
- lipgloss.Left,
- s.logoRendered,
- hyperSelector,
- )
- case s.showCopilotDeviceFlow:
- remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
- copilotView := s.copilotDeviceFlow.View()
- copilotSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("Let's Auth GitHub Copilot"),
- copilotView,
- ),
- )
- content = lipgloss.JoinVertical(
- lipgloss.Left,
- s.logoRendered,
- copilotSelector,
- )
- case s.needsAPIKey:
- remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
- apiKeyView := t.S().Base.PaddingLeft(1).Render(s.apiKeyInput.View())
- apiKeySelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- apiKeyView,
- ),
- )
- content = lipgloss.JoinVertical(
- lipgloss.Left,
- s.logoRendered,
- apiKeySelector,
- )
- case s.isOnboarding:
- modelListView := s.modelList.View()
- remainingHeight := s.height - lipgloss.Height(s.logoRendered) - SplashScreenPaddingY
- modelSelector := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.PaddingLeft(1).Foreground(t.Primary).Render("To start, letβs choose a provider and model."),
- "",
- modelListView,
- ),
- )
- content = lipgloss.JoinVertical(
- lipgloss.Left,
- s.logoRendered,
- modelSelector,
- )
- case s.needsProjectInit:
- titleStyle := t.S().Base.Foreground(t.FgBase)
- pathStyle := t.S().Base.Foreground(t.Success).PaddingLeft(2)
- bodyStyle := t.S().Base.Foreground(t.FgMuted)
- shortcutStyle := t.S().Base.Foreground(t.Success)
-
- initFile := config.Get().Options.InitializeAs
- initText := lipgloss.JoinVertical(
- lipgloss.Left,
- titleStyle.Render("Would you like to initialize this project?"),
- "",
- pathStyle.Render(s.cwd()),
- "",
- bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
- bodyStyle.Render(fmt.Sprintf("result into an %s file which serves as general context.", initFile)),
- "",
- bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
- "",
- bodyStyle.Render("Would you like to initialize now?"),
- )
-
- yesButton := core.SelectableButton(core.ButtonOpts{
- Text: "Yep!",
- UnderlineIndex: 0,
- Selected: !s.selectedNo,
- })
-
- noButton := core.SelectableButton(core.ButtonOpts{
- Text: "Nope",
- UnderlineIndex: 0,
- Selected: s.selectedNo,
- })
-
- buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton)
- remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
-
- initContent := t.S().Base.AlignVertical(lipgloss.Bottom).PaddingLeft(1).Height(remainingHeight).Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- initText,
- "",
- buttons,
- ),
- )
-
- content = lipgloss.JoinVertical(
- lipgloss.Left,
- s.logoRendered,
- "",
- initContent,
- )
- default:
- parts := []string{
- s.logoRendered,
- s.infoSection(),
- }
- content = lipgloss.JoinVertical(lipgloss.Left, parts...)
- }
-
- return t.S().Base.
- Width(s.width).
- Height(s.height).
- PaddingTop(SplashScreenPaddingY).
- PaddingBottom(SplashScreenPaddingY).
- Render(content)
-}
-
-func (s *splashCmp) Cursor() *tea.Cursor {
- switch {
- case s.needsAPIKey:
- cursor := s.apiKeyInput.Cursor()
- if cursor != nil {
- return s.moveCursor(cursor)
- }
- case s.isOnboarding:
- cursor := s.modelList.Cursor()
- if cursor != nil {
- return s.moveCursor(cursor)
- }
- }
- return nil
-}
-
-func (s *splashCmp) isSmallScreen() bool {
- // Consider a screen small if either the width is less than 40 or if the
- // height is less than 20
- return s.width < 55 || s.height < 20
-}
-
-func (s *splashCmp) infoSection() string {
- t := styles.CurrentTheme()
- infoStyle := t.S().Base.PaddingLeft(2)
- if s.isSmallScreen() {
- infoStyle = infoStyle.MarginTop(1)
- }
- return infoStyle.Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- s.cwdPart(),
- "",
- s.currentModelBlock(),
- "",
- lipgloss.JoinHorizontal(lipgloss.Left, s.lspBlock(), s.mcpBlock()),
- "",
- ),
- )
-}
-
-func (s *splashCmp) logoBlock() string {
- t := styles.CurrentTheme()
- logoStyle := t.S().Base.Padding(0, 2).Width(s.width)
- if s.isSmallScreen() {
- // If the width is too small, render a smaller version of the logo
- // NOTE: 20 is not correct because [splashCmp.height] is not the
- // *actual* window height, instead, it is the height of the splash
- // component and that depends on other variables like compact mode and
- // the height of the editor.
- return logoStyle.Render(
- logo.SmallRender(s.width - logoStyle.GetHorizontalFrameSize()),
- )
- }
- return logoStyle.Render(
- logo.Render(version.Version, false, logo.Opts{
- FieldColor: t.Primary,
- TitleColorA: t.Secondary,
- TitleColorB: t.Primary,
- CharmColor: t.Secondary,
- VersionColor: t.Primary,
- Width: s.width - logoStyle.GetHorizontalFrameSize(),
- }),
- )
-}
-
-func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
- if cursor == nil {
- return nil
- }
- // Calculate the correct Y offset based on current state
- logoHeight := lipgloss.Height(s.logoRendered)
- if s.needsAPIKey {
- infoSectionHeight := lipgloss.Height(s.infoSection())
- baseOffset := logoHeight + SplashScreenPaddingY + infoSectionHeight
- remainingHeight := s.height - baseOffset - lipgloss.Height(s.apiKeyInput.View()) - SplashScreenPaddingY
- offset := baseOffset + remainingHeight
- cursor.Y += offset
- cursor.X += 1
- } else if s.isOnboarding {
- offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
- cursor.Y += offset
- cursor.X += 1
- }
-
- return cursor
-}
-
-func (s *splashCmp) logoGap() int {
- if s.height > 35 {
- return LogoGap
- }
- return 0
-}
-
-// Bindings implements SplashPage.
-func (s *splashCmp) Bindings() []key.Binding {
- switch {
- case s.needsAPIKey:
- return []key.Binding{
- s.keyMap.Select,
- s.keyMap.Back,
- }
- case s.isOnboarding:
- return []key.Binding{
- s.keyMap.Select,
- s.keyMap.Next,
- s.keyMap.Previous,
- }
- case s.needsProjectInit:
- return []key.Binding{
- s.keyMap.Select,
- s.keyMap.Yes,
- s.keyMap.No,
- s.keyMap.Tab,
- s.keyMap.LeftRight,
- }
- default:
- return []key.Binding{}
- }
-}
-
-func (s *splashCmp) getMaxInfoWidth() int {
- return min(s.width-2, 90) // 2 for left padding
-}
-
-func (s *splashCmp) cwdPart() string {
- t := styles.CurrentTheme()
- maxWidth := s.getMaxInfoWidth()
- return t.S().Muted.Width(maxWidth).Render(s.cwd())
-}
-
-func (s *splashCmp) cwd() string {
- return home.Short(config.Get().WorkingDir())
-}
-
-func LSPList(maxWidth int) []string {
- return lspcomponent.RenderLSPList(nil, lspcomponent.RenderOptions{
- MaxWidth: maxWidth,
- ShowSection: false,
- })
-}
-
-func (s *splashCmp) lspBlock() string {
- t := styles.CurrentTheme()
- maxWidth := s.getMaxInfoWidth() / 2
- section := t.S().Subtle.Render("LSPs")
- lspList := append([]string{section, ""}, LSPList(maxWidth-1)...)
- return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- lspList...,
- ),
- )
-}
-
-func MCPList(maxWidth int) []string {
- return mcp.RenderMCPList(mcp.RenderOptions{
- MaxWidth: maxWidth,
- ShowSection: false,
- })
-}
-
-func (s *splashCmp) mcpBlock() string {
- t := styles.CurrentTheme()
- maxWidth := s.getMaxInfoWidth() / 2
- section := t.S().Subtle.Render("MCPs")
- mcpList := append([]string{section, ""}, MCPList(maxWidth-1)...)
- return t.S().Base.Width(maxWidth).PaddingRight(1).Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- mcpList...,
- ),
- )
-}
-
-func (s *splashCmp) currentModelBlock() string {
- cfg := config.Get()
- agentCfg := cfg.Agents[config.AgentCoder]
- model := config.Get().GetModelByType(agentCfg.Model)
- if model == nil {
- return ""
- }
- t := styles.CurrentTheme()
- modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
- modelName := t.S().Text.Render(model.Name)
- modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
- parts := []string{
- modelInfo,
- }
-
- return lipgloss.JoinVertical(
- lipgloss.Left,
- parts...,
- )
-}
-
-func (s *splashCmp) IsShowingAPIKey() bool {
- return s.needsAPIKey
-}
-
-func (s *splashCmp) IsAPIKeyValid() bool {
- return s.isAPIKeyValid
-}
-
-func (s *splashCmp) IsShowingHyperOAuth2() bool {
- return s.showHyperDeviceFlow
-}
-
-func (s *splashCmp) IsShowingCopilotOAuth2() bool {
- return s.showCopilotDeviceFlow
-}
@@ -1,67 +0,0 @@
-package todos
-
-import (
- "slices"
- "strings"
-
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/x/ansi"
-)
-
-func sortTodos(todos []session.Todo) {
- slices.SortStableFunc(todos, func(a, b session.Todo) int {
- return statusOrder(a.Status) - statusOrder(b.Status)
- })
-}
-
-func statusOrder(s session.TodoStatus) int {
- switch s {
- case session.TodoStatusCompleted:
- return 0
- case session.TodoStatusInProgress:
- return 1
- default:
- return 2
- }
-}
-
-func FormatTodosList(todos []session.Todo, inProgressIcon string, t *styles.Theme, width int) string {
- if len(todos) == 0 {
- return ""
- }
-
- sorted := make([]session.Todo, len(todos))
- copy(sorted, todos)
- sortTodos(sorted)
-
- var lines []string
- for _, todo := range sorted {
- var prefix string
- var textStyle lipgloss.Style
-
- switch todo.Status {
- case session.TodoStatusCompleted:
- prefix = t.S().Base.Foreground(t.Green).Render(styles.TodoCompletedIcon) + " "
- textStyle = t.S().Base.Foreground(t.FgBase)
- case session.TodoStatusInProgress:
- prefix = t.S().Base.Foreground(t.GreenDark).Render(inProgressIcon + " ")
- textStyle = t.S().Base.Foreground(t.FgBase)
- default:
- prefix = t.S().Base.Foreground(t.FgMuted).Render(styles.TodoPendingIcon) + " "
- textStyle = t.S().Base.Foreground(t.FgBase)
- }
-
- text := todo.Content
- if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
- text = todo.ActiveForm
- }
- line := prefix + textStyle.Render(text)
- line = ansi.Truncate(line, width, "β¦")
-
- lines = append(lines, line)
- }
-
- return strings.Join(lines, "\n")
-}
@@ -1,308 +0,0 @@
-package completions
-
-import (
- "strings"
-
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const maxCompletionsHeight = 10
-
-type Completion struct {
- Title string // The title of the completion item
- Value any // The value of the completion item
-}
-
-type OpenCompletionsMsg struct {
- Completions []Completion
- X int // X position for the completions popup
- Y int // Y position for the completions popup
- MaxResults int // Maximum number of results to render, 0 for no limit
-}
-
-type FilterCompletionsMsg struct {
- Query string // The query to filter completions
- Reopen bool
- X int // X position for the completions popup
- Y int // Y position for the completions popup
-}
-
-type RepositionCompletionsMsg struct {
- X, Y int
-}
-
-type CompletionsClosedMsg struct{}
-
-type CompletionsOpenedMsg struct{}
-
-type CloseCompletionsMsg struct{}
-
-type SelectCompletionMsg struct {
- Value any // The value of the selected completion item
- Insert bool
-}
-
-type Completions interface {
- util.Model
- Open() bool
- Query() string // Returns the current filter query
- KeyMap() KeyMap
- Position() (int, int) // Returns the X and Y position of the completions popup
- Width() int
- Height() int
-}
-
-type listModel = list.FilterableList[list.CompletionItem[any]]
-
-type completionsCmp struct {
- wWidth int // The window width
- wHeight int // The window height
- width int
- lastWidth int
- height int // Height of the completions component`
- x, xorig int // X position for the completions popup
- y int // Y position for the completions popup
- open bool // Indicates if the completions are open
- keyMap KeyMap
-
- list listModel
- query string // The current filter query
-}
-
-func New() Completions {
- completionsKeyMap := DefaultKeyMap()
- keyMap := list.DefaultKeyMap()
- keyMap.Up.SetEnabled(false)
- keyMap.Down.SetEnabled(false)
- keyMap.HalfPageDown.SetEnabled(false)
- keyMap.HalfPageUp.SetEnabled(false)
- keyMap.Home.SetEnabled(false)
- keyMap.End.SetEnabled(false)
- keyMap.UpOneItem = completionsKeyMap.Up
- keyMap.DownOneItem = completionsKeyMap.Down
-
- l := list.NewFilterableList(
- []list.CompletionItem[any]{},
- list.WithFilterInputHidden(),
- list.WithFilterListOptions(
- list.WithDirectionBackward(),
- list.WithKeyMap(keyMap),
- ),
- )
- return &completionsCmp{
- width: 0,
- height: maxCompletionsHeight,
- list: l,
- query: "",
- keyMap: completionsKeyMap,
- }
-}
-
-// Init implements Completions.
-func (c *completionsCmp) Init() tea.Cmd {
- return tea.Sequence(
- c.list.Init(),
- c.list.SetSize(c.width, c.height),
- )
-}
-
-// Update implements Completions.
-func (c *completionsCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- c.wWidth, c.wHeight = msg.Width, msg.Height
- return c, nil
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, c.keyMap.Up):
- u, cmd := c.list.Update(msg)
- c.list = u.(listModel)
- return c, cmd
-
- case key.Matches(msg, c.keyMap.Down):
- d, cmd := c.list.Update(msg)
- c.list = d.(listModel)
- return c, cmd
- case key.Matches(msg, c.keyMap.UpInsert):
- s := c.list.SelectedItem()
- if s == nil {
- return c, nil
- }
- selectedItem := *s
- c.list.SetSelected(selectedItem.ID())
- return c, util.CmdHandler(SelectCompletionMsg{
- Value: selectedItem.Value(),
- Insert: true,
- })
- case key.Matches(msg, c.keyMap.DownInsert):
- s := c.list.SelectedItem()
- if s == nil {
- return c, nil
- }
- selectedItem := *s
- c.list.SetSelected(selectedItem.ID())
- return c, util.CmdHandler(SelectCompletionMsg{
- Value: selectedItem.Value(),
- Insert: true,
- })
- case key.Matches(msg, c.keyMap.Select):
- s := c.list.SelectedItem()
- if s == nil {
- return c, nil
- }
- selectedItem := *s
- c.open = false // Close completions after selection
- return c, util.CmdHandler(SelectCompletionMsg{
- Value: selectedItem.Value(),
- })
- case key.Matches(msg, c.keyMap.Cancel):
- return c, util.CmdHandler(CloseCompletionsMsg{})
- }
- case RepositionCompletionsMsg:
- c.x, c.y = msg.X, msg.Y
- c.adjustPosition()
- case CloseCompletionsMsg:
- c.open = false
- return c, util.CmdHandler(CompletionsClosedMsg{})
- case OpenCompletionsMsg:
- c.open = true
- c.query = ""
- c.x, c.xorig = msg.X, msg.X
- c.y = msg.Y
- items := []list.CompletionItem[any]{}
- t := styles.CurrentTheme()
- for _, completion := range msg.Completions {
- item := list.NewCompletionItem(
- completion.Title,
- completion.Value,
- list.WithCompletionBackgroundColor(t.BgSubtle),
- )
- items = append(items, item)
- }
- width := listWidth(items)
- if len(items) == 0 {
- width = listWidth(c.list.Items())
- }
- if c.x+width >= c.wWidth {
- c.x = c.wWidth - width - 1
- }
- c.width = width
- c.height = max(min(maxCompletionsHeight, len(items)), 1) // Ensure at least 1 item height
- c.list.SetResultsSize(msg.MaxResults)
- return c, tea.Batch(
- c.list.SetItems(items),
- c.list.SetSize(c.width, c.height),
- util.CmdHandler(CompletionsOpenedMsg{}),
- )
- case FilterCompletionsMsg:
- if !c.open && !msg.Reopen {
- return c, nil
- }
- if msg.Query == c.query {
- // PERF: if same query, don't need to filter again
- return c, nil
- }
- if len(c.list.Items()) == 0 &&
- len(msg.Query) > len(c.query) &&
- strings.HasPrefix(msg.Query, c.query) {
- // PERF: if c.query didn't match anything,
- // AND msg.Query is longer than c.query,
- // AND msg.Query is prefixed with c.query - which means
- // that the user typed more chars after a 0 match,
- // it won't match anything, so return earlier.
- return c, nil
- }
- c.query = msg.Query
- var cmds []tea.Cmd
- cmds = append(cmds, c.list.Filter(msg.Query))
- items := c.list.Items()
- itemsLen := len(items)
- c.xorig = msg.X
- c.x, c.y = msg.X, msg.Y
- c.adjustPosition()
- cmds = append(cmds, c.list.SetSize(c.width, c.height))
- if itemsLen == 0 {
- cmds = append(cmds, util.CmdHandler(CloseCompletionsMsg{}))
- } else if msg.Reopen {
- c.open = true
- cmds = append(cmds, util.CmdHandler(CompletionsOpenedMsg{}))
- }
- return c, tea.Batch(cmds...)
- }
- return c, nil
-}
-
-func (c *completionsCmp) adjustPosition() {
- items := c.list.Items()
- itemsLen := len(items)
- width := listWidth(items)
- c.lastWidth = c.width
- if c.x < 0 || width < c.lastWidth {
- c.x = c.xorig
- } else if c.x+width >= c.wWidth {
- c.x = c.wWidth - width - 1
- }
- c.width = width
- c.height = max(min(maxCompletionsHeight, itemsLen), 1)
-}
-
-// View implements Completions.
-func (c *completionsCmp) View() string {
- if !c.open || len(c.list.Items()) == 0 {
- return ""
- }
-
- t := styles.CurrentTheme()
- style := t.S().Base.
- Width(c.width).
- Height(c.height).
- Background(t.BgSubtle)
-
- return style.Render(c.list.View())
-}
-
-// listWidth returns the width of the last 10 items in the list, which is used
-// to determine the width of the completions popup.
-// Note this only works for [completionItemCmp] items.
-func listWidth(items []list.CompletionItem[any]) int {
- var width int
- if len(items) == 0 {
- return width
- }
-
- for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
- itemWidth := lipgloss.Width(items[i].Text()) + 2 // +2 for padding
- width = max(width, itemWidth)
- }
-
- return width
-}
-
-func (c *completionsCmp) Open() bool {
- return c.open
-}
-
-func (c *completionsCmp) Query() string {
- return c.query
-}
-
-func (c *completionsCmp) KeyMap() KeyMap {
- return c.keyMap
-}
-
-func (c *completionsCmp) Position() (int, int) {
- return c.x, c.y - c.height
-}
-
-func (c *completionsCmp) Width() int {
- return c.width
-}
-
-func (c *completionsCmp) Height() int {
- return c.height
-}
@@ -1,72 +0,0 @@
-package completions
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
- Down,
- Up,
- Select,
- Cancel key.Binding
- DownInsert,
- UpInsert key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- Down: key.NewBinding(
- key.WithKeys("down"),
- key.WithHelp("down", "move down"),
- ),
- Up: key.NewBinding(
- key.WithKeys("up"),
- key.WithHelp("up", "move up"),
- ),
- Select: key.NewBinding(
- key.WithKeys("enter", "tab", "ctrl+y"),
- key.WithHelp("enter", "select"),
- ),
- Cancel: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel"),
- ),
- DownInsert: key.NewBinding(
- key.WithKeys("ctrl+n"),
- key.WithHelp("ctrl+n", "insert next"),
- ),
- UpInsert: key.NewBinding(
- key.WithKeys("ctrl+p"),
- key.WithHelp("ctrl+p", "insert previous"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.Down,
- k.Up,
- k.Select,
- k.Cancel,
- }
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
- m := [][]key.Binding{}
- slice := k.KeyBindings()
- for i := 0; i < len(slice); i += 4 {
- end := min(i+4, len(slice))
- m = append(m, slice[i:end])
- }
- return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
- return []key.Binding{
- k.Up,
- k.Down,
- }
-}
@@ -1,207 +0,0 @@
-package core
-
-import (
- "image/color"
- "strings"
-
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/key"
- "charm.land/lipgloss/v2"
- "github.com/alecthomas/chroma/v2"
- "github.com/charmbracelet/crush/internal/tui/exp/diffview"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/x/ansi"
-)
-
-type KeyMapHelp interface {
- Help() help.KeyMap
-}
-
-type simpleHelp struct {
- shortList []key.Binding
- fullList [][]key.Binding
-}
-
-func NewSimpleHelp(shortList []key.Binding, fullList [][]key.Binding) help.KeyMap {
- return &simpleHelp{
- shortList: shortList,
- fullList: fullList,
- }
-}
-
-// FullHelp implements help.KeyMap.
-func (s *simpleHelp) FullHelp() [][]key.Binding {
- return s.fullList
-}
-
-// ShortHelp implements help.KeyMap.
-func (s *simpleHelp) ShortHelp() []key.Binding {
- return s.shortList
-}
-
-func Section(text string, width int) string {
- t := styles.CurrentTheme()
- char := "β"
- length := lipgloss.Width(text) + 1
- remainingWidth := width - length
- lineStyle := t.S().Base.Foreground(t.Border)
- if remainingWidth > 0 {
- text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
- }
- return text
-}
-
-func SectionWithInfo(text string, width int, info string) string {
- t := styles.CurrentTheme()
- char := "β"
- length := lipgloss.Width(text) + 1
- remainingWidth := width - length
-
- if info != "" {
- remainingWidth -= lipgloss.Width(info) + 1 // 1 for the space before info
- }
- lineStyle := t.S().Base.Foreground(t.Border)
- if remainingWidth > 0 {
- text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) + " " + info
- }
- return text
-}
-
-func Title(title string, width int) string {
- t := styles.CurrentTheme()
- char := "β±"
- length := lipgloss.Width(title) + 1
- remainingWidth := width - length
- titleStyle := t.S().Base.Foreground(t.Primary)
- if remainingWidth > 0 {
- lines := strings.Repeat(char, remainingWidth)
- lines = styles.ApplyForegroundGrad(lines, t.Primary, t.Secondary)
- title = titleStyle.Render(title) + " " + lines
- }
- return title
-}
-
-type StatusOpts struct {
- Icon string // if empty no icon will be shown
- Title string
- TitleColor color.Color
- Description string
- DescriptionColor color.Color
- ExtraContent string // additional content to append after the description
-}
-
-func Status(opts StatusOpts, width int) string {
- t := styles.CurrentTheme()
- icon := opts.Icon
- title := opts.Title
- titleColor := t.FgMuted
- if opts.TitleColor != nil {
- titleColor = opts.TitleColor
- }
- description := opts.Description
- descriptionColor := t.FgSubtle
- if opts.DescriptionColor != nil {
- descriptionColor = opts.DescriptionColor
- }
- title = t.S().Base.Foreground(titleColor).Render(title)
- if description != "" {
- extraContentWidth := lipgloss.Width(opts.ExtraContent)
- if extraContentWidth > 0 {
- extraContentWidth += 1
- }
- description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "β¦")
- description = t.S().Base.Foreground(descriptionColor).Render(description)
- }
-
- content := []string{}
- if icon != "" {
- content = append(content, icon)
- }
- content = append(content, title)
- if description != "" {
- content = append(content, description)
- }
- if opts.ExtraContent != "" {
- content = append(content, opts.ExtraContent)
- }
-
- return strings.Join(content, " ")
-}
-
-type ButtonOpts struct {
- Text string
- UnderlineIndex int // Index of character to underline (0-based)
- Selected bool // Whether this button is selected
-}
-
-// SelectableButton creates a button with an underlined character and selection state
-func SelectableButton(opts ButtonOpts) string {
- t := styles.CurrentTheme()
-
- // Base style for the button
- buttonStyle := t.S().Text
-
- // Apply selection styling
- if opts.Selected {
- buttonStyle = buttonStyle.Foreground(t.White).Background(t.Secondary)
- } else {
- buttonStyle = buttonStyle.Background(t.BgSubtle)
- }
-
- // Create the button text with underlined character
- text := opts.Text
- if opts.UnderlineIndex >= 0 && opts.UnderlineIndex < len(text) {
- before := text[:opts.UnderlineIndex]
- underlined := text[opts.UnderlineIndex : opts.UnderlineIndex+1]
- after := text[opts.UnderlineIndex+1:]
-
- message := buttonStyle.Render(before) +
- buttonStyle.Underline(true).Render(underlined) +
- buttonStyle.Render(after)
-
- return buttonStyle.Padding(0, 2).Render(message)
- }
-
- // Fallback if no underline index specified
- return buttonStyle.Padding(0, 2).Render(text)
-}
-
-// SelectableButtons creates a horizontal row of selectable buttons
-func SelectableButtons(buttons []ButtonOpts, spacing string) string {
- if spacing == "" {
- spacing = " "
- }
-
- var parts []string
- for i, button := range buttons {
- parts = append(parts, SelectableButton(button))
- if i < len(buttons)-1 {
- parts = append(parts, spacing)
- }
- }
-
- return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
-}
-
-// SelectableButtonsVertical creates a vertical row of selectable buttons
-func SelectableButtonsVertical(buttons []ButtonOpts, spacing int) string {
- var parts []string
- for i, button := range buttons {
- parts = append(parts, SelectableButton(button))
- if i < len(buttons)-1 {
- for range spacing {
- parts = append(parts, "")
- }
- }
- }
-
- return lipgloss.JoinVertical(lipgloss.Center, parts...)
-}
-
-func DiffFormatter() *diffview.DiffView {
- t := styles.CurrentTheme()
- formatDiff := diffview.New()
- style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
- diff := formatDiff.ChromaStyle(style).Style(t.S().Diff).TabWidth(4)
- return diff
-}
@@ -1,27 +0,0 @@
-package layout
-
-import (
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
-)
-
-// TODO: move this to core
-
-type Focusable interface {
- Focus() tea.Cmd
- Blur() tea.Cmd
- IsFocused() bool
-}
-
-type Sizeable interface {
- SetSize(width, height int) tea.Cmd
- GetSize() (int, int)
-}
-
-type Help interface {
- Bindings() []key.Binding
-}
-
-type Positional interface {
- SetPosition(x, y int) tea.Cmd
-}
@@ -1,113 +0,0 @@
-package status
-
-import (
- "time"
-
- "charm.land/bubbles/v2/help"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/x/ansi"
-)
-
-type StatusCmp interface {
- util.Model
- ToggleFullHelp()
- SetKeyMap(keyMap help.KeyMap)
-}
-
-type statusCmp struct {
- info util.InfoMsg
- width int
- messageTTL time.Duration
- help help.Model
- keyMap help.KeyMap
-}
-
-// clearMessageCmd is a command that clears status messages after a timeout
-func (m *statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
- return tea.Tick(ttl, func(time.Time) tea.Msg {
- return util.ClearStatusMsg{}
- })
-}
-
-func (m *statusCmp) Init() tea.Cmd {
- return nil
-}
-
-func (m *statusCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.help.SetWidth(msg.Width - 2)
- return m, nil
-
- // Handle status info
- case util.InfoMsg:
- m.info = msg
- ttl := msg.TTL
- if ttl == 0 {
- ttl = m.messageTTL
- }
- return m, m.clearMessageCmd(ttl)
- case util.ClearStatusMsg:
- m.info = util.InfoMsg{}
- }
- return m, nil
-}
-
-func (m *statusCmp) View() string {
- t := styles.CurrentTheme()
- status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
- if m.info.Msg != "" {
- status = m.infoMsg()
- }
- return status
-}
-
-func (m *statusCmp) infoMsg() string {
- t := styles.CurrentTheme()
- message := ""
- infoType := ""
- switch m.info.Type {
- case util.InfoTypeError:
- infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
- widthLeft := m.width - (lipgloss.Width(infoType) + 2)
- info := ansi.Truncate(m.info.Msg, widthLeft, "β¦")
- message = t.S().Base.Background(t.Error).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info)
- case util.InfoTypeWarn:
- infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
- widthLeft := m.width - (lipgloss.Width(infoType) + 2)
- info := ansi.Truncate(m.info.Msg, widthLeft, "β¦")
- message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info)
- default:
- note := "OKAY!"
- if m.info.Type == util.InfoTypeUpdate {
- note = "HEY!"
- }
- infoType = t.S().Base.Foreground(t.BgSubtle).Background(t.Green).Padding(0, 1).Bold(true).Render(note)
- widthLeft := m.width - (lipgloss.Width(infoType) + 2)
- info := ansi.Truncate(m.info.Msg, widthLeft, "β¦")
- message = t.S().Base.Background(t.GreenDark).Width(widthLeft+2).Foreground(t.BgSubtle).Padding(0, 1).Render(info)
- }
- return ansi.Truncate(infoType+message, m.width, "β¦")
-}
-
-func (m *statusCmp) ToggleFullHelp() {
- m.help.ShowAll = !m.help.ShowAll
-}
-
-func (m *statusCmp) SetKeyMap(keyMap help.KeyMap) {
- m.keyMap = keyMap
-}
-
-func NewStatusCmp() StatusCmp {
- t := styles.CurrentTheme()
- help := help.New()
- help.Styles = t.S().Help
- return &statusCmp{
- messageTTL: 5 * time.Second,
- help: help,
- }
-}
@@ -1,144 +0,0 @@
-package core_test
-
-import (
- "fmt"
- "image/color"
- "testing"
-
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/x/exp/golden"
-)
-
-func TestStatus(t *testing.T) {
- t.Parallel()
-
- tests := []struct {
- name string
- opts core.StatusOpts
- width int
- }{
- {
- name: "Default",
- opts: core.StatusOpts{
- Title: "Status",
- Description: "Everything is working fine",
- },
- width: 80,
- },
- {
- name: "WithCustomIcon",
- opts: core.StatusOpts{
- Icon: "β",
- Title: "Success",
- Description: "Operation completed successfully",
- },
- width: 80,
- },
- {
- name: "NoIcon",
- opts: core.StatusOpts{
- Title: "Info",
- Description: "This status has no icon",
- },
- width: 80,
- },
- {
- name: "WithColors",
- opts: core.StatusOpts{
- Icon: "β ",
- Title: "Warning",
- TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
- Description: "This is a warning message",
- DescriptionColor: color.RGBA{255, 0, 0, 255}, // Red
- },
- width: 80,
- },
- {
- name: "WithExtraContent",
- opts: core.StatusOpts{
- Title: "Build",
- Description: "Building project",
- ExtraContent: "[2/5]",
- },
- width: 80,
- },
- {
- name: "LongDescription",
- opts: core.StatusOpts{
- Title: "Processing",
- Description: "This is a very long description that should be truncated when the width is too small to display it completely without wrapping",
- },
- width: 60,
- },
- {
- name: "NarrowWidth",
- opts: core.StatusOpts{
- Icon: "β",
- Title: "Status",
- Description: "Short message",
- },
- width: 30,
- },
- {
- name: "VeryNarrowWidth",
- opts: core.StatusOpts{
- Icon: "β",
- Title: "Test",
- Description: "This will be truncated",
- },
- width: 20,
- },
- {
- name: "EmptyDescription",
- opts: core.StatusOpts{
- Icon: "β",
- Title: "Title Only",
- },
- width: 80,
- },
- {
- name: "AllFieldsWithExtraContent",
- opts: core.StatusOpts{
- Icon: "π",
- Title: "Deployment",
- TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
- Description: "Deploying to production environment",
- DescriptionColor: color.RGBA{128, 128, 128, 255}, // Gray
- ExtraContent: "v1.2.3",
- },
- width: 80,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
-
- output := core.Status(tt.opts, tt.width)
- golden.RequireEqual(t, []byte(output))
- })
- }
-}
-
-func TestStatusTruncation(t *testing.T) {
- t.Parallel()
-
- opts := core.StatusOpts{
- Icon: "β",
- Title: "Very Long Title",
- Description: "This is an extremely long description that definitely needs to be truncated",
- ExtraContent: "[extra]",
- }
-
- // Test different widths to ensure truncation works correctly
- widths := []int{20, 30, 40, 50, 60}
-
- for _, width := range widths {
- t.Run(fmt.Sprintf("Width%d", width), func(t *testing.T) {
- t.Parallel()
-
- output := core.Status(opts, width)
- golden.RequireEqual(t, []byte(output))
- })
- }
-}
@@ -1 +0,0 @@
-π [38;2;0;0;255mDeployment[m [38;2;128;128;128mDeploying to production environment[m v1.2.3
@@ -1 +0,0 @@
-[38;2;133;131;146mStatus[m [38;2;96;95;107mEverything is working fine[m
@@ -1 +0,0 @@
-β [38;2;133;131;146mTitle Only[m
@@ -1 +0,0 @@
-[38;2;133;131;146mProcessing[m [38;2;96;95;107mThis is a very long description that should be β¦[m
@@ -1 +0,0 @@
-β [38;2;133;131;146mStatus[m [38;2;96;95;107mShort message[m
@@ -1 +0,0 @@
-[38;2;133;131;146mInfo[m [38;2;96;95;107mThis status has no icon[m
@@ -1 +0,0 @@
-β [38;2;133;131;146mTest[m [38;2;96;95;107mThis will beβ¦[m
@@ -1 +0,0 @@
-β [38;2;255;255;0mWarning[m [38;2;255;0;0mThis is a warning message[m
@@ -1 +0,0 @@
-β [38;2;133;131;146mSuccess[m [38;2;96;95;107mOperation completed successfully[m
@@ -1 +0,0 @@
-[38;2;133;131;146mBuild[m [38;2;96;95;107mBuilding project[m [2/5]
@@ -1 +0,0 @@
-β [38;2;133;131;146mVery Long Title[m [38;2;96;95;107m[m [extra]
@@ -1 +0,0 @@
-β [38;2;133;131;146mVery Long Title[m [38;2;96;95;107mThiβ¦[m [extra]
@@ -1 +0,0 @@
-β [38;2;133;131;146mVery Long Title[m [38;2;96;95;107mThis is an exβ¦[m [extra]
@@ -1 +0,0 @@
-β [38;2;133;131;146mVery Long Title[m [38;2;96;95;107mThis is an extremely loβ¦[m [extra]
@@ -1 +0,0 @@
-β [38;2;133;131;146mVery Long Title[m [38;2;96;95;107mThis is an extremely long descripβ¦[m [extra]
@@ -1,245 +0,0 @@
-package commands
-
-import (
- "cmp"
-
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/textinput"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/crush/internal/uicmd"
-)
-
-const (
- argumentsDialogID dialogs.DialogID = "arguments"
-)
-
-// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
-type ShowArgumentsDialogMsg = uicmd.ShowArgumentsDialogMsg
-
-// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
-type CloseArgumentsDialogMsg = uicmd.CloseArgumentsDialogMsg
-
-// CommandArgumentsDialog represents the commands dialog.
-type CommandArgumentsDialog interface {
- dialogs.DialogModel
-}
-
-type commandArgumentsDialogCmp struct {
- wWidth, wHeight int
- width, height int
-
- inputs []textinput.Model
- focused int
- keys ArgumentsDialogKeyMap
- arguments []Argument
- help help.Model
-
- id string
- title string
- name string
- description string
-
- onSubmit func(args map[string]string) tea.Cmd
-}
-
-type Argument struct {
- Name, Title, Description string
- Required bool
-}
-
-func NewCommandArgumentsDialog(
- id, title, name, description string,
- arguments []Argument,
- onSubmit func(args map[string]string) tea.Cmd,
-) CommandArgumentsDialog {
- t := styles.CurrentTheme()
- inputs := make([]textinput.Model, len(arguments))
-
- for i, arg := range arguments {
- ti := textinput.New()
- ti.Placeholder = cmp.Or(arg.Description, "Enter value for "+arg.Title)
- ti.SetWidth(40)
- ti.SetVirtualCursor(false)
- ti.Prompt = ""
-
- ti.SetStyles(t.S().TextInput)
- // Only focus the first input initially
- if i == 0 {
- ti.Focus()
- } else {
- ti.Blur()
- }
-
- inputs[i] = ti
- }
-
- return &commandArgumentsDialogCmp{
- inputs: inputs,
- keys: DefaultArgumentsDialogKeyMap(),
- id: id,
- name: name,
- title: title,
- description: description,
- arguments: arguments,
- width: 60,
- help: help.New(),
- onSubmit: onSubmit,
- }
-}
-
-// Init implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) Init() tea.Cmd {
- return nil
-}
-
-// Update implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- c.wWidth = msg.Width
- c.wHeight = msg.Height
- c.width = min(90, c.wWidth)
- c.height = min(15, c.wHeight)
- for i := range c.inputs {
- c.inputs[i].SetWidth(c.width - (paddingHorizontal * 2))
- }
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, c.keys.Close):
- return c, util.CmdHandler(dialogs.CloseDialogMsg{})
- case key.Matches(msg, c.keys.Confirm):
- if c.focused == len(c.inputs)-1 {
- args := make(map[string]string)
- for i, arg := range c.arguments {
- value := c.inputs[i].Value()
- args[arg.Name] = value
- }
- return c, tea.Sequence(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- c.onSubmit(args),
- )
- }
- // Otherwise, move to the next input
- c.inputs[c.focused].Blur()
- c.focused++
- c.inputs[c.focused].Focus()
- case key.Matches(msg, c.keys.Next):
- // Move to the next input
- c.inputs[c.focused].Blur()
- c.focused = (c.focused + 1) % len(c.inputs)
- c.inputs[c.focused].Focus()
- case key.Matches(msg, c.keys.Previous):
- // Move to the previous input
- c.inputs[c.focused].Blur()
- c.focused = (c.focused - 1 + len(c.inputs)) % len(c.inputs)
- c.inputs[c.focused].Focus()
- case key.Matches(msg, c.keys.Close):
- return c, util.CmdHandler(dialogs.CloseDialogMsg{})
- default:
- var cmd tea.Cmd
- c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
- return c, cmd
- }
- case tea.PasteMsg:
- var cmd tea.Cmd
- c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
- return c, cmd
- }
- return c, nil
-}
-
-// View implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) View() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base
-
- title := lipgloss.NewStyle().
- Foreground(t.Primary).
- Bold(true).
- Padding(0, 1).
- Render(cmp.Or(c.title, c.name))
-
- promptName := t.S().Text.
- Padding(0, 1).
- Render(c.description)
-
- inputFields := make([]string, len(c.inputs))
- for i, input := range c.inputs {
- labelStyle := baseStyle.Padding(1, 1, 0, 1)
-
- if i == c.focused {
- labelStyle = labelStyle.Foreground(t.FgBase).Bold(true)
- } else {
- labelStyle = labelStyle.Foreground(t.FgMuted)
- }
-
- arg := c.arguments[i]
- argName := cmp.Or(arg.Title, arg.Name)
- if arg.Required {
- argName += "*"
- }
- label := labelStyle.Render(argName + ":")
-
- field := t.S().Text.
- Padding(0, 1).
- Render(input.View())
-
- inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
- }
-
- elements := []string{title, promptName}
- elements = append(elements, inputFields...)
-
- c.help.ShowAll = false
- helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys))
- elements = append(elements, "", helpText)
-
- content := lipgloss.JoinVertical(lipgloss.Left, elements...)
-
- return baseStyle.Padding(1, 1, 0, 1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus).
- Width(c.width).
- Render(content)
-}
-
-func (c *commandArgumentsDialogCmp) Cursor() *tea.Cursor {
- if len(c.inputs) == 0 {
- return nil
- }
- cursor := c.inputs[c.focused].Cursor()
- if cursor != nil {
- cursor = c.moveCursor(cursor)
- }
- return cursor
-}
-
-const (
- headerHeight = 3
- itemHeight = 3
- paddingHorizontal = 3
-)
-
-func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
- row, col := c.Position()
- offset := row + headerHeight + (1+c.focused)*itemHeight
- cursor.Y += offset
- cursor.X = cursor.X + col + paddingHorizontal
- return cursor
-}
-
-func (c *commandArgumentsDialogCmp) Position() (int, int) {
- row := (c.wHeight / 2) - (c.height / 2)
- col := (c.wWidth / 2) - (c.width / 2)
- return row, col
-}
-
-// ID implements CommandArgumentsDialog.
-func (c *commandArgumentsDialogCmp) ID() dialogs.DialogID {
- return argumentsDialogID
-}
@@ -1,479 +0,0 @@
-package commands
-
-import (
- "fmt"
- "os"
- "slices"
- "strings"
-
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
-
- "github.com/charmbracelet/crush/internal/agent"
- "github.com/charmbracelet/crush/internal/agent/tools/mcp"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/pubsub"
- "github.com/charmbracelet/crush/internal/tui/components/chat"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/crush/internal/uicmd"
-)
-
-const (
- CommandsDialogID dialogs.DialogID = "commands"
-
- defaultWidth int = 70
-)
-
-type commandType = uicmd.CommandType
-
-const (
- SystemCommands = uicmd.SystemCommands
- UserCommands = uicmd.UserCommands
- MCPPrompts = uicmd.MCPPrompts
-)
-
-type listModel = list.FilterableList[list.CompletionItem[Command]]
-
-// Command represents a command that can be executed
-type (
- Command = uicmd.Command
- CommandRunCustomMsg = uicmd.CommandRunCustomMsg
- ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg
-)
-
-// CommandsDialog represents the commands dialog.
-type CommandsDialog interface {
- dialogs.DialogModel
-}
-
-type commandDialogCmp struct {
- width int
- wWidth int // Width of the terminal window
- wHeight int // Height of the terminal window
-
- commandList listModel
- keyMap CommandsDialogKeyMap
- help help.Model
- selected commandType // Selected SystemCommands, UserCommands, or MCPPrompts
- userCommands []Command // User-defined commands
- mcpPrompts *csync.Slice[Command] // MCP prompts
- sessionID string // Current session ID
-}
-
-type (
- SwitchSessionsMsg struct{}
- NewSessionsMsg struct{}
- SwitchModelMsg struct{}
- QuitMsg struct{}
- OpenFilePickerMsg struct{}
- ToggleHelpMsg struct{}
- ToggleCompactModeMsg struct{}
- ToggleThinkingMsg struct{}
- OpenReasoningDialogMsg struct{}
- OpenExternalEditorMsg struct{}
- ToggleYoloModeMsg struct{}
- CompactMsg struct {
- SessionID string
- }
-)
-
-func NewCommandDialog(sessionID string) CommandsDialog {
- keyMap := DefaultCommandsDialogKeyMap()
- listKeyMap := list.DefaultKeyMap()
- listKeyMap.Down.SetEnabled(false)
- listKeyMap.Up.SetEnabled(false)
- listKeyMap.DownOneItem = keyMap.Next
- listKeyMap.UpOneItem = keyMap.Previous
-
- t := styles.CurrentTheme()
- inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
- commandList := list.NewFilterableList(
- []list.CompletionItem[Command]{},
- list.WithFilterInputStyle(inputStyle),
- list.WithFilterListOptions(
- list.WithKeyMap(listKeyMap),
- list.WithWrapNavigation(),
- list.WithResizeByList(),
- ),
- )
- help := help.New()
- help.Styles = t.S().Help
- return &commandDialogCmp{
- commandList: commandList,
- width: defaultWidth,
- keyMap: DefaultCommandsDialogKeyMap(),
- help: help,
- selected: SystemCommands,
- sessionID: sessionID,
- mcpPrompts: csync.NewSlice[Command](),
- }
-}
-
-func (c *commandDialogCmp) Init() tea.Cmd {
- commands, err := uicmd.LoadCustomCommands()
- if err != nil {
- return util.ReportError(err)
- }
- c.userCommands = commands
- c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
- return c.setCommandType(c.selected)
-}
-
-func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- c.wWidth = msg.Width
- c.wHeight = msg.Height
- return c, tea.Batch(
- c.setCommandType(c.selected),
- c.commandList.SetSize(c.listWidth(), c.listHeight()),
- )
- case pubsub.Event[mcp.Event]:
- // Reload MCP prompts when MCP state changes
- if msg.Type == pubsub.UpdatedEvent {
- c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
- // If we're currently viewing MCP prompts, refresh the list
- if c.selected == MCPPrompts {
- return c, c.setCommandType(MCPPrompts)
- }
- return c, nil
- }
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, c.keyMap.Select):
- selectedItem := c.commandList.SelectedItem()
- if selectedItem == nil {
- return c, nil // No item selected, do nothing
- }
- command := (*selectedItem).Value()
- return c, tea.Sequence(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- command.Handler(command),
- )
- case key.Matches(msg, c.keyMap.Tab):
- if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
- return c, nil
- }
- return c, c.setCommandType(c.next())
- case key.Matches(msg, c.keyMap.Close):
- return c, util.CmdHandler(dialogs.CloseDialogMsg{})
- default:
- u, cmd := c.commandList.Update(msg)
- c.commandList = u.(listModel)
- return c, cmd
- }
- }
- return c, nil
-}
-
-func (c *commandDialogCmp) next() commandType {
- switch c.selected {
- case SystemCommands:
- if len(c.userCommands) > 0 {
- return UserCommands
- }
- if c.mcpPrompts.Len() > 0 {
- return MCPPrompts
- }
- fallthrough
- case UserCommands:
- if c.mcpPrompts.Len() > 0 {
- return MCPPrompts
- }
- fallthrough
- case MCPPrompts:
- return SystemCommands
- default:
- return SystemCommands
- }
-}
-
-func (c *commandDialogCmp) View() string {
- t := styles.CurrentTheme()
- listView := c.commandList
- radio := c.commandTypeRadio()
-
- header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
- if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
- header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
- }
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- header,
- listView.View(),
- "",
- t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
- )
- return c.style().Render(content)
-}
-
-func (c *commandDialogCmp) Cursor() *tea.Cursor {
- if cursor, ok := c.commandList.(util.Cursor); ok {
- cursor := cursor.Cursor()
- if cursor != nil {
- cursor = c.moveCursor(cursor)
- }
- return cursor
- }
- return nil
-}
-
-func (c *commandDialogCmp) commandTypeRadio() string {
- t := styles.CurrentTheme()
-
- fn := func(i commandType) string {
- if i == c.selected {
- return "β " + i.String()
- }
- return "β " + i.String()
- }
-
- parts := []string{
- fn(SystemCommands),
- }
- if len(c.userCommands) > 0 {
- parts = append(parts, fn(UserCommands))
- }
- if c.mcpPrompts.Len() > 0 {
- parts = append(parts, fn(MCPPrompts))
- }
- return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
-}
-
-func (c *commandDialogCmp) listWidth() int {
- return defaultWidth - 2 // 4 for padding
-}
-
-func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
- c.selected = commandType
-
- var commands []Command
- switch c.selected {
- case SystemCommands:
- commands = c.defaultCommands()
- case UserCommands:
- commands = c.userCommands
- case MCPPrompts:
- commands = slices.Collect(c.mcpPrompts.Seq())
- }
-
- commandItems := []list.CompletionItem[Command]{}
- for _, cmd := range commands {
- opts := []list.CompletionItemOption{
- list.WithCompletionID(cmd.ID),
- }
- if cmd.Shortcut != "" {
- opts = append(
- opts,
- list.WithCompletionShortcut(cmd.Shortcut),
- )
- }
- commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
- }
- return c.commandList.SetItems(commandItems)
-}
-
-func (c *commandDialogCmp) listHeight() int {
- listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
- return min(listHeigh, c.wHeight/2)
-}
-
-func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
- row, col := c.Position()
- offset := row + 3
- cursor.Y += offset
- cursor.X = cursor.X + col + 2
- return cursor
-}
-
-func (c *commandDialogCmp) style() lipgloss.Style {
- t := styles.CurrentTheme()
- return t.S().Base.
- Width(c.width).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus)
-}
-
-func (c *commandDialogCmp) Position() (int, int) {
- row := c.wHeight/4 - 2 // just a bit above the center
- col := c.wWidth / 2
- col -= c.width / 2
- return row, col
-}
-
-func (c *commandDialogCmp) defaultCommands() []Command {
- commands := []Command{
- {
- ID: "new_session",
- Title: "New Session",
- Description: "start a new session",
- Shortcut: "ctrl+n",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(NewSessionsMsg{})
- },
- },
- {
- ID: "switch_session",
- Title: "Switch Session",
- Description: "Switch to a different session",
- Shortcut: "ctrl+s",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(SwitchSessionsMsg{})
- },
- },
- {
- ID: "switch_model",
- Title: "Switch Model",
- Description: "Switch to a different model",
- Shortcut: "ctrl+l",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(SwitchModelMsg{})
- },
- },
- }
-
- // Only show compact command if there's an active session
- if c.sessionID != "" {
- commands = append(commands, Command{
- ID: "Summarize",
- Title: "Summarize Session",
- Description: "Summarize the current session and create a new one with the summary",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(CompactMsg{
- SessionID: c.sessionID,
- })
- },
- })
- }
-
- // Add reasoning toggle for models that support it
- cfg := config.Get()
- if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
- providerCfg := cfg.GetProviderForModel(agentCfg.Model)
- model := cfg.GetModelByType(agentCfg.Model)
- if providerCfg != nil && model != nil && model.CanReason {
- selectedModel := cfg.Models[agentCfg.Model]
-
- // Anthropic models: thinking toggle
- if model.CanReason && len(model.ReasoningLevels) == 0 {
- status := "Enable"
- if selectedModel.Think {
- status = "Disable"
- }
- commands = append(commands, Command{
- ID: "toggle_thinking",
- Title: status + " Thinking Mode",
- Description: "Toggle model thinking for reasoning-capable models",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(ToggleThinkingMsg{})
- },
- })
- }
-
- // OpenAI models: reasoning effort dialog
- if len(model.ReasoningLevels) > 0 {
- commands = append(commands, Command{
- ID: "select_reasoning_effort",
- Title: "Select Reasoning Effort",
- Description: "Choose reasoning effort level (low/medium/high)",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(OpenReasoningDialogMsg{})
- },
- })
- }
- }
- }
- // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
- if c.wWidth > 120 && c.sessionID != "" {
- commands = append(commands, Command{
- ID: "toggle_sidebar",
- Title: "Toggle Sidebar",
- Description: "Toggle between compact and normal layout",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(ToggleCompactModeMsg{})
- },
- })
- }
- if c.sessionID != "" {
- agentCfg := config.Get().Agents[config.AgentCoder]
- model := config.Get().GetModelByType(agentCfg.Model)
- if model.SupportsImages {
- commands = append(commands, Command{
- ID: "file_picker",
- Title: "Open File Picker",
- Shortcut: "ctrl+f",
- Description: "Open file picker",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(OpenFilePickerMsg{})
- },
- })
- }
- }
-
- // Add external editor command if $EDITOR is available
- if os.Getenv("EDITOR") != "" {
- commands = append(commands, Command{
- ID: "open_external_editor",
- Title: "Open External Editor",
- Shortcut: "ctrl+o",
- Description: "Open external editor to compose message",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(OpenExternalEditorMsg{})
- },
- })
- }
-
- return append(commands, []Command{
- {
- ID: "toggle_yolo",
- Title: "Toggle Yolo Mode",
- Description: "Toggle yolo mode",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(ToggleYoloModeMsg{})
- },
- },
- {
- ID: "toggle_help",
- Title: "Toggle Help",
- Shortcut: "ctrl+g",
- Description: "Toggle help",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(ToggleHelpMsg{})
- },
- },
- {
- ID: "init",
- Title: "Initialize Project",
- Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
- Handler: func(cmd Command) tea.Cmd {
- initPrompt, err := agent.InitializePrompt(*config.Get())
- if err != nil {
- return util.ReportError(err)
- }
- return util.CmdHandler(chat.SendMsg{
- Text: initPrompt,
- })
- },
- },
- {
- ID: "quit",
- Title: "Quit",
- Description: "Quit",
- Shortcut: "ctrl+c",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(QuitMsg{})
- },
- },
- }...)
-}
-
-func (c *commandDialogCmp) ID() dialogs.DialogID {
- return CommandsDialogID
-}
@@ -1,133 +0,0 @@
-package commands
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-type CommandsDialogKeyMap struct {
- Select,
- Next,
- Previous,
- Tab,
- Close key.Binding
-}
-
-func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
- return CommandsDialogKeyMap{
- Select: key.NewBinding(
- key.WithKeys("enter", "ctrl+y"),
- key.WithHelp("enter", "confirm"),
- ),
- Next: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("β", "next item"),
- ),
- Previous: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("β", "previous item"),
- ),
- Tab: key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "switch selection"),
- ),
- Close: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k CommandsDialogKeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.Select,
- k.Next,
- k.Previous,
- k.Tab,
- k.Close,
- }
-}
-
-// FullHelp implements help.KeyMap.
-func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding {
- m := [][]key.Binding{}
- slice := k.KeyBindings()
- for i := 0; i < len(slice); i += 4 {
- end := min(i+4, len(slice))
- m = append(m, slice[i:end])
- }
- return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
- return []key.Binding{
- k.Tab,
- key.NewBinding(
- key.WithKeys("down", "up"),
- key.WithHelp("ββ", "choose"),
- ),
- k.Select,
- k.Close,
- }
-}
-
-type ArgumentsDialogKeyMap struct {
- Confirm key.Binding
- Next key.Binding
- Previous key.Binding
- Close key.Binding
-}
-
-func DefaultArgumentsDialogKeyMap() ArgumentsDialogKeyMap {
- return ArgumentsDialogKeyMap{
- Confirm: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "confirm"),
- ),
-
- Next: key.NewBinding(
- key.WithKeys("tab", "down"),
- key.WithHelp("tab/β", "next"),
- ),
- Previous: key.NewBinding(
- key.WithKeys("shift+tab", "up"),
- key.WithHelp("shift+tab/β", "previous"),
- ),
- Close: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k ArgumentsDialogKeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.Confirm,
- k.Next,
- k.Previous,
- k.Close,
- }
-}
-
-// FullHelp implements help.KeyMap.
-func (k ArgumentsDialogKeyMap) FullHelp() [][]key.Binding {
- m := [][]key.Binding{}
- slice := k.KeyBindings()
- for i := 0; i < len(slice); i += 4 {
- end := min(i+4, len(slice))
- m = append(m, slice[i:end])
- }
- return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k ArgumentsDialogKeyMap) ShortHelp() []key.Binding {
- return []key.Binding{
- k.Confirm,
- k.Next,
- k.Previous,
- k.Close,
- }
-}
@@ -1,281 +0,0 @@
-// Package copilot provides the dialog for Copilot device flow authentication.
-package copilot
-
-import (
- "context"
- "fmt"
- "time"
-
- "charm.land/bubbles/v2/spinner"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/oauth"
- "github.com/charmbracelet/crush/internal/oauth/copilot"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/pkg/browser"
-)
-
-// DeviceFlowState represents the current state of the device flow.
-type DeviceFlowState int
-
-const (
- DeviceFlowStateDisplay DeviceFlowState = iota
- DeviceFlowStateSuccess
- DeviceFlowStateError
- DeviceFlowStateUnavailable
-)
-
-// DeviceAuthInitiatedMsg is sent when the device auth is initiated
-// successfully.
-type DeviceAuthInitiatedMsg struct {
- deviceCode *copilot.DeviceCode
-}
-
-// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
-type DeviceFlowCompletedMsg struct {
- Token *oauth.Token
-}
-
-// DeviceFlowErrorMsg is sent when the device flow encounters an error.
-type DeviceFlowErrorMsg struct {
- Error error
-}
-
-// DeviceFlow handles the Copilot device flow authentication.
-type DeviceFlow struct {
- State DeviceFlowState
- width int
- deviceCode *copilot.DeviceCode
- token *oauth.Token
- cancelFunc context.CancelFunc
- spinner spinner.Model
-}
-
-// NewDeviceFlow creates a new device flow component.
-func NewDeviceFlow() *DeviceFlow {
- s := spinner.New()
- s.Spinner = spinner.Dot
- s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
- return &DeviceFlow{
- State: DeviceFlowStateDisplay,
- spinner: s,
- }
-}
-
-// Init initializes the device flow by calling the device auth API and starting polling.
-func (d *DeviceFlow) Init() tea.Cmd {
- return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
-}
-
-// Update handles messages and state transitions.
-func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- var cmd tea.Cmd
- d.spinner, cmd = d.spinner.Update(msg)
-
- switch msg := msg.(type) {
- case DeviceAuthInitiatedMsg:
- return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
- case DeviceFlowCompletedMsg:
- d.State = DeviceFlowStateSuccess
- d.token = msg.Token
- return d, nil
- case DeviceFlowErrorMsg:
- switch msg.Error {
- case copilot.ErrNotAvailable:
- d.State = DeviceFlowStateUnavailable
- default:
- d.State = DeviceFlowStateError
- }
- return d, nil
- }
-
- return d, cmd
-}
-
-// View renders the device flow dialog.
-func (d *DeviceFlow) View() string {
- t := styles.CurrentTheme()
-
- whiteStyle := lipgloss.NewStyle().Foreground(t.White)
- primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
- greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
- linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
- errorStyle := lipgloss.NewStyle().Foreground(t.Error)
- mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
-
- switch d.State {
- case DeviceFlowStateDisplay:
- if d.deviceCode == nil {
- return lipgloss.NewStyle().
- Margin(0, 1).
- Render(
- greenStyle.Render(d.spinner.View()) +
- mutedStyle.Render("Initializing..."),
- )
- }
-
- instructions := lipgloss.NewStyle().
- Margin(1, 1, 0, 1).
- Width(d.width - 2).
- Render(
- whiteStyle.Render("Press ") +
- primaryStyle.Render("enter") +
- whiteStyle.Render(" to copy the code below and open the browser."),
- )
-
- codeBox := lipgloss.NewStyle().
- Width(d.width-2).
- Height(7).
- Align(lipgloss.Center, lipgloss.Center).
- Background(t.BgBaseLighter).
- Margin(1).
- Render(
- lipgloss.NewStyle().
- Bold(true).
- Foreground(t.White).
- Render(d.deviceCode.UserCode),
- )
-
- uri := d.deviceCode.VerificationURI
- link := lipgloss.NewStyle().Hyperlink(uri, "id=copilot-verify").Render(uri)
- url := mutedStyle.
- Margin(0, 1).
- Width(d.width - 2).
- Render("Browser not opening? Refer to\n" + link)
-
- waiting := greenStyle.
- Width(d.width-2).
- Margin(1, 1, 0, 1).
- Render(d.spinner.View() + "Verifying...")
-
- return lipgloss.JoinVertical(
- lipgloss.Left,
- instructions,
- codeBox,
- url,
- waiting,
- )
-
- case DeviceFlowStateSuccess:
- return greenStyle.Margin(0, 1).Render("Authentication successful!")
-
- case DeviceFlowStateError:
- return lipgloss.NewStyle().
- Margin(0, 1).
- Width(d.width - 2).
- Render(errorStyle.Render("Authentication failed."))
-
- case DeviceFlowStateUnavailable:
- message := lipgloss.NewStyle().
- Margin(0, 1).
- Width(d.width - 2).
- Render("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
- freeMessage := lipgloss.NewStyle().
- Margin(0, 1).
- Width(d.width - 2).
- Render("You may be able to request free access if eligible. For more information, see:")
- return lipgloss.JoinVertical(
- lipgloss.Left,
- message,
- "",
- linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.SignupURL, "id=copilot-signup").Render(copilot.SignupURL),
- "",
- freeMessage,
- "",
- linkStyle.Margin(0, 1).Width(d.width-2).Hyperlink(copilot.FreeURL, "id=copilot-free").Render(copilot.FreeURL),
- )
-
- default:
- return ""
- }
-}
-
-// SetWidth sets the width of the dialog.
-func (d *DeviceFlow) SetWidth(w int) {
- d.width = w
-}
-
-// Cursor hides the cursor.
-func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
-
-// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
-func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
- switch d.State {
- case DeviceFlowStateDisplay:
- return tea.Sequence(
- tea.SetClipboard(d.deviceCode.UserCode),
- func() tea.Msg {
- if err := browser.OpenURL(d.deviceCode.VerificationURI); err != nil {
- return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
- }
- return nil
- },
- util.ReportInfo("Code copied and URL opened"),
- )
- case DeviceFlowStateUnavailable:
- return tea.Sequence(
- func() tea.Msg {
- if err := browser.OpenURL(copilot.SignupURL); err != nil {
- return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
- }
- return nil
- },
- util.ReportInfo("Code copied and URL opened"),
- )
- default:
- return nil
- }
-}
-
-// CopyCode copies just the user code to the clipboard.
-func (d *DeviceFlow) CopyCode() tea.Cmd {
- if d.State != DeviceFlowStateDisplay {
- return nil
- }
- return tea.Sequence(
- tea.SetClipboard(d.deviceCode.UserCode),
- util.ReportInfo("Code copied to clipboard"),
- )
-}
-
-// Cancel cancels the device flow polling.
-func (d *DeviceFlow) Cancel() {
- if d.cancelFunc != nil {
- d.cancelFunc()
- }
-}
-
-func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- deviceCode, err := copilot.RequestDeviceCode(ctx)
- if err != nil {
- return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
- }
-
- d.deviceCode = deviceCode
-
- return DeviceAuthInitiatedMsg{
- deviceCode: d.deviceCode,
- }
-}
-
-// startPolling starts polling for the device token.
-func (d *DeviceFlow) startPolling(deviceCode *copilot.DeviceCode) tea.Cmd {
- return func() tea.Msg {
- ctx, cancel := context.WithCancel(context.Background())
- d.cancelFunc = cancel
-
- token, err := copilot.PollForToken(ctx, deviceCode)
- if err != nil {
- if ctx.Err() != nil {
- return nil // cancelled, don't report error.
- }
- return DeviceFlowErrorMsg{Error: err}
- }
-
- return DeviceFlowCompletedMsg{Token: token}
- }
-}
@@ -1,165 +0,0 @@
-package dialogs
-
-import (
- "slices"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type DialogID string
-
-// DialogModel represents a dialog component that can be displayed.
-type DialogModel interface {
- util.Model
- Position() (int, int)
- ID() DialogID
-}
-
-// CloseCallback allows dialogs to perform cleanup when closed.
-type CloseCallback interface {
- Close() tea.Cmd
-}
-
-// OpenDialogMsg is sent to open a new dialog with specified dimensions.
-type OpenDialogMsg struct {
- Model DialogModel
-}
-
-// CloseDialogMsg is sent to close the topmost dialog.
-type CloseDialogMsg struct{}
-
-// DialogCmp manages a stack of dialogs with keyboard navigation.
-type DialogCmp interface {
- util.Model
-
- Dialogs() []DialogModel
- HasDialogs() bool
- GetLayers() []*lipgloss.Layer
- ActiveModel() util.Model
- ActiveDialogID() DialogID
-}
-
-type dialogCmp struct {
- width, height int
- dialogs []DialogModel
- idMap map[DialogID]int
- keyMap KeyMap
-}
-
-// NewDialogCmp creates a new dialog manager.
-func NewDialogCmp() DialogCmp {
- return dialogCmp{
- dialogs: []DialogModel{},
- keyMap: DefaultKeyMap(),
- idMap: make(map[DialogID]int),
- }
-}
-
-func (d dialogCmp) Init() tea.Cmd {
- return nil
-}
-
-// Update handles dialog lifecycle and forwards messages to the active dialog.
-func (d dialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- var cmds []tea.Cmd
- d.width = msg.Width
- d.height = msg.Height
- for i := range d.dialogs {
- u, cmd := d.dialogs[i].Update(msg)
- d.dialogs[i] = u.(DialogModel)
- cmds = append(cmds, cmd)
- }
- return d, tea.Batch(cmds...)
- case OpenDialogMsg:
- return d.handleOpen(msg)
- case CloseDialogMsg:
- if len(d.dialogs) == 0 {
- return d, nil
- }
- inx := len(d.dialogs) - 1
- dialog := d.dialogs[inx]
- delete(d.idMap, dialog.ID())
- d.dialogs = d.dialogs[:len(d.dialogs)-1]
- if closeable, ok := dialog.(CloseCallback); ok {
- return d, closeable.Close()
- }
- return d, nil
- }
- if d.HasDialogs() {
- lastIndex := len(d.dialogs) - 1
- u, cmd := d.dialogs[lastIndex].Update(msg)
- d.dialogs[lastIndex] = u.(DialogModel)
- return d, cmd
- }
- return d, nil
-}
-
-func (d dialogCmp) View() string {
- return ""
-}
-
-func (d dialogCmp) handleOpen(msg OpenDialogMsg) (util.Model, tea.Cmd) {
- if d.HasDialogs() {
- dialog := d.dialogs[len(d.dialogs)-1]
- if dialog.ID() == msg.Model.ID() {
- return d, nil // Do not open a dialog if it's already the topmost one
- }
- if dialog.ID() == "quit" {
- return d, nil // Do not open dialogs on top of quit
- }
- }
- // if the dialog is already in the stack make it the last item
- if _, ok := d.idMap[msg.Model.ID()]; ok {
- existing := d.dialogs[d.idMap[msg.Model.ID()]]
- // Reuse the model so we keep the state
- msg.Model = existing
- d.dialogs = slices.Delete(d.dialogs, d.idMap[msg.Model.ID()], d.idMap[msg.Model.ID()]+1)
- }
- d.idMap[msg.Model.ID()] = len(d.dialogs)
- d.dialogs = append(d.dialogs, msg.Model)
- var cmds []tea.Cmd
- cmd := msg.Model.Init()
- cmds = append(cmds, cmd)
- _, cmd = msg.Model.Update(tea.WindowSizeMsg{
- Width: d.width,
- Height: d.height,
- })
- cmds = append(cmds, cmd)
- return d, tea.Batch(cmds...)
-}
-
-func (d dialogCmp) Dialogs() []DialogModel {
- return d.dialogs
-}
-
-func (d dialogCmp) ActiveModel() util.Model {
- if len(d.dialogs) == 0 {
- return nil
- }
- return d.dialogs[len(d.dialogs)-1]
-}
-
-func (d dialogCmp) ActiveDialogID() DialogID {
- if len(d.dialogs) == 0 {
- return ""
- }
- return d.dialogs[len(d.dialogs)-1].ID()
-}
-
-func (d dialogCmp) GetLayers() []*lipgloss.Layer {
- layers := []*lipgloss.Layer{}
- for _, dialog := range d.Dialogs() {
- dialogView := dialog.View()
- row, col := dialog.Position()
- layers = append(layers, lipgloss.NewLayer(dialogView).X(col).Y(row))
- }
- return layers
-}
-
-func (d dialogCmp) HasDialogs() bool {
- return len(d.dialogs) > 0
-}
@@ -1,260 +0,0 @@
-package filepicker
-
-import (
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "strings"
-
- "charm.land/bubbles/v2/filepicker"
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/home"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/components/image"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const (
- MaxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
- FilePickerID = "filepicker"
- fileSelectionHeight = 10
- previewHeight = 20
-)
-
-type FilePickedMsg struct {
- Attachment message.Attachment
-}
-
-type FilePicker interface {
- dialogs.DialogModel
-}
-
-type model struct {
- wWidth int
- wHeight int
- width int
- filePicker filepicker.Model
- highlightedFile string
- image image.Model
- keyMap KeyMap
- help help.Model
-}
-
-var AllowedTypes = []string{".jpg", ".jpeg", ".png"}
-
-func NewFilePickerCmp(workingDir string) FilePicker {
- t := styles.CurrentTheme()
- fp := filepicker.New()
- fp.AllowedTypes = AllowedTypes
-
- if workingDir != "" {
- fp.CurrentDirectory = workingDir
- } else {
- // Fallback to current working directory, then home directory
- if cwd, err := os.Getwd(); err == nil {
- fp.CurrentDirectory = cwd
- } else {
- fp.CurrentDirectory = home.Dir()
- }
- }
-
- fp.ShowPermissions = false
- fp.ShowSize = false
- fp.AutoHeight = false
- fp.Styles = t.S().FilePicker
- fp.Cursor = ""
- fp.SetHeight(fileSelectionHeight)
-
- image := image.New(1, 1, "")
-
- help := help.New()
- help.Styles = t.S().Help
- return &model{
- filePicker: fp,
- image: image,
- keyMap: DefaultKeyMap(),
- help: help,
- }
-}
-
-func (m *model) Init() tea.Cmd {
- return m.filePicker.Init()
-}
-
-func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.wWidth = msg.Width
- m.wHeight = msg.Height
- m.width = min(70, m.wWidth)
- styles := m.filePicker.Styles
- styles.Directory = styles.Directory.Width(m.width - 4)
- styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4)
- styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4)
- styles.File = styles.File.Width(m.width)
- m.filePicker.Styles = styles
- return m, nil
- case tea.KeyPressMsg:
- if key.Matches(msg, m.keyMap.Close) {
- return m, util.CmdHandler(dialogs.CloseDialogMsg{})
- }
- if key.Matches(msg, m.filePicker.KeyMap.Back) {
- // make sure we don't go back if we are at the home directory
- if m.filePicker.CurrentDirectory == home.Dir() {
- return m, nil
- }
- }
- }
-
- var cmd tea.Cmd
- var cmds []tea.Cmd
- m.filePicker, cmd = m.filePicker.Update(msg)
- cmds = append(cmds, cmd)
- if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
- w, h := m.imagePreviewSize()
- cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage())
- cmds = append(cmds, cmd)
- }
- m.highlightedFile = m.currentImage()
-
- // Did the user select a file?
- if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect {
- // Get the path of the selected file.
- return m, tea.Sequence(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- func() tea.Msg {
- isFileLarge, err := IsFileTooBig(path, MaxAttachmentSize)
- if err != nil {
- return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
- }
- if isFileLarge {
- return util.ReportError(fmt.Errorf("file too large, max 5MB"))
- }
-
- content, err := os.ReadFile(path)
- if err != nil {
- return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
- }
-
- mimeBufferSize := min(512, len(content))
- mimeType := http.DetectContentType(content[:mimeBufferSize])
- fileName := filepath.Base(path)
- attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
- return FilePickedMsg{
- Attachment: attachment,
- }
- },
- )
- }
- m.image, cmd = m.image.Update(msg)
- cmds = append(cmds, cmd)
- return m, tea.Batch(cmds...)
-}
-
-func (m *model) View() string {
- t := styles.CurrentTheme()
-
- strs := []string{
- t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)),
- }
-
- // hide image preview if the terminal is too small
- if x, y := m.imagePreviewSize(); x > 0 && y > 0 {
- strs = append(strs, m.imagePreview())
- }
-
- strs = append(
- strs,
- m.filePicker.View(),
- t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
- )
-
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- strs...,
- )
- return m.style().Render(content)
-}
-
-func (m *model) currentImage() string {
- for _, ext := range m.filePicker.AllowedTypes {
- if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) {
- return m.filePicker.HighlightedPath()
- }
- }
- return ""
-}
-
-func (m *model) imagePreview() string {
- const padding = 2
-
- t := styles.CurrentTheme()
- w, h := m.imagePreviewSize()
-
- if m.currentImage() == "" {
- imgPreview := t.S().Base.
- Width(w - padding).
- Height(h - padding).
- Background(t.BgOverlay)
-
- return m.imagePreviewStyle().Render(imgPreview.Render())
- }
-
- return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
-}
-
-func (m *model) imagePreviewStyle() lipgloss.Style {
- t := styles.CurrentTheme()
- return t.S().Base.Padding(1, 1, 1, 1)
-}
-
-func (m *model) imagePreviewSize() (int, int) {
- if m.wHeight-fileSelectionHeight-8 > previewHeight {
- return m.width - 4, previewHeight
- }
- return 0, 0
-}
-
-func (m *model) style() lipgloss.Style {
- t := styles.CurrentTheme()
- return t.S().Base.
- Width(m.width).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus)
-}
-
-// ID implements FilePicker.
-func (m *model) ID() dialogs.DialogID {
- return FilePickerID
-}
-
-// Position implements FilePicker.
-func (m *model) Position() (int, int) {
- _, imageHeight := m.imagePreviewSize()
- dialogHeight := fileSelectionHeight + imageHeight + 4
- row := (m.wHeight - dialogHeight) / 2
-
- col := m.wWidth / 2
- col -= m.width / 2
- return row, col
-}
-
-func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
- fileInfo, err := os.Stat(filePath)
- if err != nil {
- return false, fmt.Errorf("error getting file info: %w", err)
- }
-
- if fileInfo.Size() > sizeLimit {
- return true, nil
- }
-
- return false, nil
-}
@@ -1,80 +0,0 @@
-package filepicker
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-// KeyMap defines keyboard bindings for dialog management.
-type KeyMap struct {
- Select,
- Down,
- Up,
- Forward,
- Backward,
- Close key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- Select: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "accept"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down", "j"),
- key.WithHelp("down/j", "move down"),
- ),
- Up: key.NewBinding(
- key.WithKeys("up", "k"),
- key.WithHelp("up/k", "move up"),
- ),
- Forward: key.NewBinding(
- key.WithKeys("right", "l"),
- key.WithHelp("right/l", "move forward"),
- ),
- Backward: key.NewBinding(
- key.WithKeys("left", "h"),
- key.WithHelp("left/h", "move backward"),
- ),
-
- Close: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "close/exit"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.Select,
- k.Down,
- k.Up,
- k.Forward,
- k.Backward,
- k.Close,
- }
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
- m := [][]key.Binding{}
- slice := k.KeyBindings()
- for i := 0; i < len(slice); i += 4 {
- end := min(i+4, len(slice))
- m = append(m, slice[i:end])
- }
- return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
- return []key.Binding{
- key.NewBinding(
- key.WithKeys("right", "l", "left", "h", "up", "k", "down", "j"),
- key.WithHelp("ββββ", "navigate"),
- ),
- k.Select,
- k.Close,
- }
-}
@@ -1,267 +0,0 @@
-// Package hyper provides the dialog for Hyper device flow authentication.
-package hyper
-
-import (
- "context"
- "fmt"
- "time"
-
- "charm.land/bubbles/v2/spinner"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/oauth"
- "github.com/charmbracelet/crush/internal/oauth/hyper"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/pkg/browser"
-)
-
-// DeviceFlowState represents the current state of the device flow.
-type DeviceFlowState int
-
-const (
- DeviceFlowStateDisplay DeviceFlowState = iota
- DeviceFlowStateSuccess
- DeviceFlowStateError
-)
-
-// DeviceAuthInitiatedMsg is sent when the device auth is initiated
-// successfully.
-type DeviceAuthInitiatedMsg struct {
- deviceCode string
- expiresIn int
-}
-
-// DeviceFlowCompletedMsg is sent when the device flow completes successfully.
-type DeviceFlowCompletedMsg struct {
- Token *oauth.Token
-}
-
-// DeviceFlowErrorMsg is sent when the device flow encounters an error.
-type DeviceFlowErrorMsg struct {
- Error error
-}
-
-// DeviceFlow handles the Hyper device flow authentication.
-type DeviceFlow struct {
- State DeviceFlowState
- width int
- deviceCode string
- userCode string
- verificationURL string
- expiresIn int
- token *oauth.Token
- cancelFunc context.CancelFunc
- spinner spinner.Model
-}
-
-// NewDeviceFlow creates a new device flow component.
-func NewDeviceFlow() *DeviceFlow {
- s := spinner.New()
- s.Spinner = spinner.Dot
- s.Style = lipgloss.NewStyle().Foreground(styles.CurrentTheme().GreenLight)
- return &DeviceFlow{
- State: DeviceFlowStateDisplay,
- spinner: s,
- }
-}
-
-// Init initializes the device flow by calling the device auth API and starting polling.
-func (d *DeviceFlow) Init() tea.Cmd {
- return tea.Batch(d.spinner.Tick, d.initiateDeviceAuth)
-}
-
-// Update handles messages and state transitions.
-func (d *DeviceFlow) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- var cmd tea.Cmd
- d.spinner, cmd = d.spinner.Update(msg)
-
- switch msg := msg.(type) {
- case DeviceAuthInitiatedMsg:
- // Start polling now that we have the device code.
- d.expiresIn = msg.expiresIn
- return d, tea.Batch(cmd, d.startPolling(msg.deviceCode))
- case DeviceFlowCompletedMsg:
- d.State = DeviceFlowStateSuccess
- d.token = msg.Token
- return d, nil
- case DeviceFlowErrorMsg:
- d.State = DeviceFlowStateError
- return d, util.ReportError(msg.Error)
- }
-
- return d, cmd
-}
-
-// View renders the device flow dialog.
-func (d *DeviceFlow) View() string {
- t := styles.CurrentTheme()
-
- whiteStyle := lipgloss.NewStyle().Foreground(t.White)
- primaryStyle := lipgloss.NewStyle().Foreground(t.Primary)
- greenStyle := lipgloss.NewStyle().Foreground(t.GreenLight)
- linkStyle := lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true)
- errorStyle := lipgloss.NewStyle().Foreground(t.Error)
- mutedStyle := lipgloss.NewStyle().Foreground(t.FgMuted)
-
- switch d.State {
- case DeviceFlowStateDisplay:
- if d.userCode == "" {
- return lipgloss.NewStyle().
- Margin(0, 1).
- Render(
- greenStyle.Render(d.spinner.View()) +
- mutedStyle.Render("Initializing..."),
- )
- }
-
- instructions := lipgloss.NewStyle().
- Margin(1, 1, 0, 1).
- Width(d.width - 2).
- Render(
- whiteStyle.Render("Press ") +
- primaryStyle.Render("enter") +
- whiteStyle.Render(" to copy the code below and open the browser."),
- )
-
- codeBox := lipgloss.NewStyle().
- Width(d.width-2).
- Height(7).
- Align(lipgloss.Center, lipgloss.Center).
- Background(t.BgBaseLighter).
- Margin(1).
- Render(
- lipgloss.NewStyle().
- Bold(true).
- Foreground(t.White).
- Render(d.userCode),
- )
-
- link := linkStyle.Hyperlink(d.verificationURL, "id=hyper-verify").Render(d.verificationURL)
- url := mutedStyle.
- Margin(0, 1).
- Width(d.width - 2).
- Render("Browser not opening? Refer to\n" + link)
-
- waiting := greenStyle.
- Width(d.width-2).
- Margin(1, 1, 0, 1).
- Render(d.spinner.View() + "Verifying...")
-
- return lipgloss.JoinVertical(
- lipgloss.Left,
- instructions,
- codeBox,
- url,
- waiting,
- )
-
- case DeviceFlowStateSuccess:
- return greenStyle.Margin(0, 1).Render("Authentication successful!")
-
- case DeviceFlowStateError:
- return lipgloss.NewStyle().
- Margin(0, 1).
- Width(d.width - 2).
- Render(errorStyle.Render("Authentication failed."))
-
- default:
- return ""
- }
-}
-
-// SetWidth sets the width of the dialog.
-func (d *DeviceFlow) SetWidth(w int) {
- d.width = w
-}
-
-// Cursor hides the cursor.
-func (d *DeviceFlow) Cursor() *tea.Cursor { return nil }
-
-// CopyCodeAndOpenURL copies the user code to the clipboard and opens the URL.
-func (d *DeviceFlow) CopyCodeAndOpenURL() tea.Cmd {
- if d.State != DeviceFlowStateDisplay {
- return nil
- }
- return tea.Sequence(
- tea.SetClipboard(d.userCode),
- func() tea.Msg {
- if err := browser.OpenURL(d.verificationURL); err != nil {
- return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to open browser: %w", err)}
- }
- return nil
- },
- util.ReportInfo("Code copied and URL opened"),
- )
-}
-
-// CopyCode copies just the user code to the clipboard.
-func (d *DeviceFlow) CopyCode() tea.Cmd {
- if d.State != DeviceFlowStateDisplay {
- return nil
- }
- return tea.Sequence(
- tea.SetClipboard(d.userCode),
- util.ReportInfo("Code copied to clipboard"),
- )
-}
-
-// Cancel cancels the device flow polling.
-func (d *DeviceFlow) Cancel() {
- if d.cancelFunc != nil {
- d.cancelFunc()
- }
-}
-
-func (d *DeviceFlow) initiateDeviceAuth() tea.Msg {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- authResp, err := hyper.InitiateDeviceAuth(ctx)
- if err != nil {
- return DeviceFlowErrorMsg{Error: fmt.Errorf("failed to initiate device auth: %w", err)}
- }
-
- d.deviceCode = authResp.DeviceCode
- d.userCode = authResp.UserCode
- d.verificationURL = authResp.VerificationURL
-
- return DeviceAuthInitiatedMsg{
- deviceCode: authResp.DeviceCode,
- expiresIn: authResp.ExpiresIn,
- }
-}
-
-// startPolling starts polling for the device token.
-func (d *DeviceFlow) startPolling(deviceCode string) tea.Cmd {
- return func() tea.Msg {
- ctx, cancel := context.WithCancel(context.Background())
- d.cancelFunc = cancel
-
- // Poll for refresh token.
- refreshToken, err := hyper.PollForToken(ctx, deviceCode, d.expiresIn)
- if err != nil {
- if ctx.Err() != nil {
- // Cancelled, don't report error.
- return nil
- }
- return DeviceFlowErrorMsg{Error: err}
- }
-
- // Exchange refresh token for access token.
- token, err := hyper.ExchangeToken(ctx, refreshToken)
- if err != nil {
- return DeviceFlowErrorMsg{Error: fmt.Errorf("token exchange failed: %w", err)}
- }
-
- // Verify the access token works.
- introspect, err := hyper.IntrospectToken(ctx, token.AccessToken)
- if err != nil {
- return DeviceFlowErrorMsg{Error: fmt.Errorf("token introspection failed: %w", err)}
- }
- if !introspect.Active {
- return DeviceFlowErrorMsg{Error: fmt.Errorf("access token is not active")}
- }
-
- return DeviceFlowCompletedMsg{Token: token}
- }
-}
@@ -1,43 +0,0 @@
-package dialogs
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-// KeyMap defines keyboard bindings for dialog management.
-type KeyMap struct {
- Close key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- Close: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.Close,
- }
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
- m := [][]key.Binding{}
- slice := k.KeyBindings()
- for i := 0; i < len(slice); i += 4 {
- end := min(i+4, len(slice))
- m = append(m, slice[i:end])
- }
- return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
- return []key.Binding{
- k.Close,
- }
-}
@@ -1,203 +0,0 @@
-package models
-
-import (
- "fmt"
-
- "charm.land/bubbles/v2/spinner"
- "charm.land/bubbles/v2/textinput"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/home"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type APIKeyInputState int
-
-const (
- APIKeyInputStateInitial APIKeyInputState = iota
- APIKeyInputStateVerifying
- APIKeyInputStateVerified
- APIKeyInputStateError
-)
-
-type APIKeyStateChangeMsg struct {
- State APIKeyInputState
-}
-
-type APIKeyInput struct {
- input textinput.Model
- width int
- spinner spinner.Model
- providerName string
- state APIKeyInputState
- title string
- showTitle bool
-}
-
-func NewAPIKeyInput() *APIKeyInput {
- t := styles.CurrentTheme()
-
- ti := textinput.New()
- ti.Placeholder = "Enter your API key..."
- ti.SetVirtualCursor(false)
- ti.Prompt = "> "
- ti.SetStyles(t.S().TextInput)
- ti.Focus()
-
- return &APIKeyInput{
- input: ti,
- state: APIKeyInputStateInitial,
- spinner: spinner.New(
- spinner.WithSpinner(spinner.Dot),
- spinner.WithStyle(t.S().Base.Foreground(t.Green)),
- ),
- providerName: "Provider",
- showTitle: true,
- }
-}
-
-func (a *APIKeyInput) SetProviderName(name string) {
- a.providerName = name
- a.updateStatePresentation()
-}
-
-func (a *APIKeyInput) SetShowTitle(show bool) {
- a.showTitle = show
-}
-
-func (a *APIKeyInput) GetTitle() string {
- return a.title
-}
-
-func (a *APIKeyInput) Init() tea.Cmd {
- a.updateStatePresentation()
- return a.spinner.Tick
-}
-
-func (a *APIKeyInput) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case spinner.TickMsg:
- if a.state == APIKeyInputStateVerifying {
- var cmd tea.Cmd
- a.spinner, cmd = a.spinner.Update(msg)
- a.updateStatePresentation()
- return a, cmd
- }
- return a, nil
- case APIKeyStateChangeMsg:
- a.state = msg.State
- var cmd tea.Cmd
- if msg.State == APIKeyInputStateVerifying {
- cmd = a.spinner.Tick
- }
- a.updateStatePresentation()
- return a, cmd
- }
-
- var cmd tea.Cmd
- a.input, cmd = a.input.Update(msg)
- return a, cmd
-}
-
-func (a *APIKeyInput) updateStatePresentation() {
- t := styles.CurrentTheme()
-
- prefixStyle := t.S().Base.
- Foreground(t.Primary)
- accentStyle := t.S().Base.Foreground(t.Green).Bold(true)
- errorStyle := t.S().Base.Foreground(t.Cherry)
-
- switch a.state {
- case APIKeyInputStateInitial:
- titlePrefix := prefixStyle.Render("Enter your ")
- a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(".")
- a.input.SetStyles(t.S().TextInput)
- a.input.Prompt = "> "
- case APIKeyInputStateVerifying:
- titlePrefix := prefixStyle.Render("Verifying your ")
- a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render("...")
- ts := t.S().TextInput
- // make the blurred state be the same
- ts.Blurred.Prompt = ts.Focused.Prompt
- a.input.Prompt = a.spinner.View()
- a.input.Blur()
- case APIKeyInputStateVerified:
- a.title = accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(" validated.")
- ts := t.S().TextInput
- // make the blurred state be the same
- ts.Blurred.Prompt = ts.Focused.Prompt
- a.input.SetStyles(ts)
- a.input.Prompt = styles.CheckIcon + " "
- a.input.Blur()
- case APIKeyInputStateError:
- a.title = errorStyle.Render("Invalid ") + accentStyle.Render(a.providerName+" API Key") + errorStyle.Render(". Try again?")
- ts := t.S().TextInput
- ts.Focused.Prompt = ts.Focused.Prompt.Foreground(t.Cherry)
- a.input.Focus()
- a.input.SetStyles(ts)
- a.input.Prompt = styles.ErrorIcon + " "
- }
-}
-
-func (a *APIKeyInput) View() string {
- inputView := a.input.View()
-
- dataPath := config.GlobalConfigData()
- dataPath = home.Short(dataPath)
- helpText := styles.CurrentTheme().S().Muted.
- Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))
-
- var content string
- if a.showTitle && a.title != "" {
- content = lipgloss.JoinVertical(
- lipgloss.Left,
- a.title,
- "",
- inputView,
- "",
- helpText,
- )
- } else {
- content = lipgloss.JoinVertical(
- lipgloss.Left,
- inputView,
- "",
- helpText,
- )
- }
-
- return content
-}
-
-func (a *APIKeyInput) Cursor() *tea.Cursor {
- cursor := a.input.Cursor()
- if cursor != nil && a.showTitle {
- cursor.Y += 2 // Adjust for title and spacing
- }
- return cursor
-}
-
-func (a *APIKeyInput) Value() string {
- return a.input.Value()
-}
-
-func (a *APIKeyInput) Tick() tea.Cmd {
- if a.state == APIKeyInputStateVerifying {
- return a.spinner.Tick
- }
- return nil
-}
-
-func (a *APIKeyInput) SetWidth(width int) {
- a.width = width
- a.input.SetWidth(width - 4)
-}
-
-func (a *APIKeyInput) Reset() {
- a.state = APIKeyInputStateInitial
- a.input.SetValue("")
- a.input.Focus()
- a.updateStatePresentation()
-}
@@ -1,120 +0,0 @@
-package models
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
- Select,
- Next,
- Previous,
- Choose,
- Tab,
- Close key.Binding
-
- isAPIKeyHelp bool
- isAPIKeyValid bool
-
- isHyperDeviceFlow bool
- isCopilotDeviceFlow bool
- isCopilotUnavailable bool
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- Select: key.NewBinding(
- key.WithKeys("enter", "ctrl+y"),
- key.WithHelp("enter", "choose"),
- ),
- Next: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("β", "next item"),
- ),
- Previous: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("β", "previous item"),
- ),
- Choose: key.NewBinding(
- key.WithKeys("left", "right", "h", "l"),
- key.WithHelp("ββ", "choose"),
- ),
- Tab: key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "toggle type"),
- ),
- Close: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "exit"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.Select,
- k.Next,
- k.Previous,
- k.Tab,
- k.Close,
- }
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
- m := [][]key.Binding{}
- slice := k.KeyBindings()
- for i := 0; i < len(slice); i += 4 {
- end := min(i+4, len(slice))
- m = append(m, slice[i:end])
- }
- return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
- if k.isHyperDeviceFlow || k.isCopilotDeviceFlow {
- return []key.Binding{
- key.NewBinding(
- key.WithKeys("c"),
- key.WithHelp("c", "copy code"),
- ),
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "copy & open"),
- ),
- k.Close,
- }
- }
- if k.isCopilotUnavailable {
- return []key.Binding{
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "open signup"),
- ),
- k.Close,
- }
- }
- if k.isAPIKeyHelp && !k.isAPIKeyValid {
- return []key.Binding{
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "submit"),
- ),
- k.Close,
- }
- } else if k.isAPIKeyValid {
- return []key.Binding{
- k.Select,
- }
- }
- return []key.Binding{
- key.NewBinding(
- key.WithKeys("down", "up"),
- key.WithHelp("ββ", "choose"),
- ),
- k.Tab,
- k.Select,
- k.Close,
- }
-}
@@ -1,333 +0,0 @@
-package models
-
-import (
- "cmp"
- "fmt"
- "slices"
- "strings"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/catwalk/pkg/catwalk"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]]
-
-type ModelListComponent struct {
- list listModel
- modelType int
- providers []catwalk.Provider
-}
-
-func modelKey(providerID, modelID string) string {
- if providerID == "" || modelID == "" {
- return ""
- }
- return providerID + ":" + modelID
-}
-
-func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
- t := styles.CurrentTheme()
- inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
- options := []list.ListOption{
- list.WithKeyMap(keyMap),
- list.WithWrapNavigation(),
- }
- if shouldResize {
- options = append(options, list.WithResizeByList())
- }
- modelList := list.NewFilterableGroupedList(
- []list.Group[list.CompletionItem[ModelOption]]{},
- list.WithFilterInputStyle(inputStyle),
- list.WithFilterPlaceholder(inputPlaceholder),
- list.WithFilterListOptions(
- options...,
- ),
- )
-
- return &ModelListComponent{
- list: modelList,
- modelType: LargeModelType,
- }
-}
-
-func (m *ModelListComponent) Init() tea.Cmd {
- var cmds []tea.Cmd
- if len(m.providers) == 0 {
- cfg := config.Get()
- providers, err := config.Providers(cfg)
- filteredProviders := []catwalk.Provider{}
- for _, p := range providers {
- hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
- isHyper := p.ID == "hyper"
- isCopilot := p.ID == catwalk.InferenceProviderCopilot
- if (hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure) || isHyper || isCopilot {
- filteredProviders = append(filteredProviders, p)
- }
- }
-
- m.providers = filteredProviders
- if err != nil {
- cmds = append(cmds, util.ReportError(err))
- }
- }
- cmds = append(cmds, m.list.Init(), m.SetModelType(m.modelType))
- return tea.Batch(cmds...)
-}
-
-func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
- u, cmd := m.list.Update(msg)
- m.list = u.(listModel)
- return m, cmd
-}
-
-func (m *ModelListComponent) View() string {
- return m.list.View()
-}
-
-func (m *ModelListComponent) Cursor() *tea.Cursor {
- return m.list.Cursor()
-}
-
-func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
- return m.list.SetSize(width, height)
-}
-
-func (m *ModelListComponent) SelectedModel() *ModelOption {
- s := m.list.SelectedItem()
- if s == nil {
- return nil
- }
- sv := *s
- model := sv.Value()
- return &model
-}
-
-func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
- t := styles.CurrentTheme()
- m.modelType = modelType
-
- var groups []list.Group[list.CompletionItem[ModelOption]]
- // first none section
- selectedItemID := ""
- itemsByKey := make(map[string]list.CompletionItem[ModelOption])
-
- cfg := config.Get()
- var currentModel config.SelectedModel
- selectedType := config.SelectedModelTypeLarge
- if m.modelType == LargeModelType {
- currentModel = cfg.Models[config.SelectedModelTypeLarge]
- selectedType = config.SelectedModelTypeLarge
- } else {
- currentModel = cfg.Models[config.SelectedModelTypeSmall]
- selectedType = config.SelectedModelTypeSmall
- }
- recentItems := cfg.RecentModels[selectedType]
-
- configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon)
- configured := fmt.Sprintf("%s %s", configuredIcon, t.S().Subtle.Render("Configured"))
-
- // Create a map to track which providers we've already added
- addedProviders := make(map[string]bool)
-
- // First, add any configured providers that are not in the known providers list
- // These should appear at the top of the list
- knownProviders, err := config.Providers(cfg)
- if err != nil {
- return util.ReportError(err)
- }
- for providerID, providerConfig := range cfg.Providers.Seq2() {
- if providerConfig.Disable {
- continue
- }
-
- // Check if this provider is not in the known providers list
- if !slices.ContainsFunc(knownProviders, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) ||
- !slices.ContainsFunc(m.providers, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) {
- // Convert config provider to provider.Provider format
- configProvider := providerConfig.ToProvider()
-
- // Add this unknown provider to the list
- name := configProvider.Name
- if name == "" {
- name = string(configProvider.ID)
- }
- section := list.NewItemSection(name)
- section.SetInfo(configured)
- group := list.Group[list.CompletionItem[ModelOption]]{
- Section: section,
- }
- for _, model := range configProvider.Models {
- modelOption := ModelOption{
- Provider: configProvider,
- Model: model,
- }
- key := modelKey(string(configProvider.ID), model.ID)
- item := list.NewCompletionItem(
- model.Name,
- modelOption,
- list.WithCompletionID(key),
- )
- itemsByKey[key] = item
-
- group.Items = append(group.Items, item)
- if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
- selectedItemID = item.ID()
- }
- }
- groups = append(groups, group)
-
- addedProviders[providerID] = true
- }
- }
-
- // Move "Charm Hyper" to first position
- // (but still after recent models and custom providers).
- slices.SortStableFunc(m.providers, func(a, b catwalk.Provider) int {
- switch {
- case a.ID == "hyper":
- return -1
- case b.ID == "hyper":
- return 1
- default:
- return 0
- }
- })
-
- // Then add the known providers from the predefined list
- for _, provider := range m.providers {
- // Skip if we already added this provider as an unknown provider
- if addedProviders[string(provider.ID)] {
- continue
- }
-
- providerConfig, providerConfigured := cfg.Providers.Get(string(provider.ID))
- if providerConfigured && providerConfig.Disable {
- continue
- }
-
- displayProvider := provider
- if providerConfigured {
- displayProvider.Name = cmp.Or(providerConfig.Name, displayProvider.Name)
- modelIndex := make(map[string]int, len(displayProvider.Models))
- for i, model := range displayProvider.Models {
- modelIndex[model.ID] = i
- }
- for _, model := range providerConfig.Models {
- if model.ID == "" {
- continue
- }
- if idx, ok := modelIndex[model.ID]; ok {
- if model.Name != "" {
- displayProvider.Models[idx].Name = model.Name
- }
- continue
- }
- if model.Name == "" {
- model.Name = model.ID
- }
- displayProvider.Models = append(displayProvider.Models, model)
- modelIndex[model.ID] = len(displayProvider.Models) - 1
- }
- }
-
- name := displayProvider.Name
- if name == "" {
- name = string(displayProvider.ID)
- }
-
- section := list.NewItemSection(name)
- if providerConfigured {
- section.SetInfo(configured)
- }
- group := list.Group[list.CompletionItem[ModelOption]]{
- Section: section,
- }
- for _, model := range displayProvider.Models {
- modelOption := ModelOption{
- Provider: displayProvider,
- Model: model,
- }
- key := modelKey(string(displayProvider.ID), model.ID)
- item := list.NewCompletionItem(
- model.Name,
- modelOption,
- list.WithCompletionID(key),
- )
- itemsByKey[key] = item
- group.Items = append(group.Items, item)
- if model.ID == currentModel.Model && string(displayProvider.ID) == currentModel.Provider {
- selectedItemID = item.ID()
- }
- }
- groups = append(groups, group)
- }
-
- if len(recentItems) > 0 {
- recentSection := list.NewItemSection("Recently used")
- recentGroup := list.Group[list.CompletionItem[ModelOption]]{
- Section: recentSection,
- }
- var validRecentItems []config.SelectedModel
- for _, recent := range recentItems {
- key := modelKey(recent.Provider, recent.Model)
- option, ok := itemsByKey[key]
- if !ok {
- continue
- }
- validRecentItems = append(validRecentItems, recent)
- recentID := fmt.Sprintf("recent::%s", key)
- modelOption := option.Value()
- providerName := modelOption.Provider.Name
- if providerName == "" {
- providerName = string(modelOption.Provider.ID)
- }
- item := list.NewCompletionItem(
- modelOption.Model.Name,
- option.Value(),
- list.WithCompletionID(recentID),
- list.WithCompletionShortcut(providerName),
- )
- recentGroup.Items = append(recentGroup.Items, item)
- if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider {
- selectedItemID = recentID
- }
- }
-
- if len(validRecentItems) != len(recentItems) {
- if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
- return util.ReportError(err)
- }
- }
-
- if len(recentGroup.Items) > 0 {
- groups = append([]list.Group[list.CompletionItem[ModelOption]]{recentGroup}, groups...)
- }
- }
-
- var cmds []tea.Cmd
-
- cmd := m.list.SetGroups(groups)
-
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- cmd = m.list.SetSelected(selectedItemID)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- return tea.Sequence(cmds...)
-}
-
-// GetModelType returns the current model type
-func (m *ModelListComponent) GetModelType() int {
- return m.modelType
-}
-
-func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
- m.list.SetInputPlaceholder(placeholder)
-}
@@ -1,369 +0,0 @@
-package models
-
-import (
- "encoding/json"
- "io/fs"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/catwalk/pkg/catwalk"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/log"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/stretchr/testify/require"
-)
-
-// execCmdML runs a tea.Cmd through the ModelListComponent's Update loop.
-func execCmdML(t *testing.T, m *ModelListComponent, cmd tea.Cmd) {
- t.Helper()
- for cmd != nil {
- msg := cmd()
- var next tea.Cmd
- _, next = m.Update(msg)
- cmd = next
- }
-}
-
-// readConfigJSON reads and unmarshals the JSON config file at path.
-func readConfigJSON(t *testing.T, path string) map[string]any {
- t.Helper()
- baseDir := filepath.Dir(path)
- fileName := filepath.Base(path)
- b, err := fs.ReadFile(os.DirFS(baseDir), fileName)
- require.NoError(t, err)
- var out map[string]any
- require.NoError(t, json.Unmarshal(b, &out))
- return out
-}
-
-// readRecentModels reads the recent_models section from the config file.
-func readRecentModels(t *testing.T, path string) map[string]any {
- t.Helper()
- out := readConfigJSON(t, path)
- rm, ok := out["recent_models"].(map[string]any)
- require.True(t, ok)
- return rm
-}
-
-func TestModelList_RecentlyUsedSectionAndPrunesInvalid(t *testing.T) {
- // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
- log.Setup(os.DevNull, false)
-
- // Isolate config/data paths
- cfgDir := t.TempDir()
- dataDir := t.TempDir()
- t.Setenv("XDG_CONFIG_HOME", cfgDir)
- t.Setenv("XDG_DATA_HOME", dataDir)
-
- // Pre-seed config so provider auto-update is disabled and we have recents
- confPath := filepath.Join(cfgDir, "crush", "crush.json")
- require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
- initial := map[string]any{
- "options": map[string]any{
- "disable_provider_auto_update": true,
- },
- "models": map[string]any{
- "large": map[string]any{
- "model": "m1",
- "provider": "p1",
- },
- },
- "recent_models": map[string]any{
- "large": []any{
- map[string]any{"model": "m2", "provider": "p1"}, // valid
- map[string]any{"model": "x", "provider": "unknown-provider"}, // invalid -> pruned
- },
- },
- }
- bts, err := json.Marshal(initial)
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(confPath, bts, 0o644))
-
- // Also create empty providers.json to prevent loading real providers
- dataConfDir := filepath.Join(dataDir, "crush")
- require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
- emptyProviders := []byte("[]")
- require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
-
- // Initialize global config instance (no network due to auto-update disabled)
- _, err = config.Init(cfgDir, dataDir, false)
- require.NoError(t, err)
-
- // Build a small provider set for the list component
- provider := catwalk.Provider{
- ID: catwalk.InferenceProvider("p1"),
- Name: "Provider One",
- Models: []catwalk.Model{
- {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
- {ID: "m2", Name: "Model Two", DefaultMaxTokens: 100}, // recent
- },
- }
-
- // Create and initialize the component with our provider set
- listKeyMap := list.DefaultKeyMap()
- cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
- cmp.providers = []catwalk.Provider{provider}
- execCmdML(t, cmp, cmp.Init())
-
- // Find all recent items (IDs prefixed with "recent::") and verify pruning
- groups := cmp.list.Groups()
- require.NotEmpty(t, groups)
- var recentItems []list.CompletionItem[ModelOption]
- for _, g := range groups {
- for _, it := range g.Items {
- if strings.HasPrefix(it.ID(), "recent::") {
- recentItems = append(recentItems, it)
- }
- }
- }
- require.NotEmpty(t, recentItems, "no recent items found")
- // Ensure the valid recent (p1:m2) is present and the invalid one is not
- foundValid := false
- for _, it := range recentItems {
- if it.ID() == "recent::p1:m2" {
- foundValid = true
- }
- require.NotEqual(t, "recent::unknown-provider:x", it.ID(), "invalid recent should be pruned")
- }
- require.True(t, foundValid, "expected valid recent not found")
-
- // Verify original config in cfgDir remains unchanged
- origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
- afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
- require.NoError(t, err)
- var origParsed map[string]any
- require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
- origRM := origParsed["recent_models"].(map[string]any)
- origLarge := origRM["large"].([]any)
- require.Len(t, origLarge, 2, "original config should be unchanged")
-
- // Config should be rewritten with pruned recents in dataDir
- dataConf := filepath.Join(dataDir, "crush", "crush.json")
- rm := readRecentModels(t, dataConf)
- largeAny, ok := rm["large"].([]any)
- require.True(t, ok)
- // Ensure that only valid recent(s) remain and the invalid one is removed
- found := false
- for _, v := range largeAny {
- m := v.(map[string]any)
- require.NotEqual(t, "unknown-provider", m["provider"], "invalid provider should be pruned")
- if m["provider"] == "p1" && m["model"] == "m2" {
- found = true
- }
- }
- require.True(t, found, "persisted recents should include p1:m2")
-}
-
-func TestModelList_PrunesInvalidModelWithinValidProvider(t *testing.T) {
- // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
- log.Setup(os.DevNull, false)
-
- // Isolate config/data paths
- cfgDir := t.TempDir()
- dataDir := t.TempDir()
- t.Setenv("XDG_CONFIG_HOME", cfgDir)
- t.Setenv("XDG_DATA_HOME", dataDir)
-
- // Pre-seed config with valid provider but one invalid model
- confPath := filepath.Join(cfgDir, "crush", "crush.json")
- require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
- initial := map[string]any{
- "options": map[string]any{
- "disable_provider_auto_update": true,
- },
- "models": map[string]any{
- "large": map[string]any{
- "model": "m1",
- "provider": "p1",
- },
- },
- "recent_models": map[string]any{
- "large": []any{
- map[string]any{"model": "m1", "provider": "p1"}, // valid
- map[string]any{"model": "missing", "provider": "p1"}, // invalid model
- },
- },
- }
- bts, err := json.Marshal(initial)
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(confPath, bts, 0o644))
-
- // Create empty providers.json
- dataConfDir := filepath.Join(dataDir, "crush")
- require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
- emptyProviders := []byte("[]")
- require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
-
- // Initialize global config instance
- _, err = config.Init(cfgDir, dataDir, false)
- require.NoError(t, err)
-
- // Build provider set that only includes m1, not "missing"
- provider := catwalk.Provider{
- ID: catwalk.InferenceProvider("p1"),
- Name: "Provider One",
- Models: []catwalk.Model{
- {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
- },
- }
-
- // Create and initialize component
- listKeyMap := list.DefaultKeyMap()
- cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
- cmp.providers = []catwalk.Provider{provider}
- execCmdML(t, cmp, cmp.Init())
-
- // Find all recent items
- groups := cmp.list.Groups()
- require.NotEmpty(t, groups)
- var recentItems []list.CompletionItem[ModelOption]
- for _, g := range groups {
- for _, it := range g.Items {
- if strings.HasPrefix(it.ID(), "recent::") {
- recentItems = append(recentItems, it)
- }
- }
- }
- require.NotEmpty(t, recentItems, "valid recent should exist")
-
- // Verify the valid recent is present and invalid model is not
- foundValid := false
- for _, it := range recentItems {
- if it.ID() == "recent::p1:m1" {
- foundValid = true
- }
- require.NotEqual(t, "recent::p1:missing", it.ID(), "invalid model should be pruned")
- }
- require.True(t, foundValid, "valid recent p1:m1 should be present")
-
- // Verify original config in cfgDir remains unchanged
- origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
- afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
- require.NoError(t, err)
- var origParsed map[string]any
- require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
- origRM := origParsed["recent_models"].(map[string]any)
- origLarge := origRM["large"].([]any)
- require.Len(t, origLarge, 2, "original config should be unchanged")
-
- // Config should be rewritten with pruned recents in dataDir
- dataConf := filepath.Join(dataDir, "crush", "crush.json")
- rm := readRecentModels(t, dataConf)
- largeAny, ok := rm["large"].([]any)
- require.True(t, ok)
- require.Len(t, largeAny, 1, "should only have one valid model")
- // Verify only p1:m1 remains
- m := largeAny[0].(map[string]any)
- require.Equal(t, "p1", m["provider"])
- require.Equal(t, "m1", m["model"])
-}
-
-func TestModelKey_EmptyInputs(t *testing.T) {
- // Empty provider
- require.Equal(t, "", modelKey("", "model"))
- // Empty model
- require.Equal(t, "", modelKey("provider", ""))
- // Both empty
- require.Equal(t, "", modelKey("", ""))
- // Valid inputs
- require.Equal(t, "p:m", modelKey("p", "m"))
-}
-
-func TestModelList_AllRecentsInvalid(t *testing.T) {
- // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
- log.Setup(os.DevNull, false)
-
- // Isolate config/data paths
- cfgDir := t.TempDir()
- dataDir := t.TempDir()
- t.Setenv("XDG_CONFIG_HOME", cfgDir)
- t.Setenv("XDG_DATA_HOME", dataDir)
-
- // Pre-seed config with only invalid recents
- confPath := filepath.Join(cfgDir, "crush", "crush.json")
- require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
- initial := map[string]any{
- "options": map[string]any{
- "disable_provider_auto_update": true,
- },
- "models": map[string]any{
- "large": map[string]any{
- "model": "m1",
- "provider": "p1",
- },
- },
- "recent_models": map[string]any{
- "large": []any{
- map[string]any{"model": "x", "provider": "unknown1"},
- map[string]any{"model": "y", "provider": "unknown2"},
- },
- },
- }
- bts, err := json.Marshal(initial)
- require.NoError(t, err)
- require.NoError(t, os.WriteFile(confPath, bts, 0o644))
-
- // Also create empty providers.json and data config
- dataConfDir := filepath.Join(dataDir, "crush")
- require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
- emptyProviders := []byte("[]")
- require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
-
- // Initialize global config instance with isolated dataDir
- _, err = config.Init(cfgDir, dataDir, false)
- require.NoError(t, err)
-
- // Build provider set (doesn't include unknown1 or unknown2)
- provider := catwalk.Provider{
- ID: catwalk.InferenceProvider("p1"),
- Name: "Provider One",
- Models: []catwalk.Model{
- {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
- },
- }
-
- // Create and initialize component
- listKeyMap := list.DefaultKeyMap()
- cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
- cmp.providers = []catwalk.Provider{provider}
- execCmdML(t, cmp, cmp.Init())
-
- // Verify no recent items exist in UI
- groups := cmp.list.Groups()
- require.NotEmpty(t, groups)
- var recentItems []list.CompletionItem[ModelOption]
- for _, g := range groups {
- for _, it := range g.Items {
- if strings.HasPrefix(it.ID(), "recent::") {
- recentItems = append(recentItems, it)
- }
- }
- }
- require.Empty(t, recentItems, "all invalid recents should be pruned, resulting in no recent section")
-
- // Verify original config in cfgDir remains unchanged
- origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
- afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
- require.NoError(t, err)
- var origParsed map[string]any
- require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
- origRM := origParsed["recent_models"].(map[string]any)
- origLarge := origRM["large"].([]any)
- require.Len(t, origLarge, 2, "original config should be unchanged")
-
- // Config should be rewritten with empty recents in dataDir
- dataConf := filepath.Join(dataDir, "crush", "crush.json")
- rm := readRecentModels(t, dataConf)
- // When all recents are pruned, the value may be nil or an empty array
- largeVal := rm["large"]
- if largeVal == nil {
- // nil is acceptable - means empty
- return
- }
- largeAny, ok := largeVal.([]any)
- require.True(t, ok, "large key should be nil or array")
- require.Empty(t, largeAny, "persisted recents should be empty after pruning all invalid entries")
-}
@@ -1,549 +0,0 @@
-// Package models provides the model selection dialog for the TUI.
-package models
-
-import (
- "fmt"
- "time"
-
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/spinner"
- tea "charm.land/bubbletea/v2"
- "charm.land/catwalk/pkg/catwalk"
- "charm.land/lipgloss/v2"
- hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const (
- ModelsDialogID dialogs.DialogID = "models"
-
- defaultWidth = 60
-)
-
-const (
- LargeModelType int = iota
- SmallModelType
-
- largeModelInputPlaceholder = "Choose a model for large, complex tasks"
- smallModelInputPlaceholder = "Choose a model for small, simple tasks"
-)
-
-// ModelSelectedMsg is sent when a model is selected
-type ModelSelectedMsg struct {
- Model config.SelectedModel
- ModelType config.SelectedModelType
-}
-
-// CloseModelDialogMsg is sent when a model is selected
-type CloseModelDialogMsg struct{}
-
-// ModelDialog interface for the model selection dialog
-type ModelDialog interface {
- dialogs.DialogModel
-}
-
-type ModelOption struct {
- Provider catwalk.Provider
- Model catwalk.Model
-}
-
-type modelDialogCmp struct {
- width int
- wWidth int
- wHeight int
-
- modelList *ModelListComponent
- keyMap KeyMap
- help help.Model
-
- // API key state
- needsAPIKey bool
- apiKeyInput *APIKeyInput
- selectedModel *ModelOption
- selectedModelType config.SelectedModelType
- isAPIKeyValid bool
- apiKeyValue string
-
- // Hyper device flow state
- hyperDeviceFlow *hyper.DeviceFlow
- showHyperDeviceFlow bool
-
- // Copilot device flow state
- copilotDeviceFlow *copilot.DeviceFlow
- showCopilotDeviceFlow bool
-}
-
-func NewModelDialogCmp() ModelDialog {
- keyMap := DefaultKeyMap()
-
- listKeyMap := list.DefaultKeyMap()
- listKeyMap.Down.SetEnabled(false)
- listKeyMap.Up.SetEnabled(false)
- listKeyMap.DownOneItem = keyMap.Next
- listKeyMap.UpOneItem = keyMap.Previous
-
- t := styles.CurrentTheme()
- modelList := NewModelListComponent(listKeyMap, largeModelInputPlaceholder, true)
- apiKeyInput := NewAPIKeyInput()
- apiKeyInput.SetShowTitle(false)
- help := help.New()
- help.Styles = t.S().Help
-
- return &modelDialogCmp{
- modelList: modelList,
- apiKeyInput: apiKeyInput,
- width: defaultWidth,
- keyMap: DefaultKeyMap(),
- help: help,
- }
-}
-
-func (m *modelDialogCmp) Init() tea.Cmd {
- return tea.Batch(
- m.modelList.Init(),
- m.apiKeyInput.Init(),
- )
-}
-
-func (m *modelDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.wWidth = msg.Width
- m.wHeight = msg.Height
- m.apiKeyInput.SetWidth(m.width - 2)
- m.help.SetWidth(m.width - 2)
- return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
- case APIKeyStateChangeMsg:
- u, cmd := m.apiKeyInput.Update(msg)
- m.apiKeyInput = u.(*APIKeyInput)
- return m, cmd
- case hyper.DeviceFlowCompletedMsg:
- return m, m.saveOauthTokenAndContinue(msg.Token, true)
- case hyper.DeviceAuthInitiatedMsg, hyper.DeviceFlowErrorMsg:
- if m.hyperDeviceFlow != nil {
- u, cmd := m.hyperDeviceFlow.Update(msg)
- m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
- return m, cmd
- }
- return m, nil
- case copilot.DeviceAuthInitiatedMsg, copilot.DeviceFlowErrorMsg:
- if m.copilotDeviceFlow != nil {
- u, cmd := m.copilotDeviceFlow.Update(msg)
- m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
- return m, cmd
- }
- return m, nil
- case copilot.DeviceFlowCompletedMsg:
- return m, m.saveOauthTokenAndContinue(msg.Token, true)
- case tea.KeyPressMsg:
- switch {
- // Handle Hyper device flow keys
- case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showHyperDeviceFlow:
- return m, m.hyperDeviceFlow.CopyCode()
- case key.Matches(msg, key.NewBinding(key.WithKeys("c", "C"))) && m.showCopilotDeviceFlow:
- return m, m.copilotDeviceFlow.CopyCode()
- case key.Matches(msg, m.keyMap.Select):
- // If showing device flow, enter copies code and opens URL
- if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
- return m, m.hyperDeviceFlow.CopyCodeAndOpenURL()
- }
- if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
- return m, m.copilotDeviceFlow.CopyCodeAndOpenURL()
- }
- selectedItem := m.modelList.SelectedModel()
- if selectedItem == nil {
- return m, nil
- }
-
- modelType := config.SelectedModelTypeLarge
- if m.modelList.GetModelType() == SmallModelType {
- modelType = config.SelectedModelTypeSmall
- }
-
- askForApiKey := func() {
- m.keyMap.isAPIKeyHelp = true
- m.showHyperDeviceFlow = false
- m.showCopilotDeviceFlow = false
- m.needsAPIKey = true
- m.selectedModel = selectedItem
- m.selectedModelType = modelType
- m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
- }
-
- if m.isAPIKeyValid {
- return m, m.saveOauthTokenAndContinue(m.apiKeyValue, true)
- }
- if m.needsAPIKey {
- // Handle API key submission
- m.apiKeyValue = m.apiKeyInput.Value()
- provider, err := m.getProvider(m.selectedModel.Provider.ID)
- if err != nil || provider == nil {
- return m, util.ReportError(fmt.Errorf("provider %s not found", m.selectedModel.Provider.ID))
- }
- providerConfig := config.ProviderConfig{
- ID: string(m.selectedModel.Provider.ID),
- Name: m.selectedModel.Provider.Name,
- APIKey: m.apiKeyValue,
- Type: provider.Type,
- BaseURL: provider.APIEndpoint,
- }
- return m, tea.Sequence(
- util.CmdHandler(APIKeyStateChangeMsg{
- State: APIKeyInputStateVerifying,
- }),
- func() tea.Msg {
- start := time.Now()
- err := providerConfig.TestConnection(config.Get().Resolver())
- // intentionally wait for at least 750ms to make sure the user sees the spinner
- elapsed := time.Since(start)
- if elapsed < 750*time.Millisecond {
- time.Sleep(750*time.Millisecond - elapsed)
- }
- if err == nil {
- m.isAPIKeyValid = true
- return APIKeyStateChangeMsg{
- State: APIKeyInputStateVerified,
- }
- }
- return APIKeyStateChangeMsg{
- State: APIKeyInputStateError,
- }
- },
- )
- }
-
- // Check if provider is configured
- if m.isProviderConfigured(string(selectedItem.Provider.ID)) {
- return m, tea.Sequence(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- util.CmdHandler(ModelSelectedMsg{
- Model: config.SelectedModel{
- Model: selectedItem.Model.ID,
- Provider: string(selectedItem.Provider.ID),
- ReasoningEffort: selectedItem.Model.DefaultReasoningEffort,
- MaxTokens: selectedItem.Model.DefaultMaxTokens,
- },
- ModelType: modelType,
- }),
- )
- }
- switch selectedItem.Provider.ID {
- case hyperp.Name:
- m.showHyperDeviceFlow = true
- m.selectedModel = selectedItem
- m.selectedModelType = modelType
- m.hyperDeviceFlow = hyper.NewDeviceFlow()
- m.hyperDeviceFlow.SetWidth(m.width - 2)
- return m, m.hyperDeviceFlow.Init()
- case catwalk.InferenceProviderCopilot:
- if token, ok := config.Get().ImportCopilot(); ok {
- m.selectedModel = selectedItem
- m.selectedModelType = modelType
- return m, m.saveOauthTokenAndContinue(token, true)
- }
- m.showCopilotDeviceFlow = true
- m.selectedModel = selectedItem
- m.selectedModelType = modelType
- m.copilotDeviceFlow = copilot.NewDeviceFlow()
- m.copilotDeviceFlow.SetWidth(m.width - 2)
- return m, m.copilotDeviceFlow.Init()
- }
- // For other providers, show API key input
- askForApiKey()
- return m, nil
- case key.Matches(msg, m.keyMap.Tab):
- switch {
- case m.needsAPIKey:
- u, cmd := m.apiKeyInput.Update(msg)
- m.apiKeyInput = u.(*APIKeyInput)
- return m, cmd
- case m.modelList.GetModelType() == LargeModelType:
- m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
- return m, m.modelList.SetModelType(SmallModelType)
- default:
- m.modelList.SetInputPlaceholder(largeModelInputPlaceholder)
- return m, m.modelList.SetModelType(LargeModelType)
- }
- case key.Matches(msg, m.keyMap.Close):
- switch {
- case m.showHyperDeviceFlow:
- if m.hyperDeviceFlow != nil {
- m.hyperDeviceFlow.Cancel()
- }
- m.showHyperDeviceFlow = false
- m.selectedModel = nil
- case m.showCopilotDeviceFlow:
- if m.copilotDeviceFlow != nil {
- m.copilotDeviceFlow.Cancel()
- }
- m.showCopilotDeviceFlow = false
- m.selectedModel = nil
- case m.needsAPIKey:
- if m.isAPIKeyValid {
- return m, nil
- }
- // Go back to model selection
- m.needsAPIKey = false
- m.selectedModel = nil
- m.isAPIKeyValid = false
- m.apiKeyValue = ""
- m.apiKeyInput.Reset()
- return m, nil
- default:
- return m, util.CmdHandler(dialogs.CloseDialogMsg{})
- }
- default:
- switch {
- case m.needsAPIKey:
- u, cmd := m.apiKeyInput.Update(msg)
- m.apiKeyInput = u.(*APIKeyInput)
- return m, cmd
- default:
- u, cmd := m.modelList.Update(msg)
- m.modelList = u
- return m, cmd
- }
- }
- case tea.PasteMsg:
- switch {
- case m.needsAPIKey:
- u, cmd := m.apiKeyInput.Update(msg)
- m.apiKeyInput = u.(*APIKeyInput)
- return m, cmd
- default:
- var cmd tea.Cmd
- m.modelList, cmd = m.modelList.Update(msg)
- return m, cmd
- }
- case spinner.TickMsg:
- u, cmd := m.apiKeyInput.Update(msg)
- m.apiKeyInput = u.(*APIKeyInput)
- if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
- u, cmd = m.hyperDeviceFlow.Update(msg)
- m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
- }
- if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
- u, cmd = m.copilotDeviceFlow.Update(msg)
- m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
- }
- return m, cmd
- default:
- // Pass all other messages to the device flow for spinner animation
- switch {
- case m.showHyperDeviceFlow && m.hyperDeviceFlow != nil:
- u, cmd := m.hyperDeviceFlow.Update(msg)
- m.hyperDeviceFlow = u.(*hyper.DeviceFlow)
- return m, cmd
- case m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil:
- u, cmd := m.copilotDeviceFlow.Update(msg)
- m.copilotDeviceFlow = u.(*copilot.DeviceFlow)
- return m, cmd
- default:
- u, cmd := m.apiKeyInput.Update(msg)
- m.apiKeyInput = u.(*APIKeyInput)
- return m, cmd
- }
- }
- return m, nil
-}
-
-func (m *modelDialogCmp) View() string {
- t := styles.CurrentTheme()
-
- if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
- // Show Hyper device flow
- m.keyMap.isHyperDeviceFlow = true
- deviceFlowView := m.hyperDeviceFlow.View()
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with Hyper", m.width-4)),
- deviceFlowView,
- "",
- t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
- )
- return m.style().Render(content)
- }
- if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
- // Show Hyper device flow
- m.keyMap.isCopilotDeviceFlow = m.copilotDeviceFlow.State != copilot.DeviceFlowStateUnavailable
- m.keyMap.isCopilotUnavailable = m.copilotDeviceFlow.State == copilot.DeviceFlowStateUnavailable
- deviceFlowView := m.copilotDeviceFlow.View()
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Authenticate with GitHub Copilot", m.width-4)),
- deviceFlowView,
- "",
- t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
- )
- return m.style().Render(content)
- }
-
- // Reset the flags when not showing device flow
- m.keyMap.isHyperDeviceFlow = false
- m.keyMap.isCopilotDeviceFlow = false
- m.keyMap.isCopilotUnavailable = false
-
- switch {
- case m.needsAPIKey:
- // Show API key input
- m.keyMap.isAPIKeyHelp = true
- m.keyMap.isAPIKeyValid = m.isAPIKeyValid
- apiKeyView := m.apiKeyInput.View()
- apiKeyView = t.S().Base.Width(m.width - 3).Height(lipgloss.Height(apiKeyView)).PaddingLeft(1).Render(apiKeyView)
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(m.apiKeyInput.GetTitle(), m.width-4)),
- apiKeyView,
- "",
- t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
- )
- return m.style().Render(content)
- }
-
- // Show model selection
- listView := m.modelList.View()
- radio := m.modelTypeRadio()
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-lipgloss.Width(radio)-5)+" "+radio),
- listView,
- "",
- t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
- )
- return m.style().Render(content)
-}
-
-func (m *modelDialogCmp) Cursor() *tea.Cursor {
- if m.showHyperDeviceFlow && m.hyperDeviceFlow != nil {
- return m.hyperDeviceFlow.Cursor()
- }
- if m.showCopilotDeviceFlow && m.copilotDeviceFlow != nil {
- return m.copilotDeviceFlow.Cursor()
- }
- if m.needsAPIKey {
- cursor := m.apiKeyInput.Cursor()
- if cursor != nil {
- cursor = m.moveCursor(cursor)
- return cursor
- }
- } else {
- cursor := m.modelList.Cursor()
- if cursor != nil {
- cursor = m.moveCursor(cursor)
- return cursor
- }
- }
- return nil
-}
-
-func (m *modelDialogCmp) style() lipgloss.Style {
- t := styles.CurrentTheme()
- return t.S().Base.
- Width(m.width).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus)
-}
-
-func (m *modelDialogCmp) listWidth() int {
- return m.width - 2
-}
-
-func (m *modelDialogCmp) listHeight() int {
- return m.wHeight / 2
-}
-
-func (m *modelDialogCmp) Position() (int, int) {
- row := m.wHeight/4 - 2 // just a bit above the center
- col := m.wWidth / 2
- col -= m.width / 2
- return row, col
-}
-
-func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
- row, col := m.Position()
- if m.needsAPIKey {
- offset := row + 3 // Border + title + API key input offset
- cursor.Y += offset
- cursor.X = cursor.X + col + 2
- } else {
- offset := row + 3 // Border + title
- cursor.Y += offset
- cursor.X = cursor.X + col + 2
- }
- return cursor
-}
-
-func (m *modelDialogCmp) ID() dialogs.DialogID {
- return ModelsDialogID
-}
-
-func (m *modelDialogCmp) modelTypeRadio() string {
- t := styles.CurrentTheme()
- choices := []string{"Large Task", "Small Task"}
- iconSelected := "β"
- iconUnselected := "β"
- if m.modelList.GetModelType() == LargeModelType {
- return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
- }
- return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
-}
-
-func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
- cfg := config.Get()
- _, ok := cfg.Providers.Get(providerID)
- return ok
-}
-
-func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
- cfg := config.Get()
- providers, err := config.Providers(cfg)
- if err != nil {
- return nil, err
- }
- for _, p := range providers {
- if p.ID == providerID {
- return &p, nil
- }
- }
- return nil, nil
-}
-
-func (m *modelDialogCmp) saveOauthTokenAndContinue(apiKey any, close bool) tea.Cmd {
- if m.selectedModel == nil {
- return util.ReportError(fmt.Errorf("no model selected"))
- }
-
- cfg := config.Get()
- err := cfg.SetProviderAPIKey(string(m.selectedModel.Provider.ID), apiKey)
- if err != nil {
- return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
- }
-
- // Reset API key state and continue with model selection
- selectedModel := *m.selectedModel
- var cmds []tea.Cmd
- if close {
- cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
- }
- cmds = append(
- cmds,
- util.CmdHandler(ModelSelectedMsg{
- Model: config.SelectedModel{
- Model: selectedModel.Model.ID,
- Provider: string(selectedModel.Provider.ID),
- ReasoningEffort: selectedModel.Model.DefaultReasoningEffort,
- MaxTokens: selectedModel.Model.DefaultMaxTokens,
- },
- ModelType: m.selectedModelType,
- }),
- )
- return tea.Sequence(cmds...)
-}
@@ -1,113 +0,0 @@
-package permissions
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
- Left,
- Right,
- Tab,
- Select,
- Allow,
- AllowSession,
- Deny,
- ToggleDiffMode,
- ScrollDown,
- ScrollUp key.Binding
- ScrollLeft,
- ScrollRight key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- Left: key.NewBinding(
- key.WithKeys("left", "h"),
- key.WithHelp("β", "previous"),
- ),
- Right: key.NewBinding(
- key.WithKeys("right", "l"),
- key.WithHelp("β", "next"),
- ),
- Tab: key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "switch"),
- ),
- Allow: key.NewBinding(
- key.WithKeys("a", "A", "ctrl+a"),
- key.WithHelp("a", "allow"),
- ),
- AllowSession: key.NewBinding(
- key.WithKeys("s", "S", "ctrl+s"),
- key.WithHelp("s", "allow session"),
- ),
- Deny: key.NewBinding(
- key.WithKeys("d", "D", "esc"),
- key.WithHelp("d", "deny"),
- ),
- Select: key.NewBinding(
- key.WithKeys("enter", "ctrl+y"),
- key.WithHelp("enter", "confirm"),
- ),
- ToggleDiffMode: key.NewBinding(
- key.WithKeys("t"),
- key.WithHelp("t", "toggle diff mode"),
- ),
- ScrollDown: key.NewBinding(
- key.WithKeys("shift+down", "J"),
- key.WithHelp("shift+β", "scroll down"),
- ),
- ScrollUp: key.NewBinding(
- key.WithKeys("shift+up", "K"),
- key.WithHelp("shift+β", "scroll up"),
- ),
- ScrollLeft: key.NewBinding(
- key.WithKeys("shift+left", "H"),
- key.WithHelp("shift+β", "scroll left"),
- ),
- ScrollRight: key.NewBinding(
- key.WithKeys("shift+right", "L"),
- key.WithHelp("shift+β", "scroll right"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.Left,
- k.Right,
- k.Tab,
- k.Select,
- k.Allow,
- k.AllowSession,
- k.Deny,
- k.ToggleDiffMode,
- k.ScrollDown,
- k.ScrollUp,
- k.ScrollLeft,
- k.ScrollRight,
- }
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
- m := [][]key.Binding{}
- slice := k.KeyBindings()
- for i := 0; i < len(slice); i += 4 {
- end := min(i+4, len(slice))
- m = append(m, slice[i:end])
- }
- return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
- return []key.Binding{
- k.ToggleDiffMode,
- key.NewBinding(
- key.WithKeys("shift+left", "shift+down", "shift+up", "shift+right"),
- key.WithHelp("shift+ββββ", "scroll"),
- ),
- }
-}
@@ -1,899 +0,0 @@
-package permissions
-
-import (
- "encoding/json"
- "fmt"
- "strings"
-
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/viewport"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/agent/tools"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/permission"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/x/ansi"
-)
-
-type PermissionAction string
-
-// Permission responses
-const (
- PermissionAllow PermissionAction = "allow"
- PermissionAllowForSession PermissionAction = "allow_session"
- PermissionDeny PermissionAction = "deny"
-
- PermissionsDialogID dialogs.DialogID = "permissions"
-)
-
-// PermissionResponseMsg represents the user's response to a permission request
-type PermissionResponseMsg struct {
- Permission permission.PermissionRequest
- Action PermissionAction
-}
-
-// PermissionDialogCmp interface for permission dialog component
-type PermissionDialogCmp interface {
- dialogs.DialogModel
-}
-
-// permissionDialogCmp is the implementation of PermissionDialog
-type permissionDialogCmp struct {
- wWidth int
- wHeight int
- width int
- height int
- permission permission.PermissionRequest
- contentViewPort viewport.Model
- selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
-
- // Diff view state
- defaultDiffSplitMode bool // true for split, false for unified
- diffSplitMode *bool // nil means use defaultDiffSplitMode
- diffXOffset int // horizontal scroll offset
- diffYOffset int // vertical scroll offset
-
- // Caching
- cachedContent string
- contentDirty bool
-
- positionRow int // Row position for dialog
- positionCol int // Column position for dialog
-
- finalDialogHeight int
-
- keyMap KeyMap
-}
-
-func NewPermissionDialogCmp(permission permission.PermissionRequest, opts *Options) PermissionDialogCmp {
- if opts == nil {
- opts = &Options{}
- }
-
- // Create viewport for content
- contentViewport := viewport.New()
- return &permissionDialogCmp{
- contentViewPort: contentViewport,
- selectedOption: 0, // Default to "Allow"
- permission: permission,
- diffSplitMode: opts.isSplitMode(),
- keyMap: DefaultKeyMap(),
- contentDirty: true, // Mark as dirty initially
- }
-}
-
-func (p *permissionDialogCmp) Init() tea.Cmd {
- return p.contentViewPort.Init()
-}
-
-func (p *permissionDialogCmp) supportsDiffView() bool {
- return p.permission.ToolName == tools.EditToolName || p.permission.ToolName == tools.WriteToolName || p.permission.ToolName == tools.MultiEditToolName
-}
-
-func (p *permissionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- p.wWidth = msg.Width
- p.wHeight = msg.Height
- p.contentDirty = true // Mark content as dirty on window resize
- cmd := p.SetSize()
- cmds = append(cmds, cmd)
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, p.keyMap.Right) || key.Matches(msg, p.keyMap.Tab):
- p.selectedOption = (p.selectedOption + 1) % 3
- return p, nil
- case key.Matches(msg, p.keyMap.Left):
- p.selectedOption = (p.selectedOption + 2) % 3
- case key.Matches(msg, p.keyMap.Select):
- return p, p.selectCurrentOption()
- case key.Matches(msg, p.keyMap.Allow):
- return p, tea.Batch(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission}),
- )
- case key.Matches(msg, p.keyMap.AllowSession):
- return p, tea.Batch(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission}),
- )
- case key.Matches(msg, p.keyMap.Deny):
- return p, tea.Batch(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission}),
- )
- case key.Matches(msg, p.keyMap.ToggleDiffMode):
- if p.supportsDiffView() {
- if p.diffSplitMode == nil {
- diffSplitMode := !p.defaultDiffSplitMode
- p.diffSplitMode = &diffSplitMode
- } else {
- *p.diffSplitMode = !*p.diffSplitMode
- }
- p.contentDirty = true // Mark content as dirty when diff mode changes
- return p, nil
- }
- case key.Matches(msg, p.keyMap.ScrollDown):
- if p.supportsDiffView() {
- p.scrollDown()
- return p, nil
- }
- case key.Matches(msg, p.keyMap.ScrollUp):
- if p.supportsDiffView() {
- p.scrollUp()
- return p, nil
- }
- case key.Matches(msg, p.keyMap.ScrollLeft):
- if p.supportsDiffView() {
- p.scrollLeft()
- return p, nil
- }
- case key.Matches(msg, p.keyMap.ScrollRight):
- if p.supportsDiffView() {
- p.scrollRight()
- return p, nil
- }
- default:
- // Pass other keys to viewport
- viewPort, cmd := p.contentViewPort.Update(msg)
- p.contentViewPort = viewPort
- cmds = append(cmds, cmd)
- }
- case tea.MouseWheelMsg:
- if p.supportsDiffView() && p.isMouseOverDialog(msg.Mouse().X, msg.Mouse().Y) {
- switch msg.Button {
- case tea.MouseWheelDown:
- p.scrollDown()
- case tea.MouseWheelUp:
- p.scrollUp()
- case tea.MouseWheelLeft:
- p.scrollLeft()
- case tea.MouseWheelRight:
- p.scrollRight()
- }
- }
- }
-
- return p, tea.Batch(cmds...)
-}
-
-func (p *permissionDialogCmp) scrollDown() {
- p.diffYOffset += 1
- p.contentDirty = true
-}
-
-func (p *permissionDialogCmp) scrollUp() {
- p.diffYOffset = max(0, p.diffYOffset-1)
- p.contentDirty = true
-}
-
-func (p *permissionDialogCmp) scrollLeft() {
- p.diffXOffset = max(0, p.diffXOffset-5)
- p.contentDirty = true
-}
-
-func (p *permissionDialogCmp) scrollRight() {
- p.diffXOffset += 5
- p.contentDirty = true
-}
-
-// isMouseOverDialog checks if the given mouse coordinates are within the dialog bounds.
-// Returns true if the mouse is over the dialog area, false otherwise.
-func (p *permissionDialogCmp) isMouseOverDialog(x, y int) bool {
- if p.permission.ID == "" {
- return false
- }
- var (
- dialogX = p.positionCol
- dialogY = p.positionRow
- dialogWidth = p.width
- dialogHeight = p.finalDialogHeight
- )
- return x >= dialogX && x < dialogX+dialogWidth && y >= dialogY && y < dialogY+dialogHeight
-}
-
-func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
- var action PermissionAction
-
- switch p.selectedOption {
- case 0:
- action = PermissionAllow
- case 1:
- action = PermissionAllowForSession
- case 2:
- action = PermissionDeny
- }
-
- return tea.Batch(
- util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission}),
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- )
-}
-
-func (p *permissionDialogCmp) renderButtons() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base
-
- buttons := []core.ButtonOpts{
- {
- Text: "Allow",
- UnderlineIndex: 0, // "A"
- Selected: p.selectedOption == 0,
- },
- {
- Text: "Allow for Session",
- UnderlineIndex: 10, // "S" in "Session"
- Selected: p.selectedOption == 1,
- },
- {
- Text: "Deny",
- UnderlineIndex: 0, // "D"
- Selected: p.selectedOption == 2,
- },
- }
-
- content := core.SelectableButtons(buttons, " ")
- if lipgloss.Width(content) > p.width-4 {
- content = core.SelectableButtonsVertical(buttons, 1)
- return baseStyle.AlignVertical(lipgloss.Center).
- AlignHorizontal(lipgloss.Center).
- Width(p.width - 4).
- Render(content)
- }
-
- return baseStyle.AlignHorizontal(lipgloss.Right).Width(p.width - 4).Render(content)
-}
-
-func (p *permissionDialogCmp) renderHeader() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base
-
- toolKey := t.S().Muted.Render("Tool")
- toolValue := t.S().Text.
- Width(p.width - lipgloss.Width(toolKey)).
- Render(fmt.Sprintf(" %s", p.permission.ToolName))
-
- pathKey := t.S().Muted.Render("Path")
- pathValue := t.S().Text.
- Width(p.width - lipgloss.Width(pathKey)).
- Render(fmt.Sprintf(" %s", fsext.PrettyPath(p.permission.Path)))
-
- headerParts := []string{
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- toolKey,
- toolValue,
- ),
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- pathKey,
- pathValue,
- ),
- }
-
- // Add tool-specific header information
- switch p.permission.ToolName {
- case tools.BashToolName:
- params := p.permission.Params.(tools.BashPermissionsParams)
- descKey := t.S().Muted.Render("Desc")
- descValue := t.S().Text.
- Width(p.width - lipgloss.Width(descKey)).
- Render(fmt.Sprintf(" %s", params.Description))
- headerParts = append(headerParts,
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- descKey,
- descValue,
- ),
- baseStyle.Render(strings.Repeat(" ", p.width)),
- t.S().Muted.Width(p.width).Render("Command"),
- )
- case tools.DownloadToolName:
- params := p.permission.Params.(tools.DownloadPermissionsParams)
- urlKey := t.S().Muted.Render("URL")
- urlValue := t.S().Text.
- Width(p.width - lipgloss.Width(urlKey)).
- Render(fmt.Sprintf(" %s", params.URL))
- fileKey := t.S().Muted.Render("File")
- filePath := t.S().Text.
- Width(p.width - lipgloss.Width(fileKey)).
- Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
- headerParts = append(headerParts,
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- urlKey,
- urlValue,
- ),
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- fileKey,
- filePath,
- ),
- baseStyle.Render(strings.Repeat(" ", p.width)),
- )
- case tools.EditToolName:
- params := p.permission.Params.(tools.EditPermissionsParams)
- fileKey := t.S().Muted.Render("File")
- filePath := t.S().Text.
- Width(p.width - lipgloss.Width(fileKey)).
- Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
- headerParts = append(headerParts,
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- fileKey,
- filePath,
- ),
- baseStyle.Render(strings.Repeat(" ", p.width)),
- )
-
- case tools.WriteToolName:
- params := p.permission.Params.(tools.WritePermissionsParams)
- fileKey := t.S().Muted.Render("File")
- filePath := t.S().Text.
- Width(p.width - lipgloss.Width(fileKey)).
- Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
- headerParts = append(headerParts,
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- fileKey,
- filePath,
- ),
- baseStyle.Render(strings.Repeat(" ", p.width)),
- )
- case tools.MultiEditToolName:
- params := p.permission.Params.(tools.MultiEditPermissionsParams)
- fileKey := t.S().Muted.Render("File")
- filePath := t.S().Text.
- Width(p.width - lipgloss.Width(fileKey)).
- Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
- headerParts = append(headerParts,
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- fileKey,
- filePath,
- ),
- baseStyle.Render(strings.Repeat(" ", p.width)),
- )
- case tools.FetchToolName:
- headerParts = append(headerParts,
- baseStyle.Render(strings.Repeat(" ", p.width)),
- t.S().Muted.Width(p.width).Bold(true).Render("URL"),
- )
- case tools.AgenticFetchToolName:
- headerParts = append(headerParts,
- baseStyle.Render(strings.Repeat(" ", p.width)),
- t.S().Muted.Width(p.width).Bold(true).Render("Web"),
- )
- case tools.ViewToolName:
- params := p.permission.Params.(tools.ViewPermissionsParams)
- fileKey := t.S().Muted.Render("File")
- filePath := t.S().Text.
- Width(p.width - lipgloss.Width(fileKey)).
- Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.FilePath)))
- headerParts = append(headerParts,
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- fileKey,
- filePath,
- ),
- baseStyle.Render(strings.Repeat(" ", p.width)),
- )
- case tools.LSToolName:
- params := p.permission.Params.(tools.LSPermissionsParams)
- pathKey := t.S().Muted.Render("Directory")
- pathValue := t.S().Text.
- Width(p.width - lipgloss.Width(pathKey)).
- Render(fmt.Sprintf(" %s", fsext.PrettyPath(params.Path)))
- headerParts = append(headerParts,
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- pathKey,
- pathValue,
- ),
- baseStyle.Render(strings.Repeat(" ", p.width)),
- )
- }
-
- return baseStyle.Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-}
-
-func (p *permissionDialogCmp) getOrGenerateContent() string {
- // Return cached content if available and not dirty
- if !p.contentDirty && p.cachedContent != "" {
- return p.cachedContent
- }
-
- // Generate new content
- var content string
- switch p.permission.ToolName {
- case tools.BashToolName:
- content = p.generateBashContent()
- case tools.DownloadToolName:
- content = p.generateDownloadContent()
- case tools.EditToolName:
- content = p.generateEditContent()
- case tools.WriteToolName:
- content = p.generateWriteContent()
- case tools.MultiEditToolName:
- content = p.generateMultiEditContent()
- case tools.FetchToolName:
- content = p.generateFetchContent()
- case tools.AgenticFetchToolName:
- content = p.generateAgenticFetchContent()
- case tools.ViewToolName:
- content = p.generateViewContent()
- case tools.LSToolName:
- content = p.generateLSContent()
- default:
- content = p.generateDefaultContent()
- }
-
- // Cache the result
- p.cachedContent = content
- p.contentDirty = false
-
- return content
-}
-
-func (p *permissionDialogCmp) generateBashContent() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base.Background(t.BgSubtle)
- if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
- content := pr.Command
- t := styles.CurrentTheme()
- content = strings.TrimSpace(content)
- lines := strings.Split(content, "\n")
-
- width := p.width - 4
- var out []string
- for _, ln := range lines {
- out = append(out, t.S().Muted.
- Width(width).
- Padding(0, 3).
- Foreground(t.FgBase).
- Background(t.BgSubtle).
- Render(ln))
- }
-
- // Ensure minimum of 7 lines for command display
- minLines := 7
- for len(out) < minLines {
- out = append(out, t.S().Muted.
- Width(width).
- Padding(0, 3).
- Foreground(t.FgBase).
- Background(t.BgSubtle).
- Render(""))
- }
-
- // Use the cache for markdown rendering
- renderedContent := strings.Join(out, "\n")
- finalContent := baseStyle.
- Width(p.contentViewPort.Width()).
- Padding(1, 0).
- Render(renderedContent)
-
- return finalContent
- }
- return ""
-}
-
-func (p *permissionDialogCmp) generateEditContent() string {
- if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
- formatter := core.DiffFormatter().
- Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
- After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
- Height(p.contentViewPort.Height()).
- Width(p.contentViewPort.Width()).
- XOffset(p.diffXOffset).
- YOffset(p.diffYOffset)
- if p.useDiffSplitMode() {
- formatter = formatter.Split()
- } else {
- formatter = formatter.Unified()
- }
-
- diff := formatter.String()
- return diff
- }
- return ""
-}
-
-func (p *permissionDialogCmp) generateWriteContent() string {
- if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
- // Use the cache for diff rendering
- formatter := core.DiffFormatter().
- Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
- After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
- Height(p.contentViewPort.Height()).
- Width(p.contentViewPort.Width()).
- XOffset(p.diffXOffset).
- YOffset(p.diffYOffset)
- if p.useDiffSplitMode() {
- formatter = formatter.Split()
- } else {
- formatter = formatter.Unified()
- }
-
- diff := formatter.String()
- return diff
- }
- return ""
-}
-
-func (p *permissionDialogCmp) generateDownloadContent() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base.Background(t.BgSubtle)
- if pr, ok := p.permission.Params.(tools.DownloadPermissionsParams); ok {
- content := fmt.Sprintf("URL: %s\nFile: %s", pr.URL, fsext.PrettyPath(pr.FilePath))
- if pr.Timeout > 0 {
- content += fmt.Sprintf("\nTimeout: %ds", pr.Timeout)
- }
-
- finalContent := baseStyle.
- Padding(1, 2).
- Width(p.contentViewPort.Width()).
- Render(content)
- return finalContent
- }
- return ""
-}
-
-func (p *permissionDialogCmp) generateMultiEditContent() string {
- if pr, ok := p.permission.Params.(tools.MultiEditPermissionsParams); ok {
- // Use the cache for diff rendering
- formatter := core.DiffFormatter().
- Before(fsext.PrettyPath(pr.FilePath), pr.OldContent).
- After(fsext.PrettyPath(pr.FilePath), pr.NewContent).
- Height(p.contentViewPort.Height()).
- Width(p.contentViewPort.Width()).
- XOffset(p.diffXOffset).
- YOffset(p.diffYOffset)
- if p.useDiffSplitMode() {
- formatter = formatter.Split()
- } else {
- formatter = formatter.Unified()
- }
-
- diff := formatter.String()
- return diff
- }
- return ""
-}
-
-func (p *permissionDialogCmp) generateFetchContent() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base.Background(t.BgSubtle)
- if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
- finalContent := baseStyle.
- Padding(1, 2).
- Width(p.contentViewPort.Width()).
- Render(pr.URL)
- return finalContent
- }
- return ""
-}
-
-func (p *permissionDialogCmp) generateAgenticFetchContent() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base.Background(t.BgSubtle)
- if pr, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams); ok {
- var content string
- if pr.URL != "" {
- content = fmt.Sprintf("URL: %s\n\nPrompt: %s", pr.URL, pr.Prompt)
- } else {
- content = fmt.Sprintf("Prompt: %s", pr.Prompt)
- }
- finalContent := baseStyle.
- Padding(1, 2).
- Width(p.contentViewPort.Width()).
- Render(content)
- return finalContent
- }
- return ""
-}
-
-func (p *permissionDialogCmp) generateViewContent() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base.Background(t.BgSubtle)
- if pr, ok := p.permission.Params.(tools.ViewPermissionsParams); ok {
- content := fmt.Sprintf("File: %s", fsext.PrettyPath(pr.FilePath))
- if pr.Offset > 0 {
- content += fmt.Sprintf("\nStarting from line: %d", pr.Offset+1)
- }
- if pr.Limit > 0 && pr.Limit != 2000 { // 2000 is the default limit
- content += fmt.Sprintf("\nLines to read: %d", pr.Limit)
- }
-
- finalContent := baseStyle.
- Padding(1, 2).
- Width(p.contentViewPort.Width()).
- Render(content)
- return finalContent
- }
- return ""
-}
-
-func (p *permissionDialogCmp) generateLSContent() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base.Background(t.BgSubtle)
- if pr, ok := p.permission.Params.(tools.LSPermissionsParams); ok {
- content := fmt.Sprintf("Directory: %s", fsext.PrettyPath(pr.Path))
- if len(pr.Ignore) > 0 {
- content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(pr.Ignore, ", "))
- }
-
- finalContent := baseStyle.
- Padding(1, 2).
- Width(p.contentViewPort.Width()).
- Render(content)
- return finalContent
- }
- return ""
-}
-
-func (p *permissionDialogCmp) generateDefaultContent() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base.Background(t.BgSubtle)
-
- content := p.permission.Description
-
- // Add pretty-printed JSON parameters for MCP tools
- if p.permission.Params != nil {
- var paramStr string
-
- // Ensure params is a string
- if str, ok := p.permission.Params.(string); ok {
- paramStr = str
- } else {
- paramStr = fmt.Sprintf("%v", p.permission.Params)
- }
-
- // Try to parse as JSON for pretty printing
- var parsed any
- if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
- if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
- if content != "" {
- content += "\n\n"
- }
- content += string(b)
- }
- } else {
- // Not JSON, show as-is
- if content != "" {
- content += "\n\n"
- }
- content += paramStr
- }
- }
-
- content = strings.TrimSpace(content)
- content = "\n" + content + "\n"
- lines := strings.Split(content, "\n")
-
- width := p.width - 4
- var out []string
- for _, ln := range lines {
- ln = " " + ln // left padding
- if len(ln) > width {
- ln = ansi.Truncate(ln, width, "β¦")
- }
- out = append(out, t.S().Muted.
- Width(width).
- Foreground(t.FgBase).
- Background(t.BgSubtle).
- Render(ln))
- }
-
- // Use the cache for markdown rendering
- renderedContent := strings.Join(out, "\n")
- finalContent := baseStyle.
- Width(p.contentViewPort.Width()).
- Render(renderedContent)
-
- if renderedContent == "" {
- return ""
- }
-
- return finalContent
-}
-
-func (p *permissionDialogCmp) useDiffSplitMode() bool {
- if p.diffSplitMode != nil {
- return *p.diffSplitMode
- }
- return p.defaultDiffSplitMode
-}
-
-func (p *permissionDialogCmp) styleViewport() string {
- t := styles.CurrentTheme()
- return t.S().Base.Render(p.contentViewPort.View())
-}
-
-func (p *permissionDialogCmp) render() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base
- title := core.Title("Permission Required", p.width-4)
- // Render header
- headerContent := p.renderHeader()
- // Render buttons
- buttons := p.renderButtons()
-
- p.contentViewPort.SetWidth(p.width - 4)
-
- // Always set viewport content (the caching is handled in getOrGenerateContent)
- const minContentHeight = 9
-
- availableDialogHeight := max(minContentHeight, p.height-minContentHeight)
- p.contentViewPort.SetHeight(availableDialogHeight)
- contentFinal := p.getOrGenerateContent()
- contentHeight := min(availableDialogHeight, lipgloss.Height(contentFinal))
-
- p.contentViewPort.SetHeight(contentHeight)
- p.contentViewPort.SetContent(contentFinal)
-
- p.positionRow = p.wHeight / 2
- p.positionRow -= (contentHeight + 9) / 2
- p.positionRow -= 3 // Move dialog slightly higher than middle
-
- var contentHelp string
- if p.supportsDiffView() {
- contentHelp = help.New().View(p.keyMap)
- }
-
- // Calculate content height dynamically based on window size
- strs := []string{
- title,
- "",
- headerContent,
- "",
- p.styleViewport(),
- "",
- buttons,
- "",
- }
- if contentHelp != "" {
- strs = append(strs, "", contentHelp)
- }
- content := lipgloss.JoinVertical(lipgloss.Top, strs...)
-
- dialog := baseStyle.
- Padding(0, 1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus).
- Width(p.width).
- Render(
- content,
- )
- p.finalDialogHeight = lipgloss.Height(dialog)
- return dialog
-}
-
-func (p *permissionDialogCmp) View() string {
- return p.render()
-}
-
-func (p *permissionDialogCmp) SetSize() tea.Cmd {
- if p.permission.ID == "" {
- return nil
- }
-
- oldWidth, oldHeight := p.width, p.height
-
- switch p.permission.ToolName {
- case tools.BashToolName:
- p.width = int(float64(p.wWidth) * 0.8)
- p.height = int(float64(p.wHeight) * 0.3)
- case tools.DownloadToolName:
- p.width = int(float64(p.wWidth) * 0.8)
- p.height = int(float64(p.wHeight) * 0.4)
- case tools.EditToolName:
- p.width = int(float64(p.wWidth) * 0.8)
- p.height = int(float64(p.wHeight) * 0.8)
- case tools.WriteToolName:
- p.width = int(float64(p.wWidth) * 0.8)
- p.height = int(float64(p.wHeight) * 0.8)
- case tools.MultiEditToolName:
- p.width = int(float64(p.wWidth) * 0.8)
- p.height = int(float64(p.wHeight) * 0.8)
- case tools.FetchToolName:
- p.width = int(float64(p.wWidth) * 0.8)
- p.height = int(float64(p.wHeight) * 0.3)
- case tools.AgenticFetchToolName:
- p.width = int(float64(p.wWidth) * 0.8)
- p.height = int(float64(p.wHeight) * 0.4)
- case tools.ViewToolName:
- p.width = int(float64(p.wWidth) * 0.8)
- p.height = int(float64(p.wHeight) * 0.4)
- case tools.LSToolName:
- p.width = int(float64(p.wWidth) * 0.8)
- p.height = int(float64(p.wHeight) * 0.4)
- default:
- p.width = int(float64(p.wWidth) * 0.7)
- p.height = int(float64(p.wHeight) * 0.5)
- }
-
- // Default to diff split mode when dialog is wide enough.
- p.defaultDiffSplitMode = p.width >= 140
-
- // Set a maximum width for the dialog
- p.width = min(p.width, 180)
-
- // Mark content as dirty if size changed
- if oldWidth != p.width || oldHeight != p.height {
- p.contentDirty = true
- }
- p.positionRow = p.wHeight / 2
- p.positionRow -= p.height / 2
- p.positionRow -= 3 // Move dialog slightly higher than middle
- p.positionCol = p.wWidth / 2
- p.positionCol -= p.width / 2
- return nil
-}
-
-func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
- content, err := generator()
- if err != nil {
- return fmt.Sprintf("Error rendering markdown: %v", err)
- }
-
- return content
-}
-
-// ID implements PermissionDialogCmp.
-func (p *permissionDialogCmp) ID() dialogs.DialogID {
- return PermissionsDialogID
-}
-
-// Position implements PermissionDialogCmp.
-func (p *permissionDialogCmp) Position() (int, int) {
- return p.positionRow, p.positionCol
-}
-
-// Options for create a new permission dialog
-type Options struct {
- DiffMode string // split or unified, empty means use defaultDiffSplitMode
-}
-
-// isSplitMode returns internal representation of diff mode switch
-func (o Options) isSplitMode() *bool {
- var split bool
-
- switch o.DiffMode {
- case "split":
- split = true
- case "unified":
- split = false
- default:
- return nil
- }
-
- return &split
-}
@@ -1,75 +0,0 @@
-package quit
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-// KeyMap defines the keyboard bindings for the quit dialog.
-type KeyMap struct {
- LeftRight,
- EnterSpace,
- Yes,
- No,
- Tab,
- Close key.Binding
-}
-
-func DefaultKeymap() KeyMap {
- return KeyMap{
- LeftRight: key.NewBinding(
- key.WithKeys("left", "right"),
- key.WithHelp("β/β", "switch options"),
- ),
- EnterSpace: key.NewBinding(
- key.WithKeys("enter", " "),
- key.WithHelp("enter/space", "confirm"),
- ),
- Yes: key.NewBinding(
- key.WithKeys("y", "Y", "ctrl+c"),
- key.WithHelp("y/Y/ctrl+c", "yes"),
- ),
- No: key.NewBinding(
- key.WithKeys("n", "N"),
- key.WithHelp("n/N", "no"),
- ),
- Tab: key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "switch options"),
- ),
- Close: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.LeftRight,
- k.EnterSpace,
- k.Yes,
- k.No,
- k.Tab,
- k.Close,
- }
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
- m := [][]key.Binding{}
- slice := k.KeyBindings()
- for i := 0; i < len(slice); i += 4 {
- end := min(i+4, len(slice))
- m = append(m, slice[i:end])
- }
- return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
- return []key.Binding{
- k.LeftRight,
- k.EnterSpace,
- }
-}
@@ -1,120 +0,0 @@
-package quit
-
-import (
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const (
- question = "Are you sure you want to quit?"
- QuitDialogID dialogs.DialogID = "quit"
-)
-
-// QuitDialog represents a confirmation dialog for quitting the application.
-type QuitDialog interface {
- dialogs.DialogModel
-}
-
-type quitDialogCmp struct {
- wWidth int
- wHeight int
-
- selectedNo bool // true if "No" button is selected
- keymap KeyMap
-}
-
-// NewQuitDialog creates a new quit confirmation dialog.
-func NewQuitDialog() QuitDialog {
- return &quitDialogCmp{
- selectedNo: true, // Default to "No" for safety
- keymap: DefaultKeymap(),
- }
-}
-
-func (q *quitDialogCmp) Init() tea.Cmd {
- return nil
-}
-
-// Update handles keyboard input for the quit dialog.
-func (q *quitDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- q.wWidth = msg.Width
- q.wHeight = msg.Height
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab):
- q.selectedNo = !q.selectedNo
- return q, nil
- case key.Matches(msg, q.keymap.EnterSpace):
- if !q.selectedNo {
- return q, tea.Quit
- }
- return q, util.CmdHandler(dialogs.CloseDialogMsg{})
- case key.Matches(msg, q.keymap.Yes):
- return q, tea.Quit
- case key.Matches(msg, q.keymap.No, q.keymap.Close):
- return q, util.CmdHandler(dialogs.CloseDialogMsg{})
- }
- }
- return q, nil
-}
-
-// View renders the quit dialog with Yes/No buttons.
-func (q *quitDialogCmp) View() string {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base
- yesStyle := t.S().Text
- noStyle := yesStyle
-
- if q.selectedNo {
- noStyle = noStyle.Foreground(t.White).Background(t.Secondary)
- yesStyle = yesStyle.Background(t.BgSubtle)
- } else {
- yesStyle = yesStyle.Foreground(t.White).Background(t.Secondary)
- noStyle = noStyle.Background(t.BgSubtle)
- }
-
- const horizontalPadding = 3
- yesButton := yesStyle.PaddingLeft(horizontalPadding).Underline(true).Render("Y") +
- yesStyle.PaddingRight(horizontalPadding).Render("ep!")
- noButton := noStyle.PaddingLeft(horizontalPadding).Underline(true).Render("N") +
- noStyle.PaddingRight(horizontalPadding).Render("ope")
-
- buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render(
- lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton),
- )
-
- content := baseStyle.Render(
- lipgloss.JoinVertical(
- lipgloss.Center,
- question,
- "",
- buttons,
- ),
- )
-
- quitDialogStyle := baseStyle.
- Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus)
-
- return quitDialogStyle.Render(content)
-}
-
-func (q *quitDialogCmp) Position() (int, int) {
- row := q.wHeight / 2
- row -= 7 / 2
- col := q.wWidth / 2
- col -= (lipgloss.Width(question) + 4) / 2
-
- return row, col
-}
-
-func (q *quitDialogCmp) ID() dialogs.DialogID {
- return QuitDialogID
-}
@@ -1,264 +0,0 @@
-package reasoning
-
-import (
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "golang.org/x/text/cases"
- "golang.org/x/text/language"
-
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const (
- ReasoningDialogID dialogs.DialogID = "reasoning"
-
- defaultWidth int = 50
-)
-
-type listModel = list.FilterableList[list.CompletionItem[EffortOption]]
-
-type EffortOption struct {
- Title string
- Effort string
-}
-
-type ReasoningDialog interface {
- dialogs.DialogModel
-}
-
-type reasoningDialogCmp struct {
- width int
- wWidth int // Width of the terminal window
- wHeight int // Height of the terminal window
-
- effortList listModel
- keyMap ReasoningDialogKeyMap
- help help.Model
-}
-
-type ReasoningEffortSelectedMsg struct {
- Effort string
-}
-
-type ReasoningDialogKeyMap struct {
- Next key.Binding
- Previous key.Binding
- Select key.Binding
- Close key.Binding
-}
-
-func DefaultReasoningDialogKeyMap() ReasoningDialogKeyMap {
- return ReasoningDialogKeyMap{
- Next: key.NewBinding(
- key.WithKeys("down", "j", "ctrl+n"),
- key.WithHelp("β/j/ctrl+n", "next"),
- ),
- Previous: key.NewBinding(
- key.WithKeys("up", "k", "ctrl+p"),
- key.WithHelp("β/k/ctrl+p", "previous"),
- ),
- Select: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select"),
- ),
- Close: key.NewBinding(
- key.WithKeys("esc", "ctrl+c"),
- key.WithHelp("esc/ctrl+c", "close"),
- ),
- }
-}
-
-func (k ReasoningDialogKeyMap) ShortHelp() []key.Binding {
- return []key.Binding{k.Select, k.Close}
-}
-
-func (k ReasoningDialogKeyMap) FullHelp() [][]key.Binding {
- return [][]key.Binding{
- {k.Next, k.Previous},
- {k.Select, k.Close},
- }
-}
-
-func NewReasoningDialog() ReasoningDialog {
- keyMap := DefaultReasoningDialogKeyMap()
- listKeyMap := list.DefaultKeyMap()
- listKeyMap.Down.SetEnabled(false)
- listKeyMap.Up.SetEnabled(false)
- listKeyMap.DownOneItem = keyMap.Next
- listKeyMap.UpOneItem = keyMap.Previous
-
- t := styles.CurrentTheme()
- inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
- effortList := list.NewFilterableList(
- []list.CompletionItem[EffortOption]{},
- list.WithFilterInputStyle(inputStyle),
- list.WithFilterListOptions(
- list.WithKeyMap(listKeyMap),
- list.WithWrapNavigation(),
- list.WithResizeByList(),
- ),
- )
- help := help.New()
- help.Styles = t.S().Help
-
- return &reasoningDialogCmp{
- effortList: effortList,
- width: defaultWidth,
- keyMap: keyMap,
- help: help,
- }
-}
-
-func (r *reasoningDialogCmp) Init() tea.Cmd {
- return r.populateEffortOptions()
-}
-
-func (r *reasoningDialogCmp) populateEffortOptions() tea.Cmd {
- cfg := config.Get()
- if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
- selectedModel := cfg.Models[agentCfg.Model]
- model := cfg.GetModelByType(agentCfg.Model)
-
- // Get current reasoning effort
- currentEffort := selectedModel.ReasoningEffort
- if currentEffort == "" && model != nil {
- currentEffort = model.DefaultReasoningEffort
- }
-
- efforts := []EffortOption{}
- caser := cases.Title(language.Und)
- for _, level := range model.ReasoningLevels {
- efforts = append(efforts, EffortOption{
- Title: caser.String(level),
- Effort: level,
- })
- }
-
- effortItems := []list.CompletionItem[EffortOption]{}
- selectedID := ""
- for _, effort := range efforts {
- opts := []list.CompletionItemOption{
- list.WithCompletionID(effort.Effort),
- }
- if effort.Effort == currentEffort {
- opts = append(opts, list.WithCompletionShortcut("current"))
- selectedID = effort.Effort
- }
- effortItems = append(effortItems, list.NewCompletionItem(
- effort.Title,
- effort,
- opts...,
- ))
- }
-
- cmd := r.effortList.SetItems(effortItems)
- // Set the current effort as the selected item
- if currentEffort != "" && selectedID != "" {
- return tea.Sequence(cmd, r.effortList.SetSelected(selectedID))
- }
- return cmd
- }
- return nil
-}
-
-func (r *reasoningDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- r.wWidth = msg.Width
- r.wHeight = msg.Height
- return r, r.effortList.SetSize(r.listWidth(), r.listHeight())
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, r.keyMap.Select):
- selectedItem := r.effortList.SelectedItem()
- if selectedItem == nil {
- return r, nil // No item selected, do nothing
- }
- effort := (*selectedItem).Value()
- return r, tea.Sequence(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- func() tea.Msg {
- return ReasoningEffortSelectedMsg{
- Effort: effort.Effort,
- }
- },
- )
- case key.Matches(msg, r.keyMap.Close):
- return r, util.CmdHandler(dialogs.CloseDialogMsg{})
- default:
- u, cmd := r.effortList.Update(msg)
- r.effortList = u.(listModel)
- return r, cmd
- }
- }
- return r, nil
-}
-
-func (r *reasoningDialogCmp) View() string {
- t := styles.CurrentTheme()
- listView := r.effortList
-
- header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Select Reasoning Effort", r.width-4))
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- header,
- listView.View(),
- "",
- t.S().Base.Width(r.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(r.help.View(r.keyMap)),
- )
- return r.style().Render(content)
-}
-
-func (r *reasoningDialogCmp) Cursor() *tea.Cursor {
- if cursor, ok := r.effortList.(util.Cursor); ok {
- cursor := cursor.Cursor()
- if cursor != nil {
- cursor = r.moveCursor(cursor)
- }
- return cursor
- }
- return nil
-}
-
-func (r *reasoningDialogCmp) listWidth() int {
- return r.width - 2 // 4 for padding
-}
-
-func (r *reasoningDialogCmp) listHeight() int {
- listHeight := len(r.effortList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
- return min(listHeight, r.wHeight/2)
-}
-
-func (r *reasoningDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
- row, col := r.Position()
- offset := row + 3
- cursor.Y += offset
- cursor.X = cursor.X + col + 2
- return cursor
-}
-
-func (r *reasoningDialogCmp) style() lipgloss.Style {
- t := styles.CurrentTheme()
- return t.S().Base.
- Width(r.width).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus)
-}
-
-func (r *reasoningDialogCmp) Position() (int, int) {
- row := r.wHeight/4 - 2 // just a bit above the center
- col := r.wWidth / 2
- col -= r.width / 2
- return row, col
-}
-
-func (r *reasoningDialogCmp) ID() dialogs.DialogID {
- return ReasoningDialogID
-}
@@ -1,67 +0,0 @@
-package sessions
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
- Select,
- Next,
- Previous,
- Close key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- Select: key.NewBinding(
- key.WithKeys("enter", "tab", "ctrl+y"),
- key.WithHelp("enter", "choose"),
- ),
- Next: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("β", "next item"),
- ),
- Previous: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("β", "previous item"),
- ),
- Close: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "exit"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.Select,
- k.Next,
- k.Previous,
- k.Close,
- }
-}
-
-// FullHelp implements help.KeyMap.
-func (k KeyMap) FullHelp() [][]key.Binding {
- m := [][]key.Binding{}
- slice := k.KeyBindings()
- for i := 0; i < len(slice); i += 4 {
- end := min(i+4, len(slice))
- m = append(m, slice[i:end])
- }
- return m
-}
-
-// ShortHelp implements help.KeyMap.
-func (k KeyMap) ShortHelp() []key.Binding {
- return []key.Binding{
- key.NewBinding(
-
- key.WithKeys("down", "up"),
- key.WithHelp("ββ", "choose"),
- ),
- k.Select,
- k.Close,
- }
-}
@@ -1,181 +0,0 @@
-package sessions
-
-import (
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/event"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/tui/components/chat"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-const SessionsDialogID dialogs.DialogID = "sessions"
-
-// SessionDialog interface for the session switching dialog
-type SessionDialog interface {
- dialogs.DialogModel
-}
-
-type SessionsList = list.FilterableList[list.CompletionItem[session.Session]]
-
-type sessionDialogCmp struct {
- selectedInx int
- wWidth int
- wHeight int
- width int
- selectedSessionID string
- keyMap KeyMap
- sessionsList SessionsList
- help help.Model
-}
-
-// NewSessionDialogCmp creates a new session switching dialog
-func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionDialog {
- t := styles.CurrentTheme()
- listKeyMap := list.DefaultKeyMap()
- keyMap := DefaultKeyMap()
- listKeyMap.Down.SetEnabled(false)
- listKeyMap.Up.SetEnabled(false)
- listKeyMap.DownOneItem = keyMap.Next
- listKeyMap.UpOneItem = keyMap.Previous
-
- items := make([]list.CompletionItem[session.Session], len(sessions))
- if len(sessions) > 0 {
- for i, session := range sessions {
- items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
- }
- }
-
- inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
- sessionsList := list.NewFilterableList(
- items,
- list.WithFilterPlaceholder("Enter a session name"),
- list.WithFilterInputStyle(inputStyle),
- list.WithFilterListOptions(
- list.WithKeyMap(listKeyMap),
- list.WithWrapNavigation(),
- ),
- )
- help := help.New()
- help.Styles = t.S().Help
- s := &sessionDialogCmp{
- selectedSessionID: selectedID,
- keyMap: DefaultKeyMap(),
- sessionsList: sessionsList,
- help: help,
- }
-
- return s
-}
-
-func (s *sessionDialogCmp) Init() tea.Cmd {
- var cmds []tea.Cmd
- cmds = append(cmds, s.sessionsList.Init())
- cmds = append(cmds, s.sessionsList.Focus())
- return tea.Sequence(cmds...)
-}
-
-func (s *sessionDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- var cmds []tea.Cmd
- s.wWidth = msg.Width
- s.wHeight = msg.Height
- s.width = min(120, s.wWidth-8)
- s.sessionsList.SetInputWidth(s.listWidth() - 2)
- cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
- if s.selectedSessionID != "" {
- cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID))
- }
- return s, tea.Batch(cmds...)
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, s.keyMap.Select):
- selectedItem := s.sessionsList.SelectedItem()
- if selectedItem != nil {
- selected := *selectedItem
- event.SessionSwitched()
- return s, tea.Sequence(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- util.CmdHandler(
- chat.SessionSelectedMsg(selected.Value()),
- ),
- )
- }
- case key.Matches(msg, s.keyMap.Close):
- return s, util.CmdHandler(dialogs.CloseDialogMsg{})
- default:
- u, cmd := s.sessionsList.Update(msg)
- s.sessionsList = u.(SessionsList)
- return s, cmd
- }
- }
- return s, nil
-}
-
-func (s *sessionDialogCmp) View() string {
- t := styles.CurrentTheme()
- listView := s.sessionsList.View()
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)),
- listView,
- "",
- t.S().Base.Width(s.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(s.help.View(s.keyMap)),
- )
-
- return s.style().Render(content)
-}
-
-func (s *sessionDialogCmp) Cursor() *tea.Cursor {
- if cursor, ok := s.sessionsList.(util.Cursor); ok {
- cursor := cursor.Cursor()
- if cursor != nil {
- cursor = s.moveCursor(cursor)
- }
- return cursor
- }
- return nil
-}
-
-func (s *sessionDialogCmp) style() lipgloss.Style {
- t := styles.CurrentTheme()
- return t.S().Base.
- Width(s.width).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus)
-}
-
-func (s *sessionDialogCmp) listHeight() int {
- return s.wHeight/2 - 6 // 5 for the border, title and help
-}
-
-func (s *sessionDialogCmp) listWidth() int {
- return s.width - 2 // 2 for the border
-}
-
-func (s *sessionDialogCmp) Position() (int, int) {
- row := s.wHeight/4 - 2 // just a bit above the center
- col := s.wWidth / 2
- col -= s.width / 2
- return row, col
-}
-
-func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
- row, col := s.Position()
- offset := row + 3 // Border + title
- cursor.Y += offset
- cursor.X = cursor.X + col + 2
- return cursor
-}
-
-// ID implements SessionDialog.
-func (s *sessionDialogCmp) ID() dialogs.DialogID {
- return SessionsDialogID
-}
@@ -1,146 +0,0 @@
-package files
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
-
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
-
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/history"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-// FileHistory represents a file history with initial and latest versions.
-type FileHistory struct {
- InitialVersion history.File
- LatestVersion history.File
-}
-
-// SessionFile represents a file with its history information.
-type SessionFile struct {
- History FileHistory
- FilePath string
- Additions int
- Deletions int
-}
-
-// RenderOptions contains options for rendering file lists.
-type RenderOptions struct {
- MaxWidth int
- MaxItems int
- ShowSection bool
- SectionName string
-}
-
-// RenderFileList renders a list of file status items with the given options.
-func RenderFileList(fileSlice []SessionFile, opts RenderOptions) []string {
- t := styles.CurrentTheme()
- fileList := []string{}
-
- if opts.ShowSection {
- sectionName := opts.SectionName
- if sectionName == "" {
- sectionName = "Modified Files"
- }
- section := t.S().Subtle.Render(sectionName)
- fileList = append(fileList, section, "")
- }
-
- if len(fileSlice) == 0 {
- fileList = append(fileList, t.S().Base.Foreground(t.Border).Render("None"))
- return fileList
- }
-
- // Sort files by the latest version's created time
- sort.Slice(fileSlice, func(i, j int) bool {
- if fileSlice[i].History.LatestVersion.CreatedAt == fileSlice[j].History.LatestVersion.CreatedAt {
- return strings.Compare(fileSlice[i].FilePath, fileSlice[j].FilePath) < 0
- }
- return fileSlice[i].History.LatestVersion.CreatedAt > fileSlice[j].History.LatestVersion.CreatedAt
- })
-
- // Determine how many items to show
- maxItems := len(fileSlice)
- if opts.MaxItems > 0 {
- maxItems = min(opts.MaxItems, len(fileSlice))
- }
-
- filesShown := 0
- for _, file := range fileSlice {
- if file.Additions == 0 && file.Deletions == 0 {
- continue // skip files with no changes
- }
- if filesShown >= maxItems {
- break
- }
-
- var statusParts []string
- if file.Additions > 0 {
- statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
- }
- if file.Deletions > 0 {
- statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
- }
-
- extraContent := strings.Join(statusParts, " ")
- cwd := config.Get().WorkingDir() + string(os.PathSeparator)
- filePath := file.FilePath
- if rel, err := filepath.Rel(cwd, filePath); err == nil {
- filePath = rel
- }
- filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
- filePath = ansi.Truncate(filePath, opts.MaxWidth-lipgloss.Width(extraContent)-2, "β¦")
-
- fileList = append(fileList,
- core.Status(
- core.StatusOpts{
- Title: filePath,
- ExtraContent: extraContent,
- },
- opts.MaxWidth,
- ),
- )
- filesShown++
- }
-
- return fileList
-}
-
-// RenderFileBlock renders a complete file block with optional truncation indicator.
-func RenderFileBlock(fileSlice []SessionFile, opts RenderOptions, showTruncationIndicator bool) string {
- t := styles.CurrentTheme()
- fileList := RenderFileList(fileSlice, opts)
-
- // Add truncation indicator if needed
- if showTruncationIndicator && opts.MaxItems > 0 {
- totalFilesWithChanges := 0
- for _, file := range fileSlice {
- if file.Additions > 0 || file.Deletions > 0 {
- totalFilesWithChanges++
- }
- }
- if totalFilesWithChanges > opts.MaxItems {
- remaining := totalFilesWithChanges - opts.MaxItems
- if remaining == 1 {
- fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
- } else {
- fileList = append(fileList,
- t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("β¦and %d more", remaining)),
- )
- }
- }
- }
-
- content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
- if opts.MaxWidth > 0 {
- return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
- }
- return content
-}
@@ -1,86 +0,0 @@
-// Based on the implementation by @trashhalo at:
-// https://github.com/trashhalo/imgcat
-package image
-
-import (
- "fmt"
- _ "image/jpeg"
- _ "image/png"
-
- tea "charm.land/bubbletea/v2"
-)
-
-type Model struct {
- url string
- image string
- width uint
- height uint
- err error
-}
-
-func New(width, height uint, url string) Model {
- return Model{
- width: width,
- height: height,
- url: url,
- }
-}
-
-func (m Model) Init() tea.Cmd {
- return nil
-}
-
-func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
- switch msg := msg.(type) {
- case errMsg:
- m.err = msg
- return m, nil
- case redrawMsg:
- m.width = msg.width
- m.height = msg.height
- m.url = msg.url
- return m, loadURL(m.url)
- case loadMsg:
- return handleLoadMsg(m, msg)
- }
- return m, nil
-}
-
-func (m Model) View() string {
- if m.err != nil {
- return fmt.Sprintf("couldn't load image(s): %v", m.err)
- }
- return m.image
-}
-
-type errMsg struct{ error }
-
-func (m Model) Redraw(width uint, height uint, url string) tea.Cmd {
- return func() tea.Msg {
- return redrawMsg{
- width: width,
- height: height,
- url: url,
- }
- }
-}
-
-func (m Model) UpdateURL(url string) tea.Cmd {
- return func() tea.Msg {
- return redrawMsg{
- width: m.width,
- height: m.height,
- url: url,
- }
- }
-}
-
-type redrawMsg struct {
- width uint
- height uint
- url string
-}
-
-func (m Model) IsLoading() bool {
- return m.image == ""
-}
@@ -1,169 +0,0 @@
-// Based on the implementation by @trashhalo at:
-// https://github.com/trashhalo/imgcat
-package image
-
-import (
- "bytes"
- "context"
- "encoding/base64"
- "image"
- "image/png"
- "io"
- "net/http"
- "os"
- "strings"
-
- tea "charm.land/bubbletea/v2"
- "github.com/disintegration/imageorient"
- "github.com/lucasb-eyer/go-colorful"
- "github.com/muesli/termenv"
- "github.com/nfnt/resize"
- "github.com/srwiley/oksvg"
- "github.com/srwiley/rasterx"
-)
-
-type loadMsg struct {
- io.ReadCloser
-}
-
-func loadURL(url string) tea.Cmd {
- var r io.ReadCloser
- var err error
-
- if strings.HasPrefix(url, "http") {
- var resp *http.Request
- resp, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
- r = resp.Body
- } else {
- r, err = os.Open(url)
- }
-
- if err != nil {
- return func() tea.Msg {
- return errMsg{err}
- }
- }
-
- return load(r)
-}
-
-func load(r io.ReadCloser) tea.Cmd {
- return func() tea.Msg {
- return loadMsg{r}
- }
-}
-
-func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
- defer msg.Close()
-
- img, err := readerToImage(m.width, m.height, m.url, msg)
- if err != nil {
- return m, func() tea.Msg { return errMsg{err} }
- }
- m.image = img
- return m, nil
-}
-
-func imageToString(width, height uint, img image.Image) (string, error) {
- img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3)
- b := img.Bounds()
- w := b.Max.X
- h := b.Max.Y
- p := termenv.ColorProfile()
- str := strings.Builder{}
- for y := 0; y < h; y += 2 {
- for x := w; x < int(width); x = x + 2 {
- str.WriteString(" ")
- }
- for x := range w {
- c1, _ := colorful.MakeColor(img.At(x, y))
- color1 := p.Color(c1.Hex())
- c2, _ := colorful.MakeColor(img.At(x, y+1))
- color2 := p.Color(c2.Hex())
- str.WriteString(termenv.String("β").
- Foreground(color1).
- Background(color2).
- String())
- }
- str.WriteString("\n")
- }
- return str.String(), nil
-}
-
-func readerToImage(width uint, height uint, url string, r io.Reader) (string, error) {
- if strings.HasSuffix(strings.ToLower(url), ".svg") {
- return svgToImage(width, height, r)
- }
-
- img, _, err := imageorient.Decode(r)
- if err != nil {
- return "", err
- }
-
- return imageToString(width, height, img)
-}
-
-func svgToImage(width uint, height uint, r io.Reader) (string, error) {
- // Original author: https://stackoverflow.com/users/10826783/usual-human
- // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang
- // Adapted to use size from SVG, and to use temp file.
-
- tmpPngFile, err := os.CreateTemp("", "img.*.png")
- if err != nil {
- return "", err
- }
- tmpPngPath := tmpPngFile.Name()
- defer os.Remove(tmpPngPath)
- defer tmpPngFile.Close()
-
- // Rasterize the SVG:
- icon, err := oksvg.ReadIconStream(r)
- if err != nil {
- return "", err
- }
- w := int(icon.ViewBox.W)
- h := int(icon.ViewBox.H)
- icon.SetTarget(0, 0, float64(w), float64(h))
- rgba := image.NewRGBA(image.Rect(0, 0, w, h))
- icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1)
- // Write rasterized image as PNG:
- err = png.Encode(tmpPngFile, rgba)
- if err != nil {
- tmpPngFile.Close()
- return "", err
- }
- tmpPngFile.Close()
-
- rPng, err := os.Open(tmpPngPath)
- if err != nil {
- return "", err
- }
- defer rPng.Close()
-
- img, _, err := imageorient.Decode(rPng)
- if err != nil {
- return "", err
- }
- return imageToString(width, height, img)
-}
-
-// ImageFromBase64 renders an image from base64-encoded data.
-func ImageFromBase64(width, height uint, data, mediaType string) (string, error) {
- decoded, err := base64.StdEncoding.DecodeString(data)
- if err != nil {
- return "", err
- }
-
- r := bytes.NewReader(decoded)
-
- if strings.Contains(mediaType, "svg") {
- return svgToImage(width, height, r)
- }
-
- img, _, err := imageorient.Decode(r)
- if err != nil {
- return "", err
- }
-
- return imageToString(width, height, img)
-}
@@ -1,346 +0,0 @@
-// Package logo renders a Crush wordmark in a stylized way.
-package logo
-
-import (
- "fmt"
- "image/color"
- "strings"
-
- "charm.land/lipgloss/v2"
- "github.com/MakeNowJust/heredoc"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/exp/slice"
-)
-
-// letterform represents a letterform. It can be stretched horizontally by
-// a given amount via the boolean argument.
-type letterform func(bool) string
-
-const diag = `β±`
-
-// Opts are the options for rendering the Crush title art.
-type Opts struct {
- FieldColor color.Color // diagonal lines
- TitleColorA color.Color // left gradient ramp point
- TitleColorB color.Color // right gradient ramp point
- CharmColor color.Color // Charmβ’ text color
- VersionColor color.Color // Version text color
- Width int // width of the rendered logo, used for truncation
-}
-
-// Render renders the Crush logo. Set the argument to true to render the narrow
-// version, intended for use in a sidebar.
-//
-// The compact argument determines whether it renders compact for the sidebar
-// or wider for the main pane.
-func Render(version string, compact bool, o Opts) string {
- const charm = " Charmβ’"
-
- fg := func(c color.Color, s string) string {
- return lipgloss.NewStyle().Foreground(c).Render(s)
- }
-
- // Title.
- const spacing = 1
- letterforms := []letterform{
- letterC,
- letterR,
- letterU,
- letterSStylized,
- letterH,
- }
- stretchIndex := -1 // -1 means no stretching.
- if !compact {
- stretchIndex = cachedRandN(len(letterforms))
- }
-
- crush := renderWord(spacing, stretchIndex, letterforms...)
- crushWidth := lipgloss.Width(crush)
- b := new(strings.Builder)
- for r := range strings.SplitSeq(crush, "\n") {
- fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
- }
- crush = b.String()
-
- // Charm and version.
- metaRowGap := 1
- maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
- version = ansi.Truncate(version, maxVersionWidth, "β¦") // truncate version if too long.
- gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
- metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
-
- // Join the meta row and big Crush title.
- crush = strings.TrimSpace(metaRow + "\n" + crush)
-
- // Narrow version.
- if compact {
- field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
- return strings.Join([]string{field, field, crush, field, ""}, "\n")
- }
-
- fieldHeight := lipgloss.Height(crush)
-
- // Left field.
- const leftWidth = 6
- leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
- leftField := new(strings.Builder)
- for range fieldHeight {
- fmt.Fprintln(leftField, leftFieldRow)
- }
-
- // Right field.
- rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
- const stepDownAt = 0
- rightField := new(strings.Builder)
- for i := range fieldHeight {
- width := rightWidth
- if i >= stepDownAt {
- width = rightWidth - (i - stepDownAt)
- }
- fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
- }
-
- // Return the wide version.
- const hGap = " "
- logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
- if o.Width > 0 {
- // Truncate the logo to the specified width.
- lines := strings.Split(logo, "\n")
- for i, line := range lines {
- lines[i] = ansi.Truncate(line, o.Width, "")
- }
- logo = strings.Join(lines, "\n")
- }
- return logo
-}
-
-// SmallRender renders a smaller version of the Crush logo, suitable for
-// smaller windows or sidebar usage.
-func SmallRender(width int) string {
- t := styles.CurrentTheme()
- title := t.S().Base.Foreground(t.Secondary).Render("Charmβ’")
- title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
- remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
- if remainingWidth > 0 {
- lines := strings.Repeat("β±", remainingWidth)
- title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
- }
- return title
-}
-
-// renderWord renders letterforms to fork a word. stretchIndex is the index of
-// the letter to stretch, or -1 if no letter should be stretched.
-func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string {
- if spacing < 0 {
- spacing = 0
- }
-
- renderedLetterforms := make([]string, len(letterforms))
-
- // pick one letter randomly to stretch
- for i, letter := range letterforms {
- renderedLetterforms[i] = letter(i == stretchIndex)
- }
-
- if spacing > 0 {
- // Add spaces between the letters and render.
- renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
- }
- return strings.TrimSpace(
- lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
- )
-}
-
-// letterC renders the letter C in a stylized way. It takes an integer that
-// determines how many cells to stretch the letter. If the stretch is less than
-// 1, it defaults to no stretching.
-func letterC(stretch bool) string {
- // Here's what we're making:
- //
- // βββββ
- // β
- // ββββ
-
- left := heredoc.Doc(`
- β
- β
- `)
- right := heredoc.Doc(`
- β
-
- β
- `)
- return joinLetterform(
- left,
- stretchLetterformPart(right, letterformProps{
- stretch: stretch,
- width: 4,
- minStretch: 7,
- maxStretch: 12,
- }),
- )
-}
-
-// letterH renders the letter H in a stylized way. It takes an integer that
-// determines how many cells to stretch the letter. If the stretch is less than
-// 1, it defaults to no stretching.
-func letterH(stretch bool) string {
- // Here's what we're making:
- //
- // β β
- // βββββ
- // β β
-
- side := heredoc.Doc(`
- β
- β
- β`)
- middle := heredoc.Doc(`
-
- β
- `)
- return joinLetterform(
- side,
- stretchLetterformPart(middle, letterformProps{
- stretch: stretch,
- width: 3,
- minStretch: 8,
- maxStretch: 12,
- }),
- side,
- )
-}
-
-// letterR renders the letter R in a stylized way. It takes an integer that
-// determines how many cells to stretch the letter. If the stretch is less than
-// 1, it defaults to no stretching.
-func letterR(stretch bool) string {
- // Here's what we're making:
- //
- // βββββ
- // βββββ
- // β β
-
- left := heredoc.Doc(`
- β
- β
- β
- `)
- center := heredoc.Doc(`
- β
- β
- `)
- right := heredoc.Doc(`
- β
- β
- β
- `)
- return joinLetterform(
- left,
- stretchLetterformPart(center, letterformProps{
- stretch: stretch,
- width: 3,
- minStretch: 7,
- maxStretch: 12,
- }),
- right,
- )
-}
-
-// letterSStylized renders the letter S in a stylized way, more so than
-// [letterS]. It takes an integer that determines how many cells to stretch the
-// letter. If the stretch is less than 1, it defaults to no stretching.
-func letterSStylized(stretch bool) string {
- // Here's what we're making:
- //
- // ββββββ
- // ββββββ
- // βββββ
-
- left := heredoc.Doc(`
- β
- β
- β
- `)
- center := heredoc.Doc(`
- β
- β
- β
- `)
- right := heredoc.Doc(`
- β
- β
- `)
- return joinLetterform(
- left,
- stretchLetterformPart(center, letterformProps{
- stretch: stretch,
- width: 3,
- minStretch: 7,
- maxStretch: 12,
- }),
- right,
- )
-}
-
-// letterU renders the letter U in a stylized way. It takes an integer that
-// determines how many cells to stretch the letter. If the stretch is less than
-// 1, it defaults to no stretching.
-func letterU(stretch bool) string {
- // Here's what we're making:
- //
- // β β
- // β β
- // βββ
-
- side := heredoc.Doc(`
- β
- β
- `)
- middle := heredoc.Doc(`
-
-
- β
- `)
- return joinLetterform(
- side,
- stretchLetterformPart(middle, letterformProps{
- stretch: stretch,
- width: 3,
- minStretch: 7,
- maxStretch: 12,
- }),
- side,
- )
-}
-
-func joinLetterform(letters ...string) string {
- return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
-}
-
-// letterformProps defines letterform stretching properties.
-// for readability.
-type letterformProps struct {
- width int
- minStretch int
- maxStretch int
- stretch bool
-}
-
-// stretchLetterformPart is a helper function for letter stretching. If randomize
-// is false the minimum number will be used.
-func stretchLetterformPart(s string, p letterformProps) string {
- if p.maxStretch < p.minStretch {
- p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
- }
- n := p.width
- if p.stretch {
- n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
- }
- parts := make([]string, n)
- for i := range parts {
- parts[i] = s
- }
- return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
-}
@@ -1,24 +0,0 @@
-package logo
-
-import (
- "math/rand/v2"
- "sync"
-)
-
-var (
- randCaches = make(map[int]int)
- randCachesMu sync.Mutex
-)
-
-func cachedRandN(n int) int {
- randCachesMu.Lock()
- defer randCachesMu.Unlock()
-
- if n, ok := randCaches[n]; ok {
- return n
- }
-
- r := rand.IntN(n)
- randCaches[n] = r
- return r
-}
@@ -1,144 +0,0 @@
-package lsp
-
-import (
- "fmt"
- "maps"
- "slices"
- "strings"
-
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/app"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/lsp"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-// RenderOptions contains options for rendering LSP lists.
-type RenderOptions struct {
- MaxWidth int
- MaxItems int
- ShowSection bool
- SectionName string
-}
-
-// RenderLSPList renders a list of LSP status items with the given options.
-func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions) []string {
- t := styles.CurrentTheme()
- lspList := []string{}
-
- if opts.ShowSection {
- sectionName := opts.SectionName
- if sectionName == "" {
- sectionName = "LSPs"
- }
- section := t.S().Subtle.Render(sectionName)
- lspList = append(lspList, section, "")
- }
-
- // Get LSP states
- lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int {
- return strings.Compare(a.Name, b.Name)
- })
- if len(lsps) == 0 {
- lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None"))
- return lspList
- }
-
- // Determine how many items to show
- maxItems := len(lsps)
- if opts.MaxItems > 0 {
- maxItems = min(opts.MaxItems, len(lsps))
- }
-
- for i, info := range lsps {
- if i >= maxItems {
- break
- }
-
- icon, description := iconAndDescription(t, info)
-
- // Calculate diagnostic counts if we have LSP clients
- var extraContent string
- if lspClients != nil {
- if client, ok := lspClients.Get(info.Name); ok {
- counts := client.GetDiagnosticCounts()
- errs := []string{}
- if counts.Error > 0 {
- errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, counts.Error)))
- }
- if counts.Warning > 0 {
- errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, counts.Warning)))
- }
- if counts.Hint > 0 {
- errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, counts.Hint)))
- }
- if counts.Information > 0 {
- errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, counts.Information)))
- }
- extraContent = strings.Join(errs, " ")
- }
- }
-
- lspList = append(lspList,
- core.Status(
- core.StatusOpts{
- Icon: icon.String(),
- Title: info.Name,
- Description: description,
- ExtraContent: extraContent,
- },
- opts.MaxWidth,
- ),
- )
- }
-
- return lspList
-}
-
-func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) {
- switch info.State {
- case lsp.StateStarting:
- return t.ItemBusyIcon, t.S().Subtle.Render("starting...")
- case lsp.StateReady:
- return t.ItemOnlineIcon, ""
- case lsp.StateError:
- description := t.S().Subtle.Render("error")
- if info.Error != nil {
- description = t.S().Subtle.Render(fmt.Sprintf("error: %s", info.Error.Error()))
- }
- return t.ItemErrorIcon, description
- case lsp.StateDisabled:
- return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("inactive")
- default:
- return t.ItemOfflineIcon, ""
- }
-}
-
-// RenderLSPBlock renders a complete LSP block with optional truncation indicator.
-func RenderLSPBlock(lspClients *csync.Map[string, *lsp.Client], opts RenderOptions, showTruncationIndicator bool) string {
- t := styles.CurrentTheme()
- lspList := RenderLSPList(lspClients, opts)
-
- // Add truncation indicator if needed
- if showTruncationIndicator && opts.MaxItems > 0 {
- lspConfigs := config.Get().LSP.Sorted()
- if len(lspConfigs) > opts.MaxItems {
- remaining := len(lspConfigs) - opts.MaxItems
- if remaining == 1 {
- lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
- } else {
- lspList = append(lspList,
- t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("β¦and %d more", remaining)),
- )
- }
- }
- }
-
- content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
- if opts.MaxWidth > 0 {
- return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
- }
- return content
-}
@@ -1,138 +0,0 @@
-package mcp
-
-import (
- "fmt"
- "strings"
-
- "charm.land/lipgloss/v2"
-
- "github.com/charmbracelet/crush/internal/agent/tools/mcp"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-// RenderOptions contains options for rendering MCP lists.
-type RenderOptions struct {
- MaxWidth int
- MaxItems int
- ShowSection bool
- SectionName string
-}
-
-// RenderMCPList renders a list of MCP status items with the given options.
-func RenderMCPList(opts RenderOptions) []string {
- t := styles.CurrentTheme()
- mcpList := []string{}
-
- if opts.ShowSection {
- sectionName := opts.SectionName
- if sectionName == "" {
- sectionName = "MCPs"
- }
- section := t.S().Subtle.Render(sectionName)
- mcpList = append(mcpList, section, "")
- }
-
- mcps := config.Get().MCP.Sorted()
- if len(mcps) == 0 {
- mcpList = append(mcpList, t.S().Base.Foreground(t.Border).Render("None"))
- return mcpList
- }
-
- // Get MCP states
- mcpStates := mcp.GetStates()
-
- // Determine how many items to show
- maxItems := len(mcps)
- if opts.MaxItems > 0 {
- maxItems = min(opts.MaxItems, len(mcps))
- }
-
- for i, l := range mcps {
- if i >= maxItems {
- break
- }
-
- // Determine icon and color based on state
- icon := t.ItemOfflineIcon
- description := ""
- extraContent := []string{}
-
- if state, exists := mcpStates[l.Name]; exists {
- switch state.State {
- case mcp.StateDisabled:
- description = t.S().Subtle.Render("disabled")
- case mcp.StateStarting:
- icon = t.ItemBusyIcon
- description = t.S().Subtle.Render("starting...")
- case mcp.StateConnected:
- icon = t.ItemOnlineIcon
- if count := state.Counts.Tools; count > 0 {
- label := "tools"
- if count == 1 {
- label = "tool"
- }
- extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label)))
- }
- if count := state.Counts.Prompts; count > 0 {
- label := "prompts"
- if count == 1 {
- label = "prompt"
- }
- extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d %s", count, label)))
- }
- case mcp.StateError:
- icon = t.ItemErrorIcon
- if state.Error != nil {
- description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
- } else {
- description = t.S().Subtle.Render("error")
- }
- }
- } else if l.MCP.Disabled {
- description = t.S().Subtle.Render("disabled")
- }
-
- mcpList = append(mcpList,
- core.Status(
- core.StatusOpts{
- Icon: icon.String(),
- Title: l.Name,
- Description: description,
- ExtraContent: strings.Join(extraContent, " "),
- },
- opts.MaxWidth,
- ),
- )
- }
-
- return mcpList
-}
-
-// RenderMCPBlock renders a complete MCP block with optional truncation indicator.
-func RenderMCPBlock(opts RenderOptions, showTruncationIndicator bool) string {
- t := styles.CurrentTheme()
- mcpList := RenderMCPList(opts)
-
- // Add truncation indicator if needed
- if showTruncationIndicator && opts.MaxItems > 0 {
- mcps := config.Get().MCP.Sorted()
- if len(mcps) > opts.MaxItems {
- remaining := len(mcps) - opts.MaxItems
- if remaining == 1 {
- mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
- } else {
- mcpList = append(mcpList,
- t.S().Base.Foreground(t.FgSubtle).Render(fmt.Sprintf("β¦and %d more", remaining)),
- )
- }
- }
- }
-
- content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
- if opts.MaxWidth > 0 {
- return lipgloss.NewStyle().Width(opts.MaxWidth).Render(content)
- }
- return content
-}
@@ -1,329 +0,0 @@
-package list
-
-import (
- "regexp"
- "slices"
-
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/textinput"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/sahilm/fuzzy"
-)
-
-// Pre-compiled regex for checking if a string is alphanumeric.
-var alphanumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
-
-type FilterableItem interface {
- Item
- FilterValue() string
-}
-
-type FilterableList[T FilterableItem] interface {
- List[T]
- Cursor() *tea.Cursor
- SetInputWidth(int)
- SetInputPlaceholder(string)
- SetResultsSize(int)
- Filter(q string) tea.Cmd
- fuzzy.Source
-}
-
-type HasMatchIndexes interface {
- MatchIndexes([]int)
-}
-
-type filterableOptions struct {
- listOptions []ListOption
- placeholder string
- inputHidden bool
- inputWidth int
- inputStyle lipgloss.Style
-}
-type filterableList[T FilterableItem] struct {
- *list[T]
- *filterableOptions
- width, height int
- // stores all available items
- items []T
- resultsSize int
- input textinput.Model
- inputWidth int
- query string
-}
-
-type filterableListOption func(*filterableOptions)
-
-func WithFilterPlaceholder(ph string) filterableListOption {
- return func(f *filterableOptions) {
- f.placeholder = ph
- }
-}
-
-func WithFilterInputHidden() filterableListOption {
- return func(f *filterableOptions) {
- f.inputHidden = true
- }
-}
-
-func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
- return func(f *filterableOptions) {
- f.inputStyle = inputStyle
- }
-}
-
-func WithFilterListOptions(opts ...ListOption) filterableListOption {
- return func(f *filterableOptions) {
- f.listOptions = opts
- }
-}
-
-func WithFilterInputWidth(inputWidth int) filterableListOption {
- return func(f *filterableOptions) {
- f.inputWidth = inputWidth
- }
-}
-
-func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] {
- t := styles.CurrentTheme()
-
- f := &filterableList[T]{
- filterableOptions: &filterableOptions{
- inputStyle: t.S().Base,
- placeholder: "Type to filter",
- },
- }
- for _, opt := range opts {
- opt(f.filterableOptions)
- }
- f.list = New(items, f.listOptions...).(*list[T])
-
- f.updateKeyMaps()
- f.items = f.list.items
-
- if f.inputHidden {
- return f
- }
-
- ti := textinput.New()
- ti.Placeholder = f.placeholder
- ti.SetVirtualCursor(false)
- ti.Focus()
- ti.SetStyles(t.S().TextInput)
- f.input = ti
- return f
-}
-
-func (f *filterableList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- switch {
- // handle movements
- case key.Matches(msg, f.keyMap.Down),
- key.Matches(msg, f.keyMap.Up),
- key.Matches(msg, f.keyMap.DownOneItem),
- key.Matches(msg, f.keyMap.UpOneItem),
- key.Matches(msg, f.keyMap.HalfPageDown),
- key.Matches(msg, f.keyMap.HalfPageUp),
- key.Matches(msg, f.keyMap.PageDown),
- key.Matches(msg, f.keyMap.PageUp),
- key.Matches(msg, f.keyMap.End),
- key.Matches(msg, f.keyMap.Home):
- u, cmd := f.list.Update(msg)
- f.list = u.(*list[T])
- return f, cmd
- default:
- if !f.inputHidden {
- var cmds []tea.Cmd
- var cmd tea.Cmd
- f.input, cmd = f.input.Update(msg)
- cmds = append(cmds, cmd)
-
- if f.query != f.input.Value() {
- cmd = f.Filter(f.input.Value())
- cmds = append(cmds, cmd)
- }
- f.query = f.input.Value()
- return f, tea.Batch(cmds...)
- }
- }
- }
- u, cmd := f.list.Update(msg)
- f.list = u.(*list[T])
- return f, cmd
-}
-
-func (f *filterableList[T]) View() string {
- if f.inputHidden {
- return f.list.View()
- }
-
- return lipgloss.JoinVertical(
- lipgloss.Left,
- f.inputStyle.Render(f.input.View()),
- f.list.View(),
- )
-}
-
-// removes bindings that are used for search
-func (f *filterableList[T]) updateKeyMaps() {
- removeLettersAndNumbers := func(bindings []string) []string {
- var keep []string
- for _, b := range bindings {
- if len(b) != 1 {
- keep = append(keep, b)
- continue
- }
- if b == " " {
- continue
- }
- m := alphanumericRegex.MatchString(b)
- if !m {
- keep = append(keep, b)
- }
- }
- return keep
- }
-
- updateBinding := func(binding key.Binding) key.Binding {
- newKeys := removeLettersAndNumbers(binding.Keys())
- if len(newKeys) == 0 {
- binding.SetEnabled(false)
- return binding
- }
- binding.SetKeys(newKeys...)
- return binding
- }
-
- f.keyMap.Down = updateBinding(f.keyMap.Down)
- f.keyMap.Up = updateBinding(f.keyMap.Up)
- f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
- f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
- f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
- f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
- f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
- f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
- f.keyMap.End = updateBinding(f.keyMap.End)
- f.keyMap.Home = updateBinding(f.keyMap.Home)
-}
-
-func (m *filterableList[T]) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func (f *filterableList[T]) SetSize(w, h int) tea.Cmd {
- f.width = w
- f.height = h
- if f.inputHidden {
- return f.list.SetSize(w, h)
- }
- if f.inputWidth == 0 {
- f.input.SetWidth(w)
- } else {
- f.input.SetWidth(f.inputWidth)
- }
- return f.list.SetSize(w, h-(f.inputHeight()))
-}
-
-func (f *filterableList[T]) inputHeight() int {
- return lipgloss.Height(f.inputStyle.Render(f.input.View()))
-}
-
-func (f *filterableList[T]) Filter(query string) tea.Cmd {
- var cmds []tea.Cmd
- for _, item := range f.items {
- if i, ok := any(item).(layout.Focusable); ok {
- cmds = append(cmds, i.Blur())
- }
- if i, ok := any(item).(HasMatchIndexes); ok {
- i.MatchIndexes(make([]int, 0))
- }
- }
-
- f.selectedItemIdx = -1
- if query == "" || len(f.items) == 0 {
- return f.list.SetItems(f.visibleItems(f.items))
- }
-
- matches := fuzzy.FindFrom(query, f)
-
- var matchedItems []T
- resultSize := len(matches)
- if f.resultsSize > 0 && resultSize > f.resultsSize {
- resultSize = f.resultsSize
- }
- for i := range resultSize {
- match := matches[i]
- item := f.items[match.Index]
- if it, ok := any(item).(HasMatchIndexes); ok {
- it.MatchIndexes(match.MatchedIndexes)
- }
- matchedItems = append(matchedItems, item)
- }
-
- if f.direction == DirectionBackward {
- slices.Reverse(matchedItems)
- }
-
- cmds = append(cmds, f.list.SetItems(matchedItems))
- return tea.Batch(cmds...)
-}
-
-func (f *filterableList[T]) SetItems(items []T) tea.Cmd {
- f.items = items
- return f.list.SetItems(f.visibleItems(items))
-}
-
-func (f *filterableList[T]) Cursor() *tea.Cursor {
- if f.inputHidden {
- return nil
- }
- return f.input.Cursor()
-}
-
-func (f *filterableList[T]) Blur() tea.Cmd {
- f.input.Blur()
- return f.list.Blur()
-}
-
-func (f *filterableList[T]) Focus() tea.Cmd {
- f.input.Focus()
- return f.list.Focus()
-}
-
-func (f *filterableList[T]) IsFocused() bool {
- return f.list.IsFocused()
-}
-
-func (f *filterableList[T]) SetInputWidth(w int) {
- f.inputWidth = w
-}
-
-func (f *filterableList[T]) SetInputPlaceholder(ph string) {
- f.placeholder = ph
-}
-
-func (f *filterableList[T]) SetResultsSize(size int) {
- f.resultsSize = size
-}
-
-func (f *filterableList[T]) String(i int) string {
- return f.items[i].FilterValue()
-}
-
-func (f *filterableList[T]) Len() int {
- return len(f.items)
-}
-
-// visibleItems returns the subset of items that should be rendered based on
-// the configured resultsSize limit. The underlying source (f.items) remains
-// intact so filtering still searches the full set.
-func (f *filterableList[T]) visibleItems(items []T) []T {
- if f.resultsSize > 0 && len(items) > f.resultsSize {
- return items[:f.resultsSize]
- }
- return items
-}
@@ -1,315 +0,0 @@
-package list
-
-import (
- "regexp"
- "sort"
- "strings"
-
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/textinput"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/sahilm/fuzzy"
-)
-
-// Pre-compiled regex for checking if a string is alphanumeric.
-// Note: This is duplicated from filterable.go to avoid circular dependencies.
-var alphanumericRegexGroup = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
-
-type FilterableGroupList[T FilterableItem] interface {
- GroupedList[T]
- Cursor() *tea.Cursor
- SetInputWidth(int)
- SetInputPlaceholder(string)
-}
-type filterableGroupList[T FilterableItem] struct {
- *groupedList[T]
- *filterableOptions
- width, height int
- groups []Group[T]
- // stores all available items
- input textinput.Model
- inputWidth int
- query string
-}
-
-func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] {
- t := styles.CurrentTheme()
-
- f := &filterableGroupList[T]{
- filterableOptions: &filterableOptions{
- inputStyle: t.S().Base,
- placeholder: "Type to filter",
- },
- }
- for _, opt := range opts {
- opt(f.filterableOptions)
- }
- f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T])
-
- f.updateKeyMaps()
-
- if f.inputHidden {
- return f
- }
-
- ti := textinput.New()
- ti.Placeholder = f.placeholder
- ti.SetVirtualCursor(false)
- ti.Focus()
- ti.SetStyles(t.S().TextInput)
- f.input = ti
- return f
-}
-
-func (f *filterableGroupList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- switch {
- // handle movements
- case key.Matches(msg, f.keyMap.Down),
- key.Matches(msg, f.keyMap.Up),
- key.Matches(msg, f.keyMap.DownOneItem),
- key.Matches(msg, f.keyMap.UpOneItem),
- key.Matches(msg, f.keyMap.HalfPageDown),
- key.Matches(msg, f.keyMap.HalfPageUp),
- key.Matches(msg, f.keyMap.PageDown),
- key.Matches(msg, f.keyMap.PageUp),
- key.Matches(msg, f.keyMap.End),
- key.Matches(msg, f.keyMap.Home):
- u, cmd := f.groupedList.Update(msg)
- f.groupedList = u.(*groupedList[T])
- return f, cmd
- default:
- if !f.inputHidden {
- var cmds []tea.Cmd
- var cmd tea.Cmd
- f.input, cmd = f.input.Update(msg)
- cmds = append(cmds, cmd)
-
- if f.query != f.input.Value() {
- cmd = f.Filter(f.input.Value())
- cmds = append(cmds, cmd)
- }
- f.query = f.input.Value()
- return f, tea.Batch(cmds...)
- }
- }
- }
- u, cmd := f.groupedList.Update(msg)
- f.groupedList = u.(*groupedList[T])
- return f, cmd
-}
-
-func (f *filterableGroupList[T]) View() string {
- if f.inputHidden {
- return f.groupedList.View()
- }
-
- return lipgloss.JoinVertical(
- lipgloss.Left,
- f.inputStyle.Render(f.input.View()),
- f.groupedList.View(),
- )
-}
-
-// removes bindings that are used for search
-func (f *filterableGroupList[T]) updateKeyMaps() {
- removeLettersAndNumbers := func(bindings []string) []string {
- var keep []string
- for _, b := range bindings {
- if len(b) != 1 {
- keep = append(keep, b)
- continue
- }
- if b == " " {
- continue
- }
- m := alphanumericRegexGroup.MatchString(b)
- if !m {
- keep = append(keep, b)
- }
- }
- return keep
- }
-
- updateBinding := func(binding key.Binding) key.Binding {
- newKeys := removeLettersAndNumbers(binding.Keys())
- if len(newKeys) == 0 {
- binding.SetEnabled(false)
- return binding
- }
- binding.SetKeys(newKeys...)
- return binding
- }
-
- f.keyMap.Down = updateBinding(f.keyMap.Down)
- f.keyMap.Up = updateBinding(f.keyMap.Up)
- f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
- f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
- f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
- f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
- f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
- f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
- f.keyMap.End = updateBinding(f.keyMap.End)
- f.keyMap.Home = updateBinding(f.keyMap.Home)
-}
-
-func (m *filterableGroupList[T]) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd {
- f.width = w
- f.height = h
- if f.inputHidden {
- return f.groupedList.SetSize(w, h)
- }
- if f.inputWidth == 0 {
- f.input.SetWidth(w)
- } else {
- f.input.SetWidth(f.inputWidth)
- }
- return f.groupedList.SetSize(w, h-(f.inputHeight()))
-}
-
-func (f *filterableGroupList[T]) inputHeight() int {
- return lipgloss.Height(f.inputStyle.Render(f.input.View()))
-}
-
-func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
- var cmds []tea.Cmd
- for _, item := range f.items {
- if i, ok := any(item).(layout.Focusable); ok {
- cmds = append(cmds, i.Blur())
- }
- if i, ok := any(item).(HasMatchIndexes); ok {
- i.MatchIndexes(make([]int, 0))
- }
- }
- return cmds
-}
-
-func (f *filterableGroupList[T]) getGroupName(g Group[T]) string {
- if section, ok := g.Section.(*itemSectionModel); ok {
- return strings.ToLower(section.title)
- }
- return strings.ToLower(g.Section.ID())
-}
-
-func (f *filterableGroupList[T]) setMatchIndexes(item T, indexes []int) {
- if i, ok := any(item).(HasMatchIndexes); ok {
- i.MatchIndexes(indexes)
- }
-}
-
-func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string) []T {
- if query == "" {
- // No query, return all items with cleared match indexes
- var items []T
- for _, item := range group.Items {
- f.setMatchIndexes(item, make([]int, 0))
- items = append(items, item)
- }
- return items
- }
-
- name := f.getGroupName(group) + " "
-
- names := make([]string, len(group.Items))
- for i, item := range group.Items {
- names[i] = strings.ToLower(name + item.FilterValue())
- }
-
- matches := fuzzy.Find(query, names)
- sort.SliceStable(matches, func(i, j int) bool {
- return matches[i].Score > matches[j].Score
- })
-
- if len(matches) > 0 {
- var matchedItems []T
- for _, match := range matches {
- item := group.Items[match.Index]
- var idxs []int
- for _, idx := range match.MatchedIndexes {
- // adjusts removing group name highlights
- if idx < len(name) {
- continue
- }
- idxs = append(idxs, idx-len(name))
- }
- f.setMatchIndexes(item, idxs)
- matchedItems = append(matchedItems, item)
- }
- return matchedItems
- }
-
- return []T{}
-}
-
-func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
- cmds := f.clearItemState()
- f.selectedItemIdx = -1
-
- if query == "" {
- return f.groupedList.SetGroups(f.groups)
- }
-
- query = strings.ToLower(strings.ReplaceAll(query, " ", ""))
-
- var result []Group[T]
- for _, g := range f.groups {
- if matches := fuzzy.Find(query, []string{f.getGroupName(g)}); len(matches) > 0 && matches[0].Score > 0 {
- result = append(result, g)
- continue
- }
- matchedItems := f.filterItemsInGroup(g, query)
- if len(matchedItems) > 0 {
- result = append(result, Group[T]{
- Section: g.Section,
- Items: matchedItems,
- })
- }
- }
-
- cmds = append(cmds, f.groupedList.SetGroups(result))
- return tea.Batch(cmds...)
-}
-
-func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd {
- f.groups = groups
- return f.groupedList.SetGroups(groups)
-}
-
-func (f *filterableGroupList[T]) Cursor() *tea.Cursor {
- if f.inputHidden {
- return nil
- }
- return f.input.Cursor()
-}
-
-func (f *filterableGroupList[T]) Blur() tea.Cmd {
- f.input.Blur()
- return f.groupedList.Blur()
-}
-
-func (f *filterableGroupList[T]) Focus() tea.Cmd {
- f.input.Focus()
- return f.groupedList.Focus()
-}
-
-func (f *filterableGroupList[T]) IsFocused() bool {
- return f.groupedList.IsFocused()
-}
-
-func (f *filterableGroupList[T]) SetInputWidth(w int) {
- f.inputWidth = w
-}
-
-func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) {
- f.input.Placeholder = ph
- f.placeholder = ph
-}
@@ -1,68 +0,0 @@
-package list
-
-import (
- "fmt"
- "slices"
- "testing"
-
- "github.com/charmbracelet/x/exp/golden"
- "github.com/stretchr/testify/assert"
-)
-
-func TestFilterableList(t *testing.T) {
- t.Parallel()
- t.Run("should create simple filterable list", func(t *testing.T) {
- t.Parallel()
- items := []FilterableItem{}
- for i := range 5 {
- item := NewFilterableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := NewFilterableList(
- items,
- WithFilterListOptions(WithDirectionForward()),
- ).(*filterableList[FilterableItem])
-
- l.SetSize(100, 10)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
-
- assert.Equal(t, 0, l.selectedItemIdx)
- golden.RequireEqual(t, []byte(l.View()))
- })
-}
-
-func TestUpdateKeyMap(t *testing.T) {
- t.Parallel()
- l := NewFilterableList(
- []FilterableItem{},
- WithFilterListOptions(WithDirectionForward()),
- ).(*filterableList[FilterableItem])
-
- hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
- fmt.Println(l.keyMap.Down.Keys())
- hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
-
- hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
-
- assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
- assert.False(t, hasJ, "should not contain j")
- assert.False(t, hasUpperCaseK, "should also remove upper case K")
- assert.True(t, hasCtrlJ, "should still have ctrl+j")
-}
-
-type filterableItem struct {
- *selectableItem
-}
-
-func NewFilterableItem(content string) FilterableItem {
- return &filterableItem{
- selectableItem: NewSelectableItem(content).(*selectableItem),
- }
-}
-
-func (f *filterableItem) FilterValue() string {
- return f.content
-}
@@ -1,100 +0,0 @@
-package list
-
-import (
- tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type Group[T Item] struct {
- Section ItemSection
- Items []T
-}
-type GroupedList[T Item] interface {
- util.Model
- layout.Sizeable
- Items() []Item
- Groups() []Group[T]
- SetGroups([]Group[T]) tea.Cmd
- MoveUp(int) tea.Cmd
- MoveDown(int) tea.Cmd
- GoToTop() tea.Cmd
- GoToBottom() tea.Cmd
- SelectItemAbove() tea.Cmd
- SelectItemBelow() tea.Cmd
- SetSelected(string) tea.Cmd
- SelectedItem() *T
-}
-type groupedList[T Item] struct {
- *list[Item]
- groups []Group[T]
-}
-
-func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] {
- list := &list[Item]{
- confOptions: &confOptions{
- direction: DirectionForward,
- keyMap: DefaultKeyMap(),
- focused: true,
- },
- items: []Item{},
- indexMap: make(map[string]int),
- renderedItems: make(map[string]renderedItem),
- }
- for _, opt := range opts {
- opt(list.confOptions)
- }
-
- return &groupedList[T]{
- list: list,
- }
-}
-
-func (g *groupedList[T]) Init() tea.Cmd {
- g.convertItems()
- return g.render()
-}
-
-func (l *groupedList[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- u, cmd := l.list.Update(msg)
- l.list = u.(*list[Item])
- return l, cmd
-}
-
-func (g *groupedList[T]) SelectedItem() *T {
- item := g.list.SelectedItem()
- if item == nil {
- return nil
- }
- dRef := *item
- c, ok := any(dRef).(T)
- if !ok {
- return nil
- }
- return &c
-}
-
-func (g *groupedList[T]) convertItems() {
- var items []Item
- for _, g := range g.groups {
- items = append(items, g.Section)
- for _, g := range g.Items {
- items = append(items, g)
- }
- }
- g.items = items
-}
-
-func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
- g.groups = groups
- g.convertItems()
- return g.SetItems(g.items)
-}
-
-func (g *groupedList[T]) Groups() []Group[T] {
- return g.groups
-}
-
-func (g *groupedList[T]) Items() []Item {
- return g.list.Items()
-}
@@ -1,399 +0,0 @@
-package list
-
-import (
- "image/color"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/x/ansi"
- "github.com/google/uuid"
- "github.com/rivo/uniseg"
-)
-
-type Indexable interface {
- SetIndex(int)
-}
-
-type CompletionItem[T any] interface {
- FilterableItem
- layout.Focusable
- layout.Sizeable
- HasMatchIndexes
- Value() T
- Text() string
-}
-
-type completionItemCmp[T any] struct {
- width int
- id string
- text string
- value T
- focus bool
- matchIndexes []int
- bgColor color.Color
- shortcut string
-}
-
-type options struct {
- id string
- text string
- bgColor color.Color
- matchIndexes []int
- shortcut string
-}
-
-type CompletionItemOption func(*options)
-
-func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
- return func(cmp *options) {
- cmp.bgColor = c
- }
-}
-
-func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
- return func(cmp *options) {
- cmp.matchIndexes = indexes
- }
-}
-
-func WithCompletionShortcut(shortcut string) CompletionItemOption {
- return func(cmp *options) {
- cmp.shortcut = shortcut
- }
-}
-
-func WithCompletionID(id string) CompletionItemOption {
- return func(cmp *options) {
- cmp.id = id
- }
-}
-
-func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
- c := &completionItemCmp[T]{
- text: text,
- value: value,
- }
- o := &options{}
-
- for _, opt := range opts {
- opt(o)
- }
- if o.id == "" {
- o.id = uuid.NewString()
- }
- c.id = o.id
- c.bgColor = o.bgColor
- c.matchIndexes = o.matchIndexes
- c.shortcut = o.shortcut
- return c
-}
-
-// Init implements CommandItem.
-func (c *completionItemCmp[T]) Init() tea.Cmd {
- return nil
-}
-
-// Update implements CommandItem.
-func (c *completionItemCmp[T]) Update(tea.Msg) (util.Model, tea.Cmd) {
- return c, nil
-}
-
-// View implements CommandItem.
-func (c *completionItemCmp[T]) View() string {
- t := styles.CurrentTheme()
-
- itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
- innerWidth := c.width - 2 // Account for padding
-
- if c.shortcut != "" {
- innerWidth -= lipgloss.Width(c.shortcut)
- }
-
- titleStyle := t.S().Text.Width(innerWidth)
- titleMatchStyle := t.S().Text.Underline(true)
- if c.bgColor != nil {
- titleStyle = titleStyle.Background(c.bgColor)
- titleMatchStyle = titleMatchStyle.Background(c.bgColor)
- itemStyle = itemStyle.Background(c.bgColor)
- }
-
- if c.focus {
- titleStyle = t.S().TextSelected.Width(innerWidth)
- titleMatchStyle = t.S().TextSelected.Underline(true)
- itemStyle = itemStyle.Background(t.Primary)
- }
-
- var truncatedTitle string
-
- if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
- // Smart truncation: ensure the last matching part is visible
- truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
- } else {
- // No matches, use regular truncation
- truncatedTitle = ansi.Truncate(c.text, innerWidth, "β¦")
- }
-
- text := titleStyle.Render(truncatedTitle)
- if len(c.matchIndexes) > 0 {
- var ranges []lipgloss.Range
- for _, rng := range matchedRanges(c.matchIndexes) {
- // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
- // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
- // so we need to adjust it here:
- start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
- ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
- }
- text = lipgloss.StyleRanges(text, ranges...)
- }
- parts := []string{text}
- if c.shortcut != "" {
- // Add the shortcut at the end
- shortcutStyle := t.S().Muted
- if c.focus {
- shortcutStyle = t.S().TextSelected
- }
- parts = append(parts, shortcutStyle.Render(c.shortcut))
- }
- item := itemStyle.Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- parts...,
- ),
- )
- return item
-}
-
-// Blur implements CommandItem.
-func (c *completionItemCmp[T]) Blur() tea.Cmd {
- c.focus = false
- return nil
-}
-
-// Focus implements CommandItem.
-func (c *completionItemCmp[T]) Focus() tea.Cmd {
- c.focus = true
- return nil
-}
-
-// GetSize implements CommandItem.
-func (c *completionItemCmp[T]) GetSize() (int, int) {
- return c.width, 1
-}
-
-// IsFocused implements CommandItem.
-func (c *completionItemCmp[T]) IsFocused() bool {
- return c.focus
-}
-
-// SetSize implements CommandItem.
-func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
- c.width = width
- return nil
-}
-
-func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
- c.matchIndexes = indexes
-}
-
-func (c *completionItemCmp[T]) FilterValue() string {
- return c.text
-}
-
-func (c *completionItemCmp[T]) Value() T {
- return c.value
-}
-
-// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
-func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
- if width <= 0 {
- return ""
- }
-
- textLen := ansi.StringWidth(text)
- if textLen <= width {
- return text
- }
-
- if len(matchIndexes) == 0 {
- return ansi.Truncate(text, width, "β¦")
- }
-
- // Find the last match position
- lastMatchPos := matchIndexes[len(matchIndexes)-1]
-
- // Convert byte position to visual width position
- lastMatchVisualPos := 0
- bytePos := 0
- gr := uniseg.NewGraphemes(text)
- for bytePos < lastMatchPos && gr.Next() {
- bytePos += len(gr.Str())
- lastMatchVisualPos += max(1, gr.Width())
- }
-
- // Calculate how much space we need for the ellipsis
- ellipsisWidth := 1 // "β¦" character width
- availableWidth := width - ellipsisWidth
-
- // If the last match is within the available width, truncate from the end
- if lastMatchVisualPos < availableWidth {
- return ansi.Truncate(text, width, "β¦")
- }
-
- // Calculate the start position to ensure the last match is visible
- // We want to show some context before the last match if possible
- startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
-
- // Convert visual position back to byte position
- startBytePos := 0
- currentVisualPos := 0
- gr = uniseg.NewGraphemes(text)
- for currentVisualPos < startVisualPos && gr.Next() {
- startBytePos += len(gr.Str())
- currentVisualPos += max(1, gr.Width())
- }
-
- // Extract the substring starting from startBytePos
- truncatedText := text[startBytePos:]
-
- // Truncate to fit width with ellipsis
- truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
- truncatedText = "β¦" + truncatedText
- return truncatedText
-}
-
-func matchedRanges(in []int) [][2]int {
- if len(in) == 0 {
- return [][2]int{}
- }
- current := [2]int{in[0], in[0]}
- if len(in) == 1 {
- return [][2]int{current}
- }
- var out [][2]int
- for i := 1; i < len(in); i++ {
- if in[i] == current[1]+1 {
- current[1] = in[i]
- } else {
- out = append(out, current)
- current = [2]int{in[i], in[i]}
- }
- }
- out = append(out, current)
- return out
-}
-
-func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
- bytePos, byteStart, byteStop := 0, rng[0], rng[1]
- pos, start, stop := 0, 0, 0
- gr := uniseg.NewGraphemes(str)
- for byteStart > bytePos {
- if !gr.Next() {
- break
- }
- bytePos += len(gr.Str())
- pos += max(1, gr.Width())
- }
- start = pos
- for byteStop > bytePos {
- if !gr.Next() {
- break
- }
- bytePos += len(gr.Str())
- pos += max(1, gr.Width())
- }
- stop = pos
- return start, stop
-}
-
-// ID implements CompletionItem.
-func (c *completionItemCmp[T]) ID() string {
- return c.id
-}
-
-func (c *completionItemCmp[T]) Text() string {
- return c.text
-}
-
-type ItemSection interface {
- Item
- layout.Sizeable
- Indexable
- SetInfo(info string)
- Title() string
-}
-type itemSectionModel struct {
- width int
- title string
- inx int
- id string
- info string
-}
-
-// ID implements ItemSection.
-func (m *itemSectionModel) ID() string {
- return m.id
-}
-
-// Title implements ItemSection.
-func (m *itemSectionModel) Title() string {
- return m.title
-}
-
-func NewItemSection(title string) ItemSection {
- return &itemSectionModel{
- title: title,
- inx: -1,
- id: uuid.NewString(),
- }
-}
-
-func (m *itemSectionModel) Init() tea.Cmd {
- return nil
-}
-
-func (m *itemSectionModel) Update(tea.Msg) (util.Model, tea.Cmd) {
- return m, nil
-}
-
-func (m *itemSectionModel) View() string {
- t := styles.CurrentTheme()
- title := ansi.Truncate(m.title, m.width-2, "β¦")
- style := t.S().Base.Padding(1, 1, 0, 1)
- if m.inx == 0 {
- style = style.Padding(0, 1, 0, 1)
- }
- title = t.S().Muted.Render(title)
- section := ""
- if m.info != "" {
- section = core.SectionWithInfo(title, m.width-2, m.info)
- } else {
- section = core.Section(title, m.width-2)
- }
-
- return style.Render(section)
-}
-
-func (m *itemSectionModel) GetSize() (int, int) {
- return m.width, 1
-}
-
-func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
- m.width = width
- return nil
-}
-
-func (m *itemSectionModel) IsSectionHeader() bool {
- return true
-}
-
-func (m *itemSectionModel) SetInfo(info string) {
- m.info = info
-}
-
-func (m *itemSectionModel) SetIndex(inx int) {
- m.inx = inx
-}
@@ -1,76 +0,0 @@
-package list
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
- Down,
- Up,
- DownOneItem,
- UpOneItem,
- PageDown,
- PageUp,
- HalfPageDown,
- HalfPageUp,
- Home,
- End key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- Down: key.NewBinding(
- key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
- key.WithHelp("β", "down"),
- ),
- Up: key.NewBinding(
- key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
- key.WithHelp("β", "up"),
- ),
- UpOneItem: key.NewBinding(
- key.WithKeys("shift+up", "K"),
- key.WithHelp("shift+β", "up one item"),
- ),
- DownOneItem: key.NewBinding(
- key.WithKeys("shift+down", "J"),
- key.WithHelp("shift+β", "down one item"),
- ),
- HalfPageDown: key.NewBinding(
- key.WithKeys("d"),
- key.WithHelp("d", "half page down"),
- ),
- PageDown: key.NewBinding(
- key.WithKeys("pgdown", " ", "f"),
- key.WithHelp("f/pgdn", "page down"),
- ),
- PageUp: key.NewBinding(
- key.WithKeys("pgup", "b"),
- key.WithHelp("b/pgup", "page up"),
- ),
- HalfPageUp: key.NewBinding(
- key.WithKeys("u"),
- key.WithHelp("u", "half page up"),
- ),
- Home: key.NewBinding(
- key.WithKeys("g", "home"),
- key.WithHelp("g", "home"),
- ),
- End: key.NewBinding(
- key.WithKeys("G", "end"),
- key.WithHelp("G", "end"),
- ),
- }
-}
-
-func (k KeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.Down,
- k.Up,
- k.DownOneItem,
- k.UpOneItem,
- k.HalfPageDown,
- k.HalfPageUp,
- k.Home,
- k.End,
- }
-}
@@ -1,1775 +0,0 @@
-package list
-
-import (
- "strings"
- "sync"
-
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/components/anim"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- uv "github.com/charmbracelet/ultraviolet"
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/exp/ordered"
- "github.com/rivo/uniseg"
-)
-
-const maxGapSize = 100
-
-var newlineBuffer = strings.Repeat("\n", maxGapSize)
-
-var (
- specialCharsMap map[string]struct{}
- specialCharsOnce sync.Once
-)
-
-func getSpecialCharsMap() map[string]struct{} {
- specialCharsOnce.Do(func() {
- specialCharsMap = make(map[string]struct{}, len(styles.SelectionIgnoreIcons))
- for _, icon := range styles.SelectionIgnoreIcons {
- specialCharsMap[icon] = struct{}{}
- }
- })
- return specialCharsMap
-}
-
-type Item interface {
- util.Model
- layout.Sizeable
- ID() string
-}
-
-type HasAnim interface {
- Item
- Spinning() bool
-}
-
-type List[T Item] interface {
- util.Model
- layout.Sizeable
- layout.Focusable
-
- MoveUp(int) tea.Cmd
- MoveDown(int) tea.Cmd
- GoToTop() tea.Cmd
- GoToBottom() tea.Cmd
- SelectItemAbove() tea.Cmd
- SelectItemBelow() tea.Cmd
- SetItems([]T) tea.Cmd
- SetSelected(string) tea.Cmd
- SelectedItem() *T
- Items() []T
- UpdateItem(string, T) tea.Cmd
- DeleteItem(string) tea.Cmd
- PrependItem(T) tea.Cmd
- AppendItem(T) tea.Cmd
- StartSelection(col, line int)
- EndSelection(col, line int)
- SelectionStop()
- SelectionClear()
- SelectWord(col, line int)
- SelectParagraph(col, line int)
- GetSelectedText(paddingLeft int) string
- HasSelection() bool
-}
-
-type direction int
-
-const (
- DirectionForward direction = iota
- DirectionBackward
-)
-
-const (
- ItemNotFound = -1
- ViewportDefaultScrollSize = 5
-)
-
-type renderedItem struct {
- view string
- height int
- start int
- end int
-}
-
-type confOptions struct {
- width, height int
- gap int
- wrap bool
- keyMap KeyMap
- direction direction
- selectedItemIdx int // Index of selected item (-1 if none)
- selectedItemID string // Temporary storage for WithSelectedItem (resolved in New())
- focused bool
- resize bool
- enableMouse bool
-}
-
-type list[T Item] struct {
- *confOptions
-
- offset int
-
- indexMap map[string]int
- items []T
- renderedItems map[string]renderedItem
-
- rendered string
- renderedHeight int // cached height of rendered content
- lineOffsets []int // cached byte offsets for each line (for fast slicing)
-
- cachedView string
- cachedViewOffset int
- cachedViewDirty bool
-
- movingByItem bool
- prevSelectedItemIdx int // Index of previously selected item (-1 if none)
- selectionStartCol int
- selectionStartLine int
- selectionEndCol int
- selectionEndLine int
-
- selectionActive bool
-}
-
-type ListOption func(*confOptions)
-
-// WithSize sets the size of the list.
-func WithSize(width, height int) ListOption {
- return func(l *confOptions) {
- l.width = width
- l.height = height
- }
-}
-
-// WithGap sets the gap between items in the list.
-func WithGap(gap int) ListOption {
- return func(l *confOptions) {
- l.gap = gap
- }
-}
-
-// WithDirectionForward sets the direction to forward
-func WithDirectionForward() ListOption {
- return func(l *confOptions) {
- l.direction = DirectionForward
- }
-}
-
-// WithDirectionBackward sets the direction to forward
-func WithDirectionBackward() ListOption {
- return func(l *confOptions) {
- l.direction = DirectionBackward
- }
-}
-
-// WithSelectedItem sets the initially selected item in the list.
-func WithSelectedItem(id string) ListOption {
- return func(l *confOptions) {
- l.selectedItemID = id // Will be resolved to index in New()
- }
-}
-
-func WithKeyMap(keyMap KeyMap) ListOption {
- return func(l *confOptions) {
- l.keyMap = keyMap
- }
-}
-
-func WithWrapNavigation() ListOption {
- return func(l *confOptions) {
- l.wrap = true
- }
-}
-
-func WithFocus(focus bool) ListOption {
- return func(l *confOptions) {
- l.focused = focus
- }
-}
-
-func WithResizeByList() ListOption {
- return func(l *confOptions) {
- l.resize = true
- }
-}
-
-func WithEnableMouse() ListOption {
- return func(l *confOptions) {
- l.enableMouse = true
- }
-}
-
-func New[T Item](items []T, opts ...ListOption) List[T] {
- list := &list[T]{
- confOptions: &confOptions{
- direction: DirectionForward,
- keyMap: DefaultKeyMap(),
- focused: true,
- selectedItemIdx: -1,
- },
- items: items,
- indexMap: make(map[string]int, len(items)),
- renderedItems: make(map[string]renderedItem),
- prevSelectedItemIdx: -1,
- selectionStartCol: -1,
- selectionStartLine: -1,
- selectionEndLine: -1,
- selectionEndCol: -1,
- }
- for _, opt := range opts {
- opt(list.confOptions)
- }
-
- for inx, item := range items {
- if i, ok := any(item).(Indexable); ok {
- i.SetIndex(inx)
- }
- list.indexMap[item.ID()] = inx
- }
-
- // Resolve selectedItemID to selectedItemIdx if specified
- if list.selectedItemID != "" {
- if idx, ok := list.indexMap[list.selectedItemID]; ok {
- list.selectedItemIdx = idx
- }
- list.selectedItemID = "" // Clear temporary storage
- }
-
- return list
-}
-
-// Init implements List.
-func (l *list[T]) Init() tea.Cmd {
- return l.render()
-}
-
-// Update implements List.
-func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.MouseWheelMsg:
- if l.enableMouse {
- return l.handleMouseWheel(msg)
- }
- return l, nil
- case anim.StepMsg:
- // Fast path: if no items, skip processing
- if len(l.items) == 0 {
- return l, nil
- }
-
- // Fast path: check if ANY items are actually spinning before processing
- if !l.hasSpinningItems() {
- return l, nil
- }
-
- var cmds []tea.Cmd
- itemsLen := len(l.items)
- for i := range itemsLen {
- if i >= len(l.items) {
- continue
- }
- item := l.items[i]
- if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
- updated, cmd := animItem.Update(msg)
- cmds = append(cmds, cmd)
- if u, ok := updated.(T); ok {
- cmds = append(cmds, l.UpdateItem(u.ID(), u))
- }
- }
- }
- return l, tea.Batch(cmds...)
- case tea.KeyPressMsg:
- if l.focused {
- switch {
- case key.Matches(msg, l.keyMap.Down):
- return l, l.MoveDown(ViewportDefaultScrollSize)
- case key.Matches(msg, l.keyMap.Up):
- return l, l.MoveUp(ViewportDefaultScrollSize)
- case key.Matches(msg, l.keyMap.DownOneItem):
- return l, l.SelectItemBelow()
- case key.Matches(msg, l.keyMap.UpOneItem):
- return l, l.SelectItemAbove()
- case key.Matches(msg, l.keyMap.HalfPageDown):
- return l, l.MoveDown(l.height / 2)
- case key.Matches(msg, l.keyMap.HalfPageUp):
- return l, l.MoveUp(l.height / 2)
- case key.Matches(msg, l.keyMap.PageDown):
- return l, l.MoveDown(l.height)
- case key.Matches(msg, l.keyMap.PageUp):
- return l, l.MoveUp(l.height)
- case key.Matches(msg, l.keyMap.End):
- return l, l.GoToBottom()
- case key.Matches(msg, l.keyMap.Home):
- return l, l.GoToTop()
- }
- s := l.SelectedItem()
- if s == nil {
- return l, nil
- }
- item := *s
- var cmds []tea.Cmd
- updated, cmd := item.Update(msg)
- cmds = append(cmds, cmd)
- if u, ok := updated.(T); ok {
- cmds = append(cmds, l.UpdateItem(u.ID(), u))
- }
- return l, tea.Batch(cmds...)
- }
- }
- return l, nil
-}
-
-func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) {
- var cmd tea.Cmd
- switch msg.Button {
- case tea.MouseWheelDown:
- cmd = l.MoveDown(ViewportDefaultScrollSize)
- case tea.MouseWheelUp:
- cmd = l.MoveUp(ViewportDefaultScrollSize)
- }
- return l, cmd
-}
-
-func (l *list[T]) hasSpinningItems() bool {
- for i := range l.items {
- item := l.items[i]
- if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
- return true
- }
- }
- return false
-}
-
-func (l *list[T]) selectionView(view string, textOnly bool) string {
- t := styles.CurrentTheme()
- area := uv.Rect(0, 0, l.width, l.height)
- scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
- uv.NewStyledString(view).Draw(scr, area)
-
- selArea := l.selectionArea(false)
- specialChars := getSpecialCharsMap()
- selStyle := uv.Style{
- Bg: t.TextSelection.GetBackground(),
- Fg: t.TextSelection.GetForeground(),
- }
-
- isNonWhitespace := func(r rune) bool {
- return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
- }
-
- type selectionBounds struct {
- startX, endX int
- inSelection bool
- }
- lineSelections := make([]selectionBounds, scr.Height())
-
- for y := range scr.Height() {
- bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
-
- if y >= selArea.Min.Y && y < selArea.Max.Y {
- bounds.inSelection = true
- if selArea.Min.Y == selArea.Max.Y-1 {
- // Single line selection
- bounds.startX = selArea.Min.X
- bounds.endX = selArea.Max.X
- } else if y == selArea.Min.Y {
- // First line of multi-line selection
- bounds.startX = selArea.Min.X
- bounds.endX = scr.Width()
- } else if y == selArea.Max.Y-1 {
- // Last line of multi-line selection
- bounds.startX = 0
- bounds.endX = selArea.Max.X
- } else {
- // Middle lines
- bounds.startX = 0
- bounds.endX = scr.Width()
- }
- }
- lineSelections[y] = bounds
- }
-
- type lineBounds struct {
- start, end int
- }
- lineTextBounds := make([]lineBounds, scr.Height())
-
- // First pass: find text bounds for lines that have selections
- for y := range scr.Height() {
- bounds := lineBounds{start: -1, end: -1}
-
- // Only process lines that might have selections
- if lineSelections[y].inSelection {
- for x := range scr.Width() {
- cell := scr.CellAt(x, y)
- if cell == nil {
- continue
- }
-
- cellStr := cell.String()
- if len(cellStr) == 0 {
- continue
- }
-
- char := rune(cellStr[0])
- _, isSpecial := specialChars[cellStr]
-
- if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
- if bounds.start == -1 {
- bounds.start = x
- }
- bounds.end = x + 1 // Position after last character
- }
- }
- }
- lineTextBounds[y] = bounds
- }
-
- var selectedText strings.Builder
-
- // Second pass: apply selection highlighting
- for y := range scr.Height() {
- selBounds := lineSelections[y]
- if !selBounds.inSelection {
- continue
- }
-
- textBounds := lineTextBounds[y]
- if textBounds.start < 0 {
- if textOnly {
- // We don't want to get rid of all empty lines in text-only mode
- selectedText.WriteByte('\n')
- }
-
- continue // No text on this line
- }
-
- // Only scan within the intersection of text bounds and selection bounds
- scanStart := max(textBounds.start, selBounds.startX)
- scanEnd := min(textBounds.end, selBounds.endX)
-
- for x := scanStart; x < scanEnd; x++ {
- cell := scr.CellAt(x, y)
- if cell == nil {
- continue
- }
-
- cellStr := cell.String()
- if len(cellStr) > 0 {
- if _, isSpecial := specialChars[cellStr]; isSpecial {
- continue
- }
- if textOnly {
- // Collect selected text without styles
- selectedText.WriteString(cell.String())
- continue
- }
-
- cell = cell.Clone()
- cell.Style.Bg = selStyle.Bg
- cell.Style.Fg = selStyle.Fg
- scr.SetCell(x, y, cell)
- }
- }
-
- if textOnly {
- // Make sure we add a newline after each line of selected text
- selectedText.WriteByte('\n')
- }
- }
-
- if textOnly {
- return strings.TrimSpace(selectedText.String())
- }
-
- return scr.Render()
-}
-
-func (l *list[T]) View() string {
- if l.height <= 0 || l.width <= 0 {
- return ""
- }
-
- if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
- return l.cachedView
- }
-
- t := styles.CurrentTheme()
-
- start, end := l.viewPosition()
- viewStart := max(0, start)
- viewEnd := end
-
- if viewStart > viewEnd {
- return ""
- }
-
- view := l.getLines(viewStart, viewEnd)
-
- if l.resize {
- return view
- }
-
- view = t.S().Base.
- Height(l.height).
- Width(l.width).
- Render(view)
-
- if !l.hasSelection() {
- l.cachedView = view
- l.cachedViewOffset = l.offset
- l.cachedViewDirty = false
- return view
- }
-
- return l.selectionView(view, false)
-}
-
-func (l *list[T]) viewPosition() (int, int) {
- start, end := 0, 0
- renderedLines := l.renderedHeight - 1
- if l.direction == DirectionForward {
- start = max(0, l.offset)
- end = min(l.offset+l.height-1, renderedLines)
- } else {
- start = max(0, renderedLines-l.offset-l.height+1)
- end = max(0, renderedLines-l.offset)
- }
- start = min(start, end)
- return start, end
-}
-
-func (l *list[T]) setRendered(rendered string) {
- l.rendered = rendered
- l.renderedHeight = lipgloss.Height(rendered)
- l.cachedViewDirty = true // Mark view cache as dirty
-
- if len(rendered) > 0 {
- l.lineOffsets = make([]int, 0, l.renderedHeight)
- l.lineOffsets = append(l.lineOffsets, 0)
-
- offset := 0
- for {
- idx := strings.IndexByte(rendered[offset:], '\n')
- if idx == -1 {
- break
- }
- offset += idx + 1
- l.lineOffsets = append(l.lineOffsets, offset)
- }
- } else {
- l.lineOffsets = nil
- }
-}
-
-func (l *list[T]) getLines(start, end int) string {
- if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
- return ""
- }
-
- if end >= len(l.lineOffsets) {
- end = len(l.lineOffsets) - 1
- }
- if start > end {
- return ""
- }
-
- startOffset := l.lineOffsets[start]
- var endOffset int
- if end+1 < len(l.lineOffsets) {
- endOffset = l.lineOffsets[end+1] - 1
- } else {
- endOffset = len(l.rendered)
- }
-
- if startOffset >= len(l.rendered) {
- return ""
- }
- endOffset = min(endOffset, len(l.rendered))
-
- return l.rendered[startOffset:endOffset]
-}
-
-// getLine returns a single line from the rendered content using lineOffsets.
-// This avoids allocating a new string for each line like strings.Split does.
-func (l *list[T]) getLine(index int) string {
- if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
- return ""
- }
-
- startOffset := l.lineOffsets[index]
- var endOffset int
- if index+1 < len(l.lineOffsets) {
- endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
- } else {
- endOffset = len(l.rendered)
- }
-
- if startOffset >= len(l.rendered) {
- return ""
- }
- endOffset = min(endOffset, len(l.rendered))
-
- return l.rendered[startOffset:endOffset]
-}
-
-// lineCount returns the number of lines in the rendered content.
-func (l *list[T]) lineCount() int {
- return len(l.lineOffsets)
-}
-
-func (l *list[T]) recalculateItemPositions() {
- l.recalculateItemPositionsFrom(0)
-}
-
-func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
- var currentContentHeight int
-
- if startIdx > 0 && startIdx <= len(l.items) {
- prevItem := l.items[startIdx-1]
- if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
- currentContentHeight = rItem.end + 1 + l.gap
- }
- }
-
- for i := startIdx; i < len(l.items); i++ {
- item := l.items[i]
- rItem, ok := l.renderedItems[item.ID()]
- if !ok {
- continue
- }
- rItem.start = currentContentHeight
- rItem.end = currentContentHeight + rItem.height - 1
- l.renderedItems[item.ID()] = rItem
- currentContentHeight = rItem.end + 1 + l.gap
- }
-}
-
-func (l *list[T]) render() tea.Cmd {
- if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
- return nil
- }
- l.setDefaultSelected()
-
- var focusChangeCmd tea.Cmd
- if l.focused {
- focusChangeCmd = l.focusSelectedItem()
- } else {
- focusChangeCmd = l.blurSelectedItem()
- }
- if l.rendered != "" {
- rendered, _ := l.renderIterator(0, false, "")
- l.setRendered(rendered)
- if l.direction == DirectionBackward {
- l.recalculateItemPositions()
- }
- if l.focused {
- l.scrollToSelection()
- }
- return focusChangeCmd
- }
- rendered, finishIndex := l.renderIterator(0, true, "")
- l.setRendered(rendered)
- if l.direction == DirectionBackward {
- l.recalculateItemPositions()
- }
-
- l.offset = 0
- rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
- l.setRendered(rendered)
- if l.direction == DirectionBackward {
- l.recalculateItemPositions()
- }
- if l.focused {
- l.scrollToSelection()
- }
-
- return focusChangeCmd
-}
-
-func (l *list[T]) setDefaultSelected() {
- if l.selectedItemIdx < 0 {
- if l.direction == DirectionForward {
- l.selectFirstItem()
- } else {
- l.selectLastItem()
- }
- }
-}
-
-func (l *list[T]) scrollToSelection() {
- if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
- l.selectedItemIdx = -1
- l.setDefaultSelected()
- return
- }
- item := l.items[l.selectedItemIdx]
- rItem, ok := l.renderedItems[item.ID()]
- if !ok {
- l.selectedItemIdx = -1
- l.setDefaultSelected()
- return
- }
-
- start, end := l.viewPosition()
- if rItem.start <= start && rItem.end >= end {
- return
- }
- if l.movingByItem {
- if rItem.start >= start && rItem.end <= end {
- return
- }
- defer func() { l.movingByItem = false }()
- } else {
- if rItem.start >= start && rItem.start <= end {
- return
- }
- if rItem.end >= start && rItem.end <= end {
- return
- }
- }
-
- if rItem.height >= l.height {
- if l.direction == DirectionForward {
- l.offset = rItem.start
- } else {
- l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
- }
- return
- }
-
- renderedLines := l.renderedHeight - 1
-
- if rItem.start < start {
- if l.direction == DirectionForward {
- l.offset = rItem.start
- } else {
- l.offset = max(0, renderedLines-rItem.start-l.height+1)
- }
- } else if rItem.end > end {
- if l.direction == DirectionForward {
- l.offset = max(0, rItem.end-l.height+1)
- } else {
- l.offset = max(0, renderedLines-rItem.end)
- }
- }
-}
-
-func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
- if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
- return nil
- }
- item := l.items[l.selectedItemIdx]
- rItem, ok := l.renderedItems[item.ID()]
- if !ok {
- return nil
- }
- start, end := l.viewPosition()
- // item bigger than the viewport do nothing
- if rItem.start <= start && rItem.end >= end {
- return nil
- }
- // item already in view do nothing
- if rItem.start >= start && rItem.end <= end {
- return nil
- }
-
- itemMiddle := rItem.start + rItem.height/2
-
- if itemMiddle < start {
- // select the first item in the viewport
- // the item is most likely an item coming after this item
- inx := l.selectedItemIdx
- for {
- inx = l.firstSelectableItemBelow(inx)
- if inx == ItemNotFound {
- return nil
- }
- if inx < 0 || inx >= len(l.items) {
- continue
- }
-
- item := l.items[inx]
- renderedItem, ok := l.renderedItems[item.ID()]
- if !ok {
- continue
- }
-
- // If the item is bigger than the viewport, select it
- if renderedItem.start <= start && renderedItem.end >= end {
- l.selectedItemIdx = inx
- return l.render()
- }
- // item is in the view
- if renderedItem.start >= start && renderedItem.start <= end {
- l.selectedItemIdx = inx
- return l.render()
- }
- }
- } else if itemMiddle > end {
- // select the first item in the viewport
- // the item is most likely an item coming after this item
- inx := l.selectedItemIdx
- for {
- inx = l.firstSelectableItemAbove(inx)
- if inx == ItemNotFound {
- return nil
- }
- if inx < 0 || inx >= len(l.items) {
- continue
- }
-
- item := l.items[inx]
- renderedItem, ok := l.renderedItems[item.ID()]
- if !ok {
- continue
- }
-
- // If the item is bigger than the viewport, select it
- if renderedItem.start <= start && renderedItem.end >= end {
- l.selectedItemIdx = inx
- return l.render()
- }
- // item is in the view
- if renderedItem.end >= start && renderedItem.end <= end {
- l.selectedItemIdx = inx
- return l.render()
- }
- }
- }
- return nil
-}
-
-func (l *list[T]) selectFirstItem() {
- inx := l.firstSelectableItemBelow(-1)
- if inx != ItemNotFound {
- l.selectedItemIdx = inx
- }
-}
-
-func (l *list[T]) selectLastItem() {
- inx := l.firstSelectableItemAbove(len(l.items))
- if inx != ItemNotFound {
- l.selectedItemIdx = inx
- }
-}
-
-func (l *list[T]) firstSelectableItemAbove(inx int) int {
- unfocusableCount := 0
- for i := inx - 1; i >= 0; i-- {
- if i < 0 || i >= len(l.items) {
- continue
- }
-
- item := l.items[i]
- if _, ok := any(item).(layout.Focusable); ok {
- return i
- }
- unfocusableCount++
- }
- if unfocusableCount == inx && l.wrap {
- return l.firstSelectableItemAbove(len(l.items))
- }
- return ItemNotFound
-}
-
-func (l *list[T]) firstSelectableItemBelow(inx int) int {
- unfocusableCount := 0
- itemsLen := len(l.items)
- for i := inx + 1; i < itemsLen; i++ {
- if i < 0 || i >= len(l.items) {
- continue
- }
-
- item := l.items[i]
- if _, ok := any(item).(layout.Focusable); ok {
- return i
- }
- unfocusableCount++
- }
- if unfocusableCount == itemsLen-inx-1 && l.wrap {
- return l.firstSelectableItemBelow(-1)
- }
- return ItemNotFound
-}
-
-func (l *list[T]) focusSelectedItem() tea.Cmd {
- if l.selectedItemIdx < 0 || !l.focused {
- return nil
- }
- // Pre-allocate with expected capacity
- cmds := make([]tea.Cmd, 0, 2)
-
- // Blur the previously selected item if it's different
- if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) {
- prevItem := l.items[l.prevSelectedItemIdx]
- if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
- cmds = append(cmds, f.Blur())
- // Mark cache as needing update, but don't delete yet
- // This allows the render to potentially reuse it
- delete(l.renderedItems, prevItem.ID())
- }
- }
-
- // Focus the currently selected item
- if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
- item := l.items[l.selectedItemIdx]
- if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() {
- cmds = append(cmds, f.Focus())
- // Mark for re-render
- delete(l.renderedItems, item.ID())
- }
- }
-
- l.prevSelectedItemIdx = l.selectedItemIdx
- return tea.Batch(cmds...)
-}
-
-func (l *list[T]) blurSelectedItem() tea.Cmd {
- if l.selectedItemIdx < 0 || l.focused {
- return nil
- }
-
- // Blur the currently selected item
- if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
- item := l.items[l.selectedItemIdx]
- if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
- delete(l.renderedItems, item.ID())
- return f.Blur()
- }
- }
-
- return nil
-}
-
-// renderFragment holds updated rendered view fragments
-type renderFragment struct {
- view string
- gap int
-}
-
-// renderIterator renders items starting from the specific index and limits height if limitHeight != -1
-// returns the last index and the rendered content so far
-// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
-func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
- // Pre-allocate fragments with expected capacity
- itemsLen := len(l.items)
- expectedFragments := itemsLen - startInx
- if limitHeight && l.height > 0 {
- expectedFragments = min(expectedFragments, l.height)
- }
- fragments := make([]renderFragment, 0, expectedFragments)
-
- currentContentHeight := lipgloss.Height(rendered) - 1
- finalIndex := itemsLen
-
- // first pass: accumulate all fragments to render until the height limit is
- // reached
- for i := startInx; i < itemsLen; i++ {
- if limitHeight && currentContentHeight >= l.height {
- finalIndex = i
- break
- }
- // cool way to go through the list in both directions
- inx := i
-
- if l.direction != DirectionForward {
- inx = (itemsLen - 1) - i
- }
-
- if inx < 0 || inx >= len(l.items) {
- continue
- }
-
- item := l.items[inx]
-
- var rItem renderedItem
- if cache, ok := l.renderedItems[item.ID()]; ok {
- rItem = cache
- } else {
- rItem = l.renderItem(item)
- rItem.start = currentContentHeight
- rItem.end = currentContentHeight + rItem.height - 1
- l.renderedItems[item.ID()] = rItem
- }
-
- gap := l.gap + 1
- if inx == itemsLen-1 {
- gap = 0
- }
-
- fragments = append(fragments, renderFragment{view: rItem.view, gap: gap})
-
- currentContentHeight = rItem.end + 1 + l.gap
- }
-
- // second pass: build rendered string efficiently
- var b strings.Builder
-
- // Pre-size the builder to reduce allocations
- estimatedSize := len(rendered)
- for _, f := range fragments {
- estimatedSize += len(f.view) + f.gap
- }
- b.Grow(estimatedSize)
-
- if l.direction == DirectionForward {
- b.WriteString(rendered)
- for i := range fragments {
- f := &fragments[i]
- b.WriteString(f.view)
- // Optimized gap writing using pre-allocated buffer
- if f.gap > 0 {
- if f.gap <= maxGapSize {
- b.WriteString(newlineBuffer[:f.gap])
- } else {
- b.WriteString(strings.Repeat("\n", f.gap))
- }
- }
- }
-
- return b.String(), finalIndex
- }
-
- // iterate backwards as fragments are in reversed order
- for i := len(fragments) - 1; i >= 0; i-- {
- f := &fragments[i]
- b.WriteString(f.view)
- // Optimized gap writing using pre-allocated buffer
- if f.gap > 0 {
- if f.gap <= maxGapSize {
- b.WriteString(newlineBuffer[:f.gap])
- } else {
- b.WriteString(strings.Repeat("\n", f.gap))
- }
- }
- }
- b.WriteString(rendered)
-
- return b.String(), finalIndex
-}
-
-func (l *list[T]) renderItem(item Item) renderedItem {
- view := item.View()
- return renderedItem{
- view: view,
- height: lipgloss.Height(view),
- }
-}
-
-// AppendItem implements List.
-func (l *list[T]) AppendItem(item T) tea.Cmd {
- // Pre-allocate with expected capacity
- cmds := make([]tea.Cmd, 0, 4)
- cmd := item.Init()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- newIndex := len(l.items)
- l.items = append(l.items, item)
- l.indexMap[item.ID()] = newIndex
-
- if l.width > 0 && l.height > 0 {
- cmd = item.SetSize(l.width, l.height)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- cmd = l.render()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- if l.direction == DirectionBackward {
- if l.offset == 0 {
- cmd = l.GoToBottom()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- } else {
- newItem, ok := l.renderedItems[item.ID()]
- if ok {
- newLines := newItem.height
- if len(l.items) > 1 {
- newLines += l.gap
- }
- l.offset = min(l.renderedHeight-1, l.offset+newLines)
- }
- }
- }
- return tea.Sequence(cmds...)
-}
-
-// Blur implements List.
-func (l *list[T]) Blur() tea.Cmd {
- l.focused = false
- return l.render()
-}
-
-// DeleteItem implements List.
-func (l *list[T]) DeleteItem(id string) tea.Cmd {
- inx, ok := l.indexMap[id]
- if !ok {
- return nil
- }
- l.items = append(l.items[:inx], l.items[inx+1:]...)
- delete(l.renderedItems, id)
- delete(l.indexMap, id)
-
- // Only update indices for items after the deleted one
- itemsLen := len(l.items)
- for i := inx; i < itemsLen; i++ {
- if i >= 0 && i < len(l.items) {
- item := l.items[i]
- l.indexMap[item.ID()] = i
- }
- }
-
- // Adjust selectedItemIdx if the deleted item was selected or before it
- if l.selectedItemIdx == inx {
- // Deleted item was selected, select the previous item if possible
- if inx > 0 {
- l.selectedItemIdx = inx - 1
- } else {
- l.selectedItemIdx = -1
- }
- } else if l.selectedItemIdx > inx {
- // Selected item is after the deleted one, shift index down
- l.selectedItemIdx--
- }
- cmd := l.render()
- if l.rendered != "" {
- if l.renderedHeight <= l.height {
- l.offset = 0
- } else {
- maxOffset := l.renderedHeight - l.height
- if l.offset > maxOffset {
- l.offset = maxOffset
- }
- }
- }
- return cmd
-}
-
-// Focus implements List.
-func (l *list[T]) Focus() tea.Cmd {
- l.focused = true
- return l.render()
-}
-
-// GetSize implements List.
-func (l *list[T]) GetSize() (int, int) {
- return l.width, l.height
-}
-
-// GoToBottom implements List.
-func (l *list[T]) GoToBottom() tea.Cmd {
- l.offset = 0
- l.selectedItemIdx = -1
- l.direction = DirectionBackward
- return l.render()
-}
-
-// GoToTop implements List.
-func (l *list[T]) GoToTop() tea.Cmd {
- l.offset = 0
- l.selectedItemIdx = -1
- l.direction = DirectionForward
- return l.render()
-}
-
-// IsFocused implements List.
-func (l *list[T]) IsFocused() bool {
- return l.focused
-}
-
-// Items implements List.
-func (l *list[T]) Items() []T {
- itemsLen := len(l.items)
- result := make([]T, 0, itemsLen)
- for i := range itemsLen {
- if i >= 0 && i < len(l.items) {
- item := l.items[i]
- result = append(result, item)
- }
- }
- return result
-}
-
-func (l *list[T]) incrementOffset(n int) {
- // no need for offset
- if l.renderedHeight <= l.height {
- return
- }
- maxOffset := l.renderedHeight - l.height
- n = min(n, maxOffset-l.offset)
- if n <= 0 {
- return
- }
- l.offset += n
- l.cachedViewDirty = true
-}
-
-func (l *list[T]) decrementOffset(n int) {
- n = min(n, l.offset)
- if n <= 0 {
- return
- }
- l.offset -= n
- if l.offset < 0 {
- l.offset = 0
- }
- l.cachedViewDirty = true
-}
-
-// MoveDown implements List.
-func (l *list[T]) MoveDown(n int) tea.Cmd {
- oldOffset := l.offset
- if l.direction == DirectionForward {
- l.incrementOffset(n)
- } else {
- l.decrementOffset(n)
- }
-
- if oldOffset == l.offset {
- // no change in offset, so no need to change selection
- return nil
- }
- // if we are not actively selecting move the whole selection down
- if l.hasSelection() && !l.selectionActive {
- if l.selectionStartLine < l.selectionEndLine {
- l.selectionStartLine -= n
- l.selectionEndLine -= n
- } else {
- l.selectionStartLine -= n
- l.selectionEndLine -= n
- }
- }
- if l.selectionActive {
- if l.selectionStartLine < l.selectionEndLine {
- l.selectionStartLine -= n
- } else {
- l.selectionEndLine -= n
- }
- }
- return l.changeSelectionWhenScrolling()
-}
-
-// MoveUp implements List.
-func (l *list[T]) MoveUp(n int) tea.Cmd {
- oldOffset := l.offset
- if l.direction == DirectionForward {
- l.decrementOffset(n)
- } else {
- l.incrementOffset(n)
- }
-
- if oldOffset == l.offset {
- // no change in offset, so no need to change selection
- return nil
- }
-
- if l.hasSelection() && !l.selectionActive {
- if l.selectionStartLine > l.selectionEndLine {
- l.selectionStartLine += n
- l.selectionEndLine += n
- } else {
- l.selectionStartLine += n
- l.selectionEndLine += n
- }
- }
- if l.selectionActive {
- if l.selectionStartLine > l.selectionEndLine {
- l.selectionStartLine += n
- } else {
- l.selectionEndLine += n
- }
- }
- return l.changeSelectionWhenScrolling()
-}
-
-// PrependItem implements List.
-func (l *list[T]) PrependItem(item T) tea.Cmd {
- // Pre-allocate with expected capacity
- cmds := make([]tea.Cmd, 0, 4)
- cmds = append(cmds, item.Init())
-
- l.items = append([]T{item}, l.items...)
-
- // Shift selectedItemIdx since all items moved down by 1
- if l.selectedItemIdx >= 0 {
- l.selectedItemIdx++
- }
-
- // Update index map incrementally: shift all existing indices up by 1
- // This is more efficient than rebuilding from scratch
- newIndexMap := make(map[string]int, len(l.indexMap)+1)
- for id, idx := range l.indexMap {
- newIndexMap[id] = idx + 1 // All existing items shift down by 1
- }
- newIndexMap[item.ID()] = 0 // New item is at index 0
- l.indexMap = newIndexMap
-
- if l.width > 0 && l.height > 0 {
- cmds = append(cmds, item.SetSize(l.width, l.height))
- }
- cmds = append(cmds, l.render())
- if l.direction == DirectionForward {
- if l.offset == 0 {
- cmd := l.GoToTop()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- } else {
- newItem, ok := l.renderedItems[item.ID()]
- if ok {
- newLines := newItem.height
- if len(l.items) > 1 {
- newLines += l.gap
- }
- l.offset = min(l.renderedHeight-1, l.offset+newLines)
- }
- }
- }
- return tea.Batch(cmds...)
-}
-
-// SelectItemAbove implements List.
-func (l *list[T]) SelectItemAbove() tea.Cmd {
- if l.selectedItemIdx < 0 {
- return nil
- }
-
- newIndex := l.firstSelectableItemAbove(l.selectedItemIdx)
- if newIndex == ItemNotFound {
- // no item above
- return nil
- }
- // Pre-allocate with expected capacity
- cmds := make([]tea.Cmd, 0, 2)
- if newIndex > l.selectedItemIdx && l.selectedItemIdx > 0 && l.offset > 0 {
- // this means there is a section above and not showing on the top, move to the top
- newIndex = l.selectedItemIdx
- cmd := l.GoToTop()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- if newIndex == 1 {
- peakAboveIndex := l.firstSelectableItemAbove(newIndex)
- if peakAboveIndex == ItemNotFound {
- // this means there is a section above move to the top
- cmd := l.GoToTop()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- if newIndex < 0 || newIndex >= len(l.items) {
- return nil
- }
- l.prevSelectedItemIdx = l.selectedItemIdx
- l.selectedItemIdx = newIndex
- l.movingByItem = true
- renderCmd := l.render()
- if renderCmd != nil {
- cmds = append(cmds, renderCmd)
- }
- return tea.Sequence(cmds...)
-}
-
-// SelectItemBelow implements List.
-func (l *list[T]) SelectItemBelow() tea.Cmd {
- if l.selectedItemIdx < 0 {
- return nil
- }
-
- newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
- if newIndex == ItemNotFound {
- // no item below
- return nil
- }
- if newIndex < 0 || newIndex >= len(l.items) {
- return nil
- }
- if newIndex < l.selectedItemIdx {
- // reset offset when wrap to the top to show the top section if it exists
- l.offset = 0
- }
- l.prevSelectedItemIdx = l.selectedItemIdx
- l.selectedItemIdx = newIndex
- l.movingByItem = true
- return l.render()
-}
-
-// SelectedItem implements List.
-func (l *list[T]) SelectedItem() *T {
- if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
- return nil
- }
- item := l.items[l.selectedItemIdx]
- return &item
-}
-
-// SetItems implements List.
-func (l *list[T]) SetItems(items []T) tea.Cmd {
- l.items = items
- var cmds []tea.Cmd
- for inx, item := range items {
- if i, ok := any(item).(Indexable); ok {
- i.SetIndex(inx)
- }
- cmds = append(cmds, item.Init())
- }
- cmds = append(cmds, l.reset(""))
- return tea.Batch(cmds...)
-}
-
-// SetSelected implements List.
-func (l *list[T]) SetSelected(id string) tea.Cmd {
- l.prevSelectedItemIdx = l.selectedItemIdx
- if idx, ok := l.indexMap[id]; ok {
- l.selectedItemIdx = idx
- } else {
- l.selectedItemIdx = -1
- }
- return l.render()
-}
-
-func (l *list[T]) reset(selectedItemID string) tea.Cmd {
- var cmds []tea.Cmd
- l.rendered = ""
- l.renderedHeight = 0
- l.offset = 0
- l.indexMap = make(map[string]int)
- l.renderedItems = make(map[string]renderedItem)
- itemsLen := len(l.items)
- for i := range itemsLen {
- if i < 0 || i >= len(l.items) {
- continue
- }
-
- item := l.items[i]
- l.indexMap[item.ID()] = i
- if l.width > 0 && l.height > 0 {
- cmds = append(cmds, item.SetSize(l.width, l.height))
- }
- }
- // Convert selectedItemID to index after rebuilding indexMap
- if selectedItemID != "" {
- if idx, ok := l.indexMap[selectedItemID]; ok {
- l.selectedItemIdx = idx
- } else {
- l.selectedItemIdx = -1
- }
- } else {
- l.selectedItemIdx = -1
- }
- cmds = append(cmds, l.render())
- return tea.Batch(cmds...)
-}
-
-// SetSize implements List.
-func (l *list[T]) SetSize(width int, height int) tea.Cmd {
- oldWidth := l.width
- oldHeight := l.height
- l.width = width
- l.height = height
- // Invalidate cache if height changed
- if oldHeight != height {
- l.cachedViewDirty = true
- }
- if oldWidth != width {
- // Get current selected item ID before reset
- selectedID := ""
- if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
- item := l.items[l.selectedItemIdx]
- selectedID = item.ID()
- }
- cmd := l.reset(selectedID)
- return cmd
- }
- return nil
-}
-
-// UpdateItem implements List.
-func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
- // Pre-allocate with expected capacity
- cmds := make([]tea.Cmd, 0, 1)
- if inx, ok := l.indexMap[id]; ok {
- l.items[inx] = item
- oldItem, hasOldItem := l.renderedItems[id]
- oldPosition := l.offset
- if l.direction == DirectionBackward {
- oldPosition = (l.renderedHeight - 1) - l.offset
- }
-
- delete(l.renderedItems, id)
- cmd := l.render()
-
- // need to check for nil because of sequence not handling nil
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- if hasOldItem && l.direction == DirectionBackward {
- // if we are the last item and there is no offset
- // make sure to go to the bottom
- if oldPosition < oldItem.end {
- newItem, ok := l.renderedItems[item.ID()]
- if ok {
- newLines := newItem.height - oldItem.height
- l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
- }
- }
- } else if hasOldItem && l.offset > oldItem.start {
- newItem, ok := l.renderedItems[item.ID()]
- if ok {
- newLines := newItem.height - oldItem.height
- l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
- }
- }
- }
- return tea.Sequence(cmds...)
-}
-
-func (l *list[T]) hasSelection() bool {
- return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
-}
-
-// StartSelection implements List.
-func (l *list[T]) StartSelection(col, line int) {
- l.selectionStartCol = col
- l.selectionStartLine = line
- l.selectionEndCol = col
- l.selectionEndLine = line
- l.selectionActive = true
-}
-
-// EndSelection implements List.
-func (l *list[T]) EndSelection(col, line int) {
- if !l.selectionActive {
- return
- }
- l.selectionEndCol = col
- l.selectionEndLine = line
-}
-
-func (l *list[T]) SelectionStop() {
- l.selectionActive = false
-}
-
-func (l *list[T]) SelectionClear() {
- l.selectionStartCol = -1
- l.selectionStartLine = -1
- l.selectionEndCol = -1
- l.selectionEndLine = -1
- l.selectionActive = false
-}
-
-func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
- numLines := l.lineCount()
-
- if l.direction == DirectionBackward && numLines > l.height {
- line = ((numLines - 1) - l.height) + line + 1
- }
-
- if l.offset > 0 {
- if l.direction == DirectionBackward {
- line -= l.offset
- } else {
- line += l.offset
- }
- }
-
- if line < 0 || line >= numLines {
- return 0, 0
- }
-
- currentLine := ansi.Strip(l.getLine(line))
- gr := uniseg.NewGraphemes(currentLine)
- startCol = -1
- upTo := col
- for gr.Next() {
- if gr.IsWordBoundary() && upTo > 0 {
- startCol = col - upTo + 1
- } else if gr.IsWordBoundary() && upTo < 0 {
- endCol = col - upTo + 1
- break
- }
- if upTo == 0 && gr.Str() == " " {
- return 0, 0
- }
- upTo -= 1
- }
- if startCol == -1 {
- return 0, 0
- }
- return startCol, endCol
-}
-
-func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
- // Helper function to get a line with ANSI stripped and icons replaced
- getCleanLine := func(index int) string {
- rawLine := l.getLine(index)
- cleanLine := ansi.Strip(rawLine)
- for _, icon := range styles.SelectionIgnoreIcons {
- cleanLine = strings.ReplaceAll(cleanLine, icon, " ")
- }
- return cleanLine
- }
-
- numLines := l.lineCount()
- if l.direction == DirectionBackward && numLines > l.height {
- line = (numLines - 1) - l.height + line + 1
- }
-
- if l.offset > 0 {
- if l.direction == DirectionBackward {
- line -= l.offset
- } else {
- line += l.offset
- }
- }
-
- // Ensure line is within bounds
- if line < 0 || line >= numLines {
- return 0, 0, false
- }
-
- if strings.TrimSpace(getCleanLine(line)) == "" {
- return 0, 0, false
- }
-
- // Find start of paragraph (search backwards for empty line or start of text)
- startLine = line
- for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" {
- startLine--
- }
-
- // Find end of paragraph (search forwards for empty line or end of text)
- endLine = line
- for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" {
- endLine++
- }
-
- // revert the line numbers if we are in backward direction
- if l.direction == DirectionBackward && numLines > l.height {
- startLine = startLine - (numLines - 1) + l.height - 1
- endLine = endLine - (numLines - 1) + l.height - 1
- }
- if l.offset > 0 {
- if l.direction == DirectionBackward {
- startLine += l.offset
- endLine += l.offset
- } else {
- startLine -= l.offset
- endLine -= l.offset
- }
- }
- return startLine, endLine, true
-}
-
-// SelectWord selects the word at the given position.
-func (l *list[T]) SelectWord(col, line int) {
- startCol, endCol := l.findWordBoundaries(col, line)
- l.selectionStartCol = startCol
- l.selectionStartLine = line
- l.selectionEndCol = endCol
- l.selectionEndLine = line
- l.selectionActive = false // Not actively selecting, just selected
-}
-
-// SelectParagraph selects the paragraph at the given position.
-func (l *list[T]) SelectParagraph(col, line int) {
- startLine, endLine, found := l.findParagraphBoundaries(line)
- if !found {
- return
- }
- l.selectionStartCol = 0
- l.selectionStartLine = startLine
- l.selectionEndCol = l.width - 1
- l.selectionEndLine = endLine
- l.selectionActive = false // Not actively selecting, just selected
-}
-
-// HasSelection returns whether there is an active selection.
-func (l *list[T]) HasSelection() bool {
- return l.hasSelection()
-}
-
-func (l *list[T]) selectionArea(absolute bool) uv.Rectangle {
- var startY int
- if absolute {
- startY, _ = l.viewPosition()
- }
- selArea := uv.Rectangle{
- Min: uv.Pos(l.selectionStartCol, l.selectionStartLine+startY),
- Max: uv.Pos(l.selectionEndCol, l.selectionEndLine+startY),
- }
- selArea = selArea.Canon()
- selArea.Max.Y++ // make max Y exclusive
- return selArea
-}
-
-// GetSelectedText returns the currently selected text.
-func (l *list[T]) GetSelectedText(paddingLeft int) string {
- if !l.hasSelection() {
- return ""
- }
-
- selArea := l.selectionArea(true)
- if selArea.Empty() {
- return ""
- }
-
- selectionHeight := selArea.Dy()
-
- tempBuf := uv.NewScreenBuffer(l.width, selectionHeight)
- tempBufArea := tempBuf.Bounds()
- renderedLines := l.getLines(selArea.Min.Y, selArea.Max.Y)
- styled := uv.NewStyledString(renderedLines)
- styled.Draw(tempBuf, tempBufArea)
-
- // XXX: Left padding assumes the list component is rendered with absolute
- // positioning. The chat component has a left margin of 1 and items in the
- // list have a border of 1 plus a padding of 1. The paddingLeft parameter
- // assumes this total left padding of 3 and we should fix that.
- leftBorder := paddingLeft - 1
-
- var b strings.Builder
- for y := tempBufArea.Min.Y; y < tempBufArea.Max.Y; y++ {
- var pending strings.Builder
- for x := tempBufArea.Min.X + leftBorder; x < tempBufArea.Max.X; {
- cell := tempBuf.CellAt(x, y)
- if cell == nil || cell.IsZero() {
- x++
- continue
- }
- if y == 0 && x < selArea.Min.X {
- x++
- continue
- }
- if y == selectionHeight-1 && x > selArea.Max.X-1 {
- break
- }
- if cell.Width == 1 && cell.Content == " " {
- pending.WriteString(cell.Content)
- x++
- continue
- }
- b.WriteString(pending.String())
- pending.Reset()
- b.WriteString(cell.Content)
- x += cell.Width
- }
- if y < tempBufArea.Max.Y-1 {
- b.WriteByte('\n')
- }
- }
-
- return b.String()
-}
@@ -1,653 +0,0 @@
-package list
-
-import (
- "fmt"
- "strings"
- "testing"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/x/exp/golden"
- "github.com/google/uuid"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestList(t *testing.T) {
- t.Parallel()
- t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 5 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
- execCmd(l, l.Init())
-
- // should select the last item
- assert.Equal(t, 0, l.selectedItemIdx)
- assert.Equal(t, 0, l.offset)
- require.Equal(t, 5, len(l.indexMap))
- require.Equal(t, 5, len(l.items))
- require.Equal(t, 5, len(l.renderedItems))
- assert.Equal(t, 5, lipgloss.Height(l.rendered))
- assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
- start, end := l.viewPosition()
- assert.Equal(t, 0, start)
- assert.Equal(t, 4, end)
- for i := range 5 {
- item, ok := l.renderedItems[items[i].ID()]
- require.True(t, ok)
- assert.Equal(t, i, item.start)
- assert.Equal(t, i, item.end)
- }
-
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 5 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
- execCmd(l, l.Init())
-
- // should select the last item
- assert.Equal(t, 4, l.selectedItemIdx)
- assert.Equal(t, 0, l.offset)
- require.Equal(t, 5, len(l.indexMap))
- require.Equal(t, 5, len(l.items))
- require.Equal(t, 5, len(l.renderedItems))
- assert.Equal(t, 5, lipgloss.Height(l.rendered))
- assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
- start, end := l.viewPosition()
- assert.Equal(t, 0, start)
- assert.Equal(t, 4, end)
- for i := range 5 {
- item, ok := l.renderedItems[items[i].ID()]
- require.True(t, ok)
- assert.Equal(t, i, item.start)
- assert.Equal(t, i, item.end)
- }
-
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- // should select the last item
- assert.Equal(t, 0, l.selectedItemIdx)
- assert.Equal(t, 0, l.offset)
- require.Equal(t, 30, len(l.indexMap))
- require.Equal(t, 30, len(l.items))
- require.Equal(t, 30, len(l.renderedItems))
- assert.Equal(t, 30, lipgloss.Height(l.rendered))
- assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
- start, end := l.viewPosition()
- assert.Equal(t, 0, start)
- assert.Equal(t, 9, end)
- for i := range 30 {
- item, ok := l.renderedItems[items[i].ID()]
- require.True(t, ok)
- assert.Equal(t, i, item.start)
- assert.Equal(t, i, item.end)
- }
-
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- // should select the last item
- assert.Equal(t, 29, l.selectedItemIdx)
- assert.Equal(t, 0, l.offset)
- require.Equal(t, 30, len(l.indexMap))
- require.Equal(t, 30, len(l.items))
- require.Equal(t, 30, len(l.renderedItems))
- assert.Equal(t, 30, lipgloss.Height(l.rendered))
- assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
- start, end := l.viewPosition()
- assert.Equal(t, 20, start)
- assert.Equal(t, 29, end)
- for i := range 30 {
- item, ok := l.renderedItems[items[i].ID()]
- require.True(t, ok)
- assert.Equal(t, i, item.start)
- assert.Equal(t, i, item.end)
- }
-
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
- content = strings.TrimSuffix(content, "\n")
- item := NewSelectableItem(content)
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- // should select the last item
- assert.Equal(t, 0, l.selectedItemIdx)
- assert.Equal(t, 0, l.offset)
- require.Equal(t, 30, len(l.indexMap))
- require.Equal(t, 30, len(l.items))
- require.Equal(t, 30, len(l.renderedItems))
- expectedLines := 0
- for i := range 30 {
- expectedLines += (i + 1) * 1
- }
- assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
- assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
- start, end := l.viewPosition()
- assert.Equal(t, 0, start)
- assert.Equal(t, 9, end)
- currentPosition := 0
- for i := range 30 {
- rItem, ok := l.renderedItems[items[i].ID()]
- require.True(t, ok)
- assert.Equal(t, currentPosition, rItem.start)
- assert.Equal(t, currentPosition+i, rItem.end)
- currentPosition += i + 1
- }
-
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
- content = strings.TrimSuffix(content, "\n")
- item := NewSelectableItem(content)
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- // should select the last item
- assert.Equal(t, 29, l.selectedItemIdx)
- assert.Equal(t, 0, l.offset)
- require.Equal(t, 30, len(l.indexMap))
- require.Equal(t, 30, len(l.items))
- require.Equal(t, 30, len(l.renderedItems))
- expectedLines := 0
- for i := range 30 {
- expectedLines += (i + 1) * 1
- }
- assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
- assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
- start, end := l.viewPosition()
- assert.Equal(t, expectedLines-10, start)
- assert.Equal(t, expectedLines-1, end)
- currentPosition := 0
- for i := range 30 {
- rItem, ok := l.renderedItems[items[i].ID()]
- require.True(t, ok)
- assert.Equal(t, currentPosition, rItem.start)
- assert.Equal(t, currentPosition+i, rItem.end)
- currentPosition += i + 1
- }
-
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should go to selected item at the beginning", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
- content = strings.TrimSuffix(content, "\n")
- item := NewSelectableItem(content)
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
- execCmd(l, l.Init())
-
- // should select the last item
- assert.Equal(t, 10, l.selectedItemIdx)
-
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
- content = strings.TrimSuffix(content, "\n")
- item := NewSelectableItem(content)
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
- execCmd(l, l.Init())
-
- // should select the last item
- assert.Equal(t, 10, l.selectedItemIdx)
-
- golden.RequireEqual(t, []byte(l.View()))
- })
-}
-
-func TestListMovement(t *testing.T) {
- t.Parallel()
- t.Run("should move viewport up", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
- content = strings.TrimSuffix(content, "\n")
- item := NewSelectableItem(content)
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveUp(25))
-
- assert.Equal(t, 25, l.offset)
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should move viewport up and down", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
- content = strings.TrimSuffix(content, "\n")
- item := NewSelectableItem(content)
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveUp(25))
- execCmd(l, l.MoveDown(25))
-
- assert.Equal(t, 0, l.offset)
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should move viewport down", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
- content = strings.TrimSuffix(content, "\n")
- item := NewSelectableItem(content)
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveDown(25))
-
- assert.Equal(t, 25, l.offset)
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should move viewport down and up", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
- content = strings.TrimSuffix(content, "\n")
- item := NewSelectableItem(content)
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveDown(25))
- execCmd(l, l.MoveUp(25))
-
- assert.Equal(t, 0, l.offset)
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
- content = strings.TrimSuffix(content, "\n")
- item := NewSelectableItem(content)
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
- execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
-
- assert.Equal(t, 0, l.offset)
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveUp(2))
- viewBefore := l.View()
- execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
- viewAfter := l.View()
- assert.Equal(t, viewBefore, viewAfter)
- assert.Equal(t, 5, l.offset)
- assert.Equal(t, 33, lipgloss.Height(l.rendered))
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveUp(2))
- viewBefore := l.View()
- item := items[29]
- execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
- viewAfter := l.View()
- assert.Equal(t, viewBefore, viewAfter)
- assert.Equal(t, 4, l.offset)
- assert.Equal(t, 32, lipgloss.Height(l.rendered))
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
- l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveUp(2))
- viewBefore := l.View()
- item := items[30]
- execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
- viewAfter := l.View()
- assert.Equal(t, viewBefore, viewAfter)
- assert.Equal(t, 0, l.offset)
- assert.Equal(t, 31, lipgloss.Height(l.rendered))
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveUp(2))
- viewBefore := l.View()
- item := items[1]
- execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
- viewAfter := l.View()
- assert.Equal(t, viewBefore, viewAfter)
- assert.Equal(t, 2, l.offset)
- assert.Equal(t, 32, lipgloss.Height(l.rendered))
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveUp(2))
- viewBefore := l.View()
- execCmd(l, l.PrependItem(NewSelectableItem("New")))
- viewAfter := l.View()
- assert.Equal(t, viewBefore, viewAfter)
- assert.Equal(t, 2, l.offset)
- assert.Equal(t, 31, lipgloss.Height(l.rendered))
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
- content = strings.TrimSuffix(content, "\n")
- item := NewSelectableItem(content)
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
- execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
-
- assert.Equal(t, 0, l.offset)
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveDown(2))
- viewBefore := l.View()
- execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
- viewAfter := l.View()
- assert.Equal(t, viewBefore, viewAfter)
- assert.Equal(t, 5, l.offset)
- assert.Equal(t, 33, lipgloss.Height(l.rendered))
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveDown(2))
- viewBefore := l.View()
- item := items[0]
- execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
- viewAfter := l.View()
- assert.Equal(t, viewBefore, viewAfter)
- assert.Equal(t, 4, l.offset)
- assert.Equal(t, 32, lipgloss.Height(l.rendered))
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveDown(3))
- viewBefore := l.View()
- item := items[0]
- execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
- viewAfter := l.View()
- assert.Equal(t, viewBefore, viewAfter)
- assert.Equal(t, 1, l.offset)
- assert.Equal(t, 31, lipgloss.Height(l.rendered))
- golden.RequireEqual(t, []byte(l.View()))
- })
-
- t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveDown(2))
- viewBefore := l.View()
- item := items[29]
- execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
- viewAfter := l.View()
- assert.Equal(t, viewBefore, viewAfter)
- assert.Equal(t, 2, l.offset)
- assert.Equal(t, 32, lipgloss.Height(l.rendered))
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 30 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
- execCmd(l, l.Init())
-
- execCmd(l, l.MoveDown(2))
- viewBefore := l.View()
- execCmd(l, l.AppendItem(NewSelectableItem("New")))
- viewAfter := l.View()
- assert.Equal(t, viewBefore, viewAfter)
- assert.Equal(t, 2, l.offset)
- assert.Equal(t, 31, lipgloss.Height(l.rendered))
- golden.RequireEqual(t, []byte(l.View()))
- })
-}
-
-type SelectableItem interface {
- Item
- layout.Focusable
-}
-
-type simpleItem struct {
- width int
- content string
- id string
-}
-type selectableItem struct {
- *simpleItem
- focused bool
-}
-
-func NewSimpleItem(content string) *simpleItem {
- return &simpleItem{
- id: uuid.NewString(),
- width: 0,
- content: content,
- }
-}
-
-func NewSelectableItem(content string) SelectableItem {
- return &selectableItem{
- simpleItem: NewSimpleItem(content),
- focused: false,
- }
-}
-
-func (s *simpleItem) ID() string {
- return s.id
-}
-
-func (s *simpleItem) Init() tea.Cmd {
- return nil
-}
-
-func (s *simpleItem) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- return s, nil
-}
-
-func (s *simpleItem) View() string {
- return lipgloss.NewStyle().Width(s.width).Render(s.content)
-}
-
-func (l *simpleItem) GetSize() (int, int) {
- return l.width, 0
-}
-
-// SetSize implements Item.
-func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
- s.width = width
- return nil
-}
-
-func (s *selectableItem) View() string {
- if s.focused {
- return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
- }
- return lipgloss.NewStyle().Width(s.width).Render(s.content)
-}
-
-// Blur implements SimpleItem.
-func (s *selectableItem) Blur() tea.Cmd {
- s.focused = false
- return nil
-}
-
-// Focus implements SimpleItem.
-func (s *selectableItem) Focus() tea.Cmd {
- s.focused = true
- return nil
-}
-
-// IsFocused implements SimpleItem.
-func (s *selectableItem) IsFocused() bool {
- return s.focused
-}
-
-func execCmd(m util.Model, cmd tea.Cmd) {
- for cmd != nil {
- msg := cmd()
- m, cmd = m.Update(msg)
- }
-}
@@ -1,10 +0,0 @@
-[38;2;223;219;221m[38;2;104;255;214m> [m[38;2;96;95;107mT[m[38;2;96;95;107mype to filter[m[38;2;96;95;107m [m[m
-[38;2;223;219;221mβItem 0 [m
-[38;2;223;219;221mItem 1 [m
-[38;2;223;219;221mItem 2 [m
-[38;2;223;219;221mItem 3 [m
-[38;2;223;219;221mItem 4 [m
-
-
-
-
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
-[38;2;223;219;221mβItem 10[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 0[m
-[38;2;223;219;221mItem 1[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 4[m
-[38;2;223;219;221mItem 5[m
-[38;2;223;219;221mItem 6[m
-[38;2;223;219;221mItem 7[m
-[38;2;223;219;221mItem 8[m
-[38;2;223;219;221mItem 9[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 0[m
-[38;2;223;219;221mItem 1[m
-[38;2;223;219;221mItem 1[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 3[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mItem 20[m
-[38;2;223;219;221mItem 21[m
-[38;2;223;219;221mItem 22[m
-[38;2;223;219;221mItem 23[m
-[38;2;223;219;221mItem 24[m
-[38;2;223;219;221mItem 25[m
-[38;2;223;219;221mItem 26[m
-[38;2;223;219;221mItem 27[m
-[38;2;223;219;221mItem 28[m
-[38;2;223;219;221mβItem 29[m
@@ -1,20 +0,0 @@
-[38;2;223;219;221mβItem 0[m
-[38;2;223;219;221mItem 1[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 4[m
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -1,20 +0,0 @@
-[38;2;223;219;221mItem 0[m
-[38;2;223;219;221mItem 1[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mβItem 4[m
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -1,10 +0,0 @@
-[38;2;223;219;221mItem 6[m
-[38;2;223;219;221mItem 6[m
-[38;2;223;219;221mItem 6[m
-[38;2;223;219;221mβItem 7[m
-[38;2;223;219;221mβItem 7[m
-[38;2;223;219;221mβItem 7[m
-[38;2;223;219;221mβItem 7[m
-[38;2;223;219;221mβItem 7[m
-[38;2;223;219;221mβItem 7[m
-[38;2;223;219;221mβItem 7[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mItem 0[m
-[38;2;223;219;221mItem 1[m
-[38;2;223;219;221mItem 1[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mβItem 3[m
-[38;2;223;219;221mβItem 3[m
-[38;2;223;219;221mβItem 3[m
-[38;2;223;219;221mβItem 3[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 28[m
-[38;2;223;219;221mβItem 28[m
-[38;2;223;219;221mβItem 28[m
-[38;2;223;219;221mβItem 28[m
-[38;2;223;219;221mβItem 28[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mβItem 29[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mItem 29[m
-[38;2;223;219;221mβTesting [m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβTesting [m
-[38;2;223;219;221mItem 0[m
-[38;2;223;219;221mItem 1[m
-[38;2;223;219;221mItem 1[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 3[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 2[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 4[m
-[38;2;223;219;221mItem 5[m
-[38;2;223;219;221mItem 6[m
-[38;2;223;219;221mItem 7[m
-[38;2;223;219;221mItem 8[m
-[38;2;223;219;221mItem 9[m
-[38;2;223;219;221mItem 10[m
-[38;2;223;219;221mItem 11[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mItem 18[m
-[38;2;223;219;221mItem 19[m
-[38;2;223;219;221mItem 20[m
-[38;2;223;219;221mItem 21[m
-[38;2;223;219;221mItem 22[m
-[38;2;223;219;221mItem 23[m
-[38;2;223;219;221mItem 24[m
-[38;2;223;219;221mItem 25[m
-[38;2;223;219;221mItem 26[m
-[38;2;223;219;221mβItem 27[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 2[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 4[m
-[38;2;223;219;221mItem 5[m
-[38;2;223;219;221mItem 6[m
-[38;2;223;219;221mItem 7[m
-[38;2;223;219;221mItem 8[m
-[38;2;223;219;221mItem 9[m
-[38;2;223;219;221mItem 10[m
-[38;2;223;219;221mItem 11[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mItem 18[m
-[38;2;223;219;221mItem 19[m
-[38;2;223;219;221mItem 20[m
-[38;2;223;219;221mItem 21[m
-[38;2;223;219;221mItem 22[m
-[38;2;223;219;221mItem 23[m
-[38;2;223;219;221mItem 24[m
-[38;2;223;219;221mItem 25[m
-[38;2;223;219;221mItem 26[m
-[38;2;223;219;221mβItem 27[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 0[m
-[38;2;223;219;221mItem 1[m
-[38;2;223;219;221mItem 2[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 4[m
-[38;2;223;219;221mItem 5[m
-[38;2;223;219;221mItem 6[m
-[38;2;223;219;221mItem 7[m
-[38;2;223;219;221mItem 8[m
-[38;2;223;219;221mItem 9[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mItem 18[m
-[38;2;223;219;221mItem 19[m
-[38;2;223;219;221mItem 20[m
-[38;2;223;219;221mItem 21[m
-[38;2;223;219;221mItem 22[m
-[38;2;223;219;221mItem 23[m
-[38;2;223;219;221mItem 24[m
-[38;2;223;219;221mItem 25[m
-[38;2;223;219;221mItem 26[m
-[38;2;223;219;221mβItem 27[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 2[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 4[m
-[38;2;223;219;221mItem 5[m
-[38;2;223;219;221mItem 6[m
-[38;2;223;219;221mItem 7[m
-[38;2;223;219;221mItem 8[m
-[38;2;223;219;221mItem 9[m
-[38;2;223;219;221mItem 10[m
-[38;2;223;219;221mItem 11[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mItem 21[m
-[38;2;223;219;221mItem 22[m
-[38;2;223;219;221mItem 23[m
-[38;2;223;219;221mItem 24[m
-[38;2;223;219;221mItem 25[m
-[38;2;223;219;221mItem 26[m
-[38;2;223;219;221mItem 27[m
-[38;2;223;219;221mItem 28[m
-[38;2;223;219;221mβItem 29[m
-[38;2;223;219;221mItem 30[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mItem 18[m
-[38;2;223;219;221mItem 19[m
-[38;2;223;219;221mItem 20[m
-[38;2;223;219;221mItem 21[m
-[38;2;223;219;221mItem 22[m
-[38;2;223;219;221mItem 23[m
-[38;2;223;219;221mItem 24[m
-[38;2;223;219;221mItem 25[m
-[38;2;223;219;221mItem 26[m
-[38;2;223;219;221mβItem 27[m
@@ -1,10 +0,0 @@
-[38;2;223;219;221mβItem 2[m
-[38;2;223;219;221mItem 3[m
-[38;2;223;219;221mItem 4[m
-[38;2;223;219;221mItem 5[m
-[38;2;223;219;221mItem 6[m
-[38;2;223;219;221mItem 7[m
-[38;2;223;219;221mItem 8[m
-[38;2;223;219;221mItem 9[m
-[38;2;223;219;221mItem 10[m
-[38;2;223;219;221mItem 11[m
@@ -1,54 +0,0 @@
-package highlight
-
-import (
- "bytes"
- "image/color"
-
- "github.com/alecthomas/chroma/v2"
- "github.com/alecthomas/chroma/v2/formatters"
- "github.com/alecthomas/chroma/v2/lexers"
- chromaStyles "github.com/alecthomas/chroma/v2/styles"
- "github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
- // Determine the language lexer to use
- l := lexers.Match(fileName)
- if l == nil {
- l = lexers.Analyse(source)
- }
- if l == nil {
- l = lexers.Fallback
- }
- l = chroma.Coalesce(l)
-
- // Get the formatter
- f := formatters.Get("terminal16m")
- if f == nil {
- f = formatters.Fallback
- }
-
- style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
-
- // Modify the style to use the provided background
- s, err := style.Builder().Transform(
- func(t chroma.StyleEntry) chroma.StyleEntry {
- r, g, b, _ := bg.RGBA()
- t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
- return t
- },
- ).Build()
- if err != nil {
- s = chromaStyles.Fallback
- }
-
- // Tokenize and format
- it, err := l.Tokenise(nil, source)
- if err != nil {
- return "", err
- }
-
- var buf bytes.Buffer
- err = f.Format(&buf, s, it)
- return buf.String(), err
-}
@@ -1,45 +0,0 @@
-package tui
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
- Quit key.Binding
- Help key.Binding
- Commands key.Binding
- Suspend key.Binding
- Models key.Binding
- Sessions key.Binding
-
- pageBindings []key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- Quit: key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("ctrl+c", "quit"),
- ),
- Help: key.NewBinding(
- key.WithKeys("ctrl+g"),
- key.WithHelp("ctrl+g", "more"),
- ),
- Commands: key.NewBinding(
- key.WithKeys("ctrl+p"),
- key.WithHelp("ctrl+p", "commands"),
- ),
- Suspend: key.NewBinding(
- key.WithKeys("ctrl+z"),
- key.WithHelp("ctrl+z", "suspend"),
- ),
- Models: key.NewBinding(
- key.WithKeys("ctrl+l", "ctrl+m"),
- key.WithHelp("ctrl+l", "models"),
- ),
- Sessions: key.NewBinding(
- key.WithKeys("ctrl+s"),
- key.WithHelp("ctrl+s", "sessions"),
- ),
- }
-}
@@ -1,1407 +0,0 @@
-package chat
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/spinner"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/app"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/history"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/permission"
- "github.com/charmbracelet/crush/internal/pubsub"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/tui/components/anim"
- "github.com/charmbracelet/crush/internal/tui/components/chat"
- "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
- "github.com/charmbracelet/crush/internal/tui/components/chat/header"
- "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
- "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
- "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
- "github.com/charmbracelet/crush/internal/tui/components/completions"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/copilot"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/hyper"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
- "github.com/charmbracelet/crush/internal/tui/page"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/crush/internal/version"
-)
-
-var ChatPageID page.PageID = "chat"
-
-type (
- ChatFocusedMsg struct {
- Focused bool
- }
- CancelTimerExpiredMsg struct{}
-)
-
-type PanelType string
-
-const (
- PanelTypeChat PanelType = "chat"
- PanelTypeEditor PanelType = "editor"
- PanelTypeSplash PanelType = "splash"
-)
-
-// PillSection represents which pill section is focused when in pills panel.
-type PillSection int
-
-const (
- PillSectionTodos PillSection = iota
- PillSectionQueue
-)
-
-const (
- CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode
- CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode
- EditorHeight = 5 // Height of the editor input area including padding
- SideBarWidth = 31 // Width of the sidebar
- SideBarDetailsPadding = 1 // Padding for the sidebar details section
- HeaderHeight = 1 // Height of the header
-
- // Layout constants for borders and padding
- BorderWidth = 1 // Width of component borders
- LeftRightBorders = 2 // Left + right border width (1 + 1)
- TopBottomBorders = 2 // Top + bottom border width (1 + 1)
- DetailsPositioning = 2 // Positioning adjustment for details panel
-
- // Timing constants
- CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
-)
-
-type ChatPage interface {
- util.Model
- layout.Help
- IsChatFocused() bool
-}
-
-// cancelTimerCmd creates a command that expires the cancel timer
-func cancelTimerCmd() tea.Cmd {
- return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
- return CancelTimerExpiredMsg{}
- })
-}
-
-type chatPage struct {
- width, height int
- detailsWidth, detailsHeight int
- app *app.App
- keyboardEnhancements tea.KeyboardEnhancementsMsg
-
- // Layout state
- compact bool
- forceCompact bool
- focusedPane PanelType
-
- // Session
- session session.Session
- keyMap KeyMap
-
- // Components
- header header.Header
- sidebar sidebar.Sidebar
- chat chat.MessageListCmp
- editor editor.Editor
- splash splash.Splash
-
- // Simple state flags
- showingDetails bool
- isCanceling bool
- splashFullScreen bool
- isOnboarding bool
- isProjectInit bool
- promptQueue int
-
- // Pills state
- pillsExpanded bool
- focusedPillSection PillSection
-
- // Todo spinner
- todoSpinner spinner.Model
-}
-
-func New(app *app.App) ChatPage {
- t := styles.CurrentTheme()
- return &chatPage{
- app: app,
- keyMap: DefaultKeyMap(),
- header: header.New(app.LSPClients),
- sidebar: sidebar.New(app.History, app.LSPClients, false),
- chat: chat.New(app),
- editor: editor.New(app),
- splash: splash.New(),
- focusedPane: PanelTypeSplash,
- todoSpinner: spinner.New(
- spinner.WithSpinner(spinner.MiniDot),
- spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)),
- ),
- }
-}
-
-func (p *chatPage) Init() tea.Cmd {
- cfg := config.Get()
- compact := cfg.Options.TUI.CompactMode
- p.compact = compact
- p.forceCompact = compact
- p.sidebar.SetCompactMode(p.compact)
-
- // Set splash state based on config
- if !config.HasInitialDataConfig() {
- // First-time setup: show model selection
- p.splash.SetOnboarding(true)
- p.isOnboarding = true
- p.splashFullScreen = true
- } else if b, _ := config.ProjectNeedsInitialization(); b {
- // Project needs context initialization
- p.splash.SetProjectInit(true)
- p.isProjectInit = true
- p.splashFullScreen = true
- } else {
- // Ready to chat: focus editor, splash in background
- p.focusedPane = PanelTypeEditor
- p.splashFullScreen = false
- }
-
- return tea.Batch(
- p.header.Init(),
- p.sidebar.Init(),
- p.chat.Init(),
- p.editor.Init(),
- p.splash.Init(),
- )
-}
-
-func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- var cmds []tea.Cmd
- if p.session.ID != "" && p.app.AgentCoordinator != nil {
- queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID)
- if queueSize != p.promptQueue {
- p.promptQueue = queueSize
- cmds = append(cmds, p.SetSize(p.width, p.height))
- }
- }
- switch msg := msg.(type) {
- case tea.KeyboardEnhancementsMsg:
- p.keyboardEnhancements = msg
- return p, nil
- case tea.MouseWheelMsg:
- if p.compact {
- msg.Y -= 1
- }
- if p.isMouseOverChat(msg.X, msg.Y) {
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- return p, cmd
- }
- return p, nil
- case tea.MouseClickMsg:
- if p.isOnboarding || p.isProjectInit {
- return p, nil
- }
- if p.compact {
- msg.Y -= 1
- }
- if p.isMouseOverChat(msg.X, msg.Y) {
- p.focusedPane = PanelTypeChat
- p.chat.Focus()
- p.editor.Blur()
- } else {
- p.focusedPane = PanelTypeEditor
- p.editor.Focus()
- p.chat.Blur()
- }
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- return p, cmd
- case tea.MouseMotionMsg:
- if p.compact {
- msg.Y -= 1
- }
- if msg.Button == tea.MouseLeft {
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- return p, cmd
- }
- return p, nil
- case tea.MouseReleaseMsg:
- if p.isOnboarding || p.isProjectInit {
- return p, nil
- }
- if p.compact {
- msg.Y -= 1
- }
- if msg.Button == tea.MouseLeft {
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- return p, cmd
- }
- return p, nil
- case chat.SelectionCopyMsg:
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- return p, cmd
- case tea.WindowSizeMsg:
- u, cmd := p.editor.Update(msg)
- p.editor = u.(editor.Editor)
- return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
- case CancelTimerExpiredMsg:
- p.isCanceling = false
- return p, nil
- case editor.OpenEditorMsg:
- u, cmd := p.editor.Update(msg)
- p.editor = u.(editor.Editor)
- return p, cmd
- case chat.SendMsg:
- return p, p.sendMessage(msg.Text, msg.Attachments)
- case chat.SessionSelectedMsg:
- return p, p.setSession(msg)
- case splash.SubmitAPIKeyMsg:
- u, cmd := p.splash.Update(msg)
- p.splash = u.(splash.Splash)
- cmds = append(cmds, cmd)
- return p, tea.Batch(cmds...)
- case commands.ToggleCompactModeMsg:
- p.forceCompact = !p.forceCompact
- var cmd tea.Cmd
- if p.forceCompact {
- p.setCompactMode(true)
- cmd = p.updateCompactConfig(true)
- } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
- p.setCompactMode(false)
- cmd = p.updateCompactConfig(false)
- }
- return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
- case commands.ToggleThinkingMsg:
- return p, p.toggleThinking()
- case commands.OpenReasoningDialogMsg:
- return p, p.openReasoningDialog()
- case reasoning.ReasoningEffortSelectedMsg:
- return p, p.handleReasoningEffortSelected(msg.Effort)
- case commands.OpenExternalEditorMsg:
- u, cmd := p.editor.Update(msg)
- p.editor = u.(editor.Editor)
- return p, cmd
- case pubsub.Event[session.Session]:
- if msg.Payload.ID == p.session.ID {
- prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
- prevHasInProgress := p.hasInProgressTodo()
- p.session = msg.Payload
- newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
- newHasInProgress := p.hasInProgressTodo()
- if prevHasIncompleteTodos != newHasIncompleteTodos {
- cmds = append(cmds, p.SetSize(p.width, p.height))
- }
- if !prevHasInProgress && newHasInProgress {
- cmds = append(cmds, p.todoSpinner.Tick)
- }
- }
- u, cmd := p.header.Update(msg)
- p.header = u.(header.Header)
- cmds = append(cmds, cmd)
- u, cmd = p.sidebar.Update(msg)
- p.sidebar = u.(sidebar.Sidebar)
- cmds = append(cmds, cmd)
- return p, tea.Batch(cmds...)
- case chat.SessionClearedMsg:
- u, cmd := p.header.Update(msg)
- p.header = u.(header.Header)
- cmds = append(cmds, cmd)
- u, cmd = p.sidebar.Update(msg)
- p.sidebar = u.(sidebar.Sidebar)
- cmds = append(cmds, cmd)
- u, cmd = p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- cmds = append(cmds, cmd)
- u, cmd = p.editor.Update(msg)
- p.editor = u.(editor.Editor)
- cmds = append(cmds, cmd)
- return p, tea.Batch(cmds...)
- case filepicker.FilePickedMsg,
- completions.CompletionsClosedMsg,
- completions.SelectCompletionMsg:
- u, cmd := p.editor.Update(msg)
- p.editor = u.(editor.Editor)
- cmds = append(cmds, cmd)
- return p, tea.Batch(cmds...)
-
- case hyper.DeviceFlowCompletedMsg,
- hyper.DeviceAuthInitiatedMsg,
- hyper.DeviceFlowErrorMsg,
- copilot.DeviceAuthInitiatedMsg,
- copilot.DeviceFlowErrorMsg,
- copilot.DeviceFlowCompletedMsg:
- if p.focusedPane == PanelTypeSplash {
- u, cmd := p.splash.Update(msg)
- p.splash = u.(splash.Splash)
- cmds = append(cmds, cmd)
- }
- return p, tea.Batch(cmds...)
- case models.APIKeyStateChangeMsg:
- if p.focusedPane == PanelTypeSplash {
- u, cmd := p.splash.Update(msg)
- p.splash = u.(splash.Splash)
- cmds = append(cmds, cmd)
- }
- return p, tea.Batch(cmds...)
- case pubsub.Event[message.Message],
- anim.StepMsg,
- spinner.TickMsg:
- // Update todo spinner if agent is busy and we have in-progress todos
- agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
- if _, ok := msg.(spinner.TickMsg); ok && p.hasInProgressTodo() && agentBusy {
- var cmd tea.Cmd
- p.todoSpinner, cmd = p.todoSpinner.Update(msg)
- cmds = append(cmds, cmd)
- }
- // Start spinner when agent becomes busy and we have in-progress todos
- if _, ok := msg.(pubsub.Event[message.Message]); ok && p.hasInProgressTodo() && agentBusy {
- cmds = append(cmds, p.todoSpinner.Tick)
- }
- if p.focusedPane == PanelTypeSplash {
- u, cmd := p.splash.Update(msg)
- p.splash = u.(splash.Splash)
- cmds = append(cmds, cmd)
- } else {
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- cmds = append(cmds, cmd)
- }
-
- return p, tea.Batch(cmds...)
- case commands.ToggleYoloModeMsg:
- // update the editor style
- u, cmd := p.editor.Update(msg)
- p.editor = u.(editor.Editor)
- return p, cmd
- case pubsub.Event[history.File], sidebar.SessionFilesMsg:
- u, cmd := p.sidebar.Update(msg)
- p.sidebar = u.(sidebar.Sidebar)
- cmds = append(cmds, cmd)
- return p, tea.Batch(cmds...)
- case pubsub.Event[permission.PermissionNotification]:
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- cmds = append(cmds, cmd)
- return p, tea.Batch(cmds...)
-
- case commands.CommandRunCustomMsg:
- if p.app.AgentCoordinator.IsBusy() {
- return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
- }
-
- cmd := p.sendMessage(msg.Content, nil)
- if cmd != nil {
- return p, cmd
- }
- case splash.OnboardingCompleteMsg:
- p.splashFullScreen = false
- if b, _ := config.ProjectNeedsInitialization(); b {
- p.splash.SetProjectInit(true)
- p.splashFullScreen = true
- return p, p.SetSize(p.width, p.height)
- }
- err := p.app.InitCoderAgent(context.TODO())
- if err != nil {
- return p, util.ReportError(err)
- }
- p.isOnboarding = false
- p.isProjectInit = false
- p.focusedPane = PanelTypeEditor
- return p, p.SetSize(p.width, p.height)
- case commands.NewSessionsMsg:
- if p.app.AgentCoordinator.IsBusy() {
- return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
- }
- return p, p.newSession()
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, p.keyMap.NewSession):
- // if we have no agent do nothing
- if p.app.AgentCoordinator == nil {
- return p, nil
- }
- if p.app.AgentCoordinator.IsBusy() {
- return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
- }
- return p, p.newSession()
- case key.Matches(msg, p.keyMap.AddAttachment):
- // Skip attachment handling during onboarding/splash screen
- if p.focusedPane == PanelTypeSplash || p.isOnboarding {
- u, cmd := p.splash.Update(msg)
- p.splash = u.(splash.Splash)
- return p, cmd
- }
- agentCfg := config.Get().Agents[config.AgentCoder]
- model := config.Get().GetModelByType(agentCfg.Model)
- if model == nil {
- return p, util.ReportWarn("No model configured yet")
- }
- if model.SupportsImages {
- return p, util.CmdHandler(commands.OpenFilePickerMsg{})
- } else {
- return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
- }
- case key.Matches(msg, p.keyMap.Tab):
- if p.session.ID == "" {
- u, cmd := p.splash.Update(msg)
- p.splash = u.(splash.Splash)
- return p, cmd
- }
- return p, p.changeFocus()
- case key.Matches(msg, p.keyMap.Cancel):
- if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
- return p, p.cancel()
- }
- case key.Matches(msg, p.keyMap.Details):
- p.toggleDetails()
- return p, nil
- case key.Matches(msg, p.keyMap.TogglePills):
- if p.session.ID != "" {
- return p, p.togglePillsExpanded()
- }
- case key.Matches(msg, p.keyMap.PillLeft):
- if p.session.ID != "" && p.pillsExpanded {
- return p, p.switchPillSection(-1)
- }
- case key.Matches(msg, p.keyMap.PillRight):
- if p.session.ID != "" && p.pillsExpanded {
- return p, p.switchPillSection(1)
- }
- }
-
- switch p.focusedPane {
- case PanelTypeChat:
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- cmds = append(cmds, cmd)
- case PanelTypeEditor:
- u, cmd := p.editor.Update(msg)
- p.editor = u.(editor.Editor)
- cmds = append(cmds, cmd)
- case PanelTypeSplash:
- u, cmd := p.splash.Update(msg)
- p.splash = u.(splash.Splash)
- cmds = append(cmds, cmd)
- }
- case tea.PasteMsg:
- switch p.focusedPane {
- case PanelTypeEditor:
- u, cmd := p.editor.Update(msg)
- p.editor = u.(editor.Editor)
- cmds = append(cmds, cmd)
- return p, tea.Batch(cmds...)
- case PanelTypeChat:
- u, cmd := p.chat.Update(msg)
- p.chat = u.(chat.MessageListCmp)
- cmds = append(cmds, cmd)
- return p, tea.Batch(cmds...)
- case PanelTypeSplash:
- u, cmd := p.splash.Update(msg)
- p.splash = u.(splash.Splash)
- cmds = append(cmds, cmd)
- return p, tea.Batch(cmds...)
- }
- }
- return p, tea.Batch(cmds...)
-}
-
-func (p *chatPage) Cursor() *tea.Cursor {
- if p.header.ShowingDetails() {
- return nil
- }
- switch p.focusedPane {
- case PanelTypeEditor:
- return p.editor.Cursor()
- case PanelTypeSplash:
- return p.splash.Cursor()
- default:
- return nil
- }
-}
-
-func (p *chatPage) View() string {
- var chatView string
- t := styles.CurrentTheme()
-
- if p.session.ID == "" {
- splashView := p.splash.View()
- // Full screen during onboarding or project initialization
- if p.splashFullScreen {
- chatView = splashView
- } else {
- // Show splash + editor for new message state
- editorView := p.editor.View()
- chatView = lipgloss.JoinVertical(
- lipgloss.Left,
- t.S().Base.Render(splashView),
- editorView,
- )
- }
- } else {
- messagesView := p.chat.View()
- editorView := p.editor.View()
-
- hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
- hasQueue := p.promptQueue > 0
- todosFocused := p.pillsExpanded && p.focusedPillSection == PillSectionTodos
- queueFocused := p.pillsExpanded && p.focusedPillSection == PillSectionQueue
-
- // Use spinner when agent is busy, otherwise show static icon
- agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
- inProgressIcon := t.S().Base.Foreground(t.GreenDark).Render(styles.CenterSpinnerIcon)
- if agentBusy {
- inProgressIcon = p.todoSpinner.View()
- }
-
- var pills []string
- if hasIncompleteTodos {
- pills = append(pills, todoPill(p.session.Todos, inProgressIcon, todosFocused, p.pillsExpanded, t))
- }
- if hasQueue {
- pills = append(pills, queuePill(p.promptQueue, queueFocused, p.pillsExpanded, t))
- }
-
- var expandedList string
- if p.pillsExpanded {
- if todosFocused && hasIncompleteTodos {
- expandedList = todoList(p.session.Todos, inProgressIcon, t, p.width-SideBarWidth)
- } else if queueFocused && hasQueue {
- queueItems := p.app.AgentCoordinator.QueuedPromptsList(p.session.ID)
- expandedList = queueList(queueItems, t)
- }
- }
-
- var pillsArea string
- if len(pills) > 0 {
- pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
-
- // Add help hint for expanding/collapsing pills based on state.
- var helpDesc string
- if p.pillsExpanded {
- helpDesc = "close"
- } else {
- helpDesc = "open"
- }
- // Style to match help section: keys in FgMuted, description in FgSubtle
- helpKey := t.S().Base.Foreground(t.FgMuted).Render("ctrl+space")
- helpText := t.S().Base.Foreground(t.FgSubtle).Render(helpDesc)
- helpHint := lipgloss.JoinHorizontal(lipgloss.Center, helpKey, " ", helpText)
- pillsRow = lipgloss.JoinHorizontal(lipgloss.Center, pillsRow, " ", helpHint)
-
- if expandedList != "" {
- pillsArea = lipgloss.JoinVertical(
- lipgloss.Left,
- pillsRow,
- expandedList,
- )
- } else {
- pillsArea = pillsRow
- }
-
- pillsArea = t.S().Base.
- MaxWidth(p.width).
- MarginTop(1).
- PaddingLeft(3).
- Render(pillsArea)
- }
-
- if p.compact {
- headerView := p.header.View()
- views := []string{headerView, messagesView}
- if pillsArea != "" {
- views = append(views, pillsArea)
- }
- views = append(views, editorView)
- chatView = lipgloss.JoinVertical(lipgloss.Left, views...)
- } else {
- sidebarView := p.sidebar.View()
- var messagesColumn string
- if pillsArea != "" {
- messagesColumn = lipgloss.JoinVertical(
- lipgloss.Left,
- messagesView,
- pillsArea,
- )
- } else {
- messagesColumn = messagesView
- }
- messages := lipgloss.JoinHorizontal(
- lipgloss.Left,
- messagesColumn,
- sidebarView,
- )
- chatView = lipgloss.JoinVertical(
- lipgloss.Left,
- messages,
- p.editor.View(),
- )
- }
- }
-
- layers := []*lipgloss.Layer{
- lipgloss.NewLayer(chatView).X(0).Y(0),
- }
-
- if p.showingDetails {
- style := t.S().Base.
- Width(p.detailsWidth).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus)
- version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
- details := style.Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- p.sidebar.View(),
- version,
- ),
- )
- layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
- }
- canvas := lipgloss.NewCompositor(layers...)
- return canvas.Render()
-}
-
-func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
- return func() tea.Msg {
- err := config.Get().SetCompactMode(compact)
- if err != nil {
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: "Failed to update compact mode configuration: " + err.Error(),
- }
- }
- return nil
- }
-}
-
-func (p *chatPage) toggleThinking() tea.Cmd {
- return func() tea.Msg {
- cfg := config.Get()
- agentCfg := cfg.Agents[config.AgentCoder]
- currentModel := cfg.Models[agentCfg.Model]
-
- // Toggle the thinking mode
- currentModel.Think = !currentModel.Think
- if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: "Failed to update thinking mode: " + err.Error(),
- }
- }
-
- // Update the agent with the new configuration
- go p.app.UpdateAgentModel(context.TODO())
-
- status := "disabled"
- if currentModel.Think {
- status = "enabled"
- }
- return util.InfoMsg{
- Type: util.InfoTypeInfo,
- Msg: "Thinking mode " + status,
- }
- }
-}
-
-func (p *chatPage) openReasoningDialog() tea.Cmd {
- return func() tea.Msg {
- cfg := config.Get()
- agentCfg := cfg.Agents[config.AgentCoder]
- model := cfg.GetModelByType(agentCfg.Model)
- providerCfg := cfg.GetProviderForModel(agentCfg.Model)
-
- if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
- // Return the OpenDialogMsg directly so it bubbles up to the main TUI
- return dialogs.OpenDialogMsg{
- Model: reasoning.NewReasoningDialog(),
- }
- }
- return nil
- }
-}
-
-func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
- return func() tea.Msg {
- cfg := config.Get()
- agentCfg := cfg.Agents[config.AgentCoder]
- currentModel := cfg.Models[agentCfg.Model]
-
- // Update the model configuration
- currentModel.ReasoningEffort = effort
- if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: "Failed to update reasoning effort: " + err.Error(),
- }
- }
-
- // Update the agent with the new configuration
- if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: "Failed to update reasoning effort: " + err.Error(),
- }
- }
-
- return util.InfoMsg{
- Type: util.InfoTypeInfo,
- Msg: "Reasoning effort set to " + effort,
- }
- }
-}
-
-func (p *chatPage) setCompactMode(compact bool) {
- if p.compact == compact {
- return
- }
- p.compact = compact
- if compact {
- p.sidebar.SetCompactMode(true)
- } else {
- p.setShowDetails(false)
- }
-}
-
-func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
- if p.forceCompact {
- return
- }
- if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
- p.setCompactMode(true)
- }
- if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
- p.setCompactMode(false)
- }
-}
-
-func (p *chatPage) SetSize(width, height int) tea.Cmd {
- p.handleCompactMode(width, height)
- p.width = width
- p.height = height
- var cmds []tea.Cmd
-
- if p.session.ID == "" {
- if p.splashFullScreen {
- cmds = append(cmds, p.splash.SetSize(width, height))
- } else {
- cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
- cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
- cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
- }
- } else {
- hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
- hasQueue := p.promptQueue > 0
- hasPills := hasIncompleteTodos || hasQueue
-
- pillsAreaHeight := 0
- if hasPills {
- pillsAreaHeight = pillHeightWithBorder + 1 // +1 for padding top
- if p.pillsExpanded {
- if p.focusedPillSection == PillSectionTodos && hasIncompleteTodos {
- pillsAreaHeight += len(p.session.Todos)
- } else if p.focusedPillSection == PillSectionQueue && hasQueue {
- pillsAreaHeight += p.promptQueue
- }
- }
- }
-
- if p.compact {
- cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight-pillsAreaHeight))
- p.detailsWidth = width - DetailsPositioning
- cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
- cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
- cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
- } else {
- cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight-pillsAreaHeight))
- cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
- cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
- }
- cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
- }
- return tea.Batch(cmds...)
-}
-
-func (p *chatPage) newSession() tea.Cmd {
- if p.session.ID == "" {
- return nil
- }
-
- p.session = session.Session{}
- p.focusedPane = PanelTypeEditor
- p.editor.Focus()
- p.chat.Blur()
- p.isCanceling = false
- return tea.Batch(
- util.CmdHandler(chat.SessionClearedMsg{}),
- p.SetSize(p.width, p.height),
- )
-}
-
-func (p *chatPage) setSession(sess session.Session) tea.Cmd {
- if p.session.ID == sess.ID {
- return nil
- }
-
- var cmds []tea.Cmd
- p.session = sess
-
- if p.hasInProgressTodo() {
- cmds = append(cmds, p.todoSpinner.Tick)
- }
-
- cmds = append(cmds, p.SetSize(p.width, p.height))
- cmds = append(cmds, p.chat.SetSession(sess))
- cmds = append(cmds, p.sidebar.SetSession(sess))
- cmds = append(cmds, p.header.SetSession(sess))
- cmds = append(cmds, p.editor.SetSession(sess))
-
- return tea.Sequence(cmds...)
-}
-
-func (p *chatPage) changeFocus() tea.Cmd {
- if p.session.ID == "" {
- return nil
- }
-
- switch p.focusedPane {
- case PanelTypeEditor:
- p.focusedPane = PanelTypeChat
- p.chat.Focus()
- p.editor.Blur()
- case PanelTypeChat:
- p.focusedPane = PanelTypeEditor
- p.editor.Focus()
- p.chat.Blur()
- }
- return nil
-}
-
-func (p *chatPage) togglePillsExpanded() tea.Cmd {
- hasPills := hasIncompleteTodos(p.session.Todos) || p.promptQueue > 0
- if !hasPills {
- return nil
- }
- p.pillsExpanded = !p.pillsExpanded
- if p.pillsExpanded {
- if hasIncompleteTodos(p.session.Todos) {
- p.focusedPillSection = PillSectionTodos
- } else {
- p.focusedPillSection = PillSectionQueue
- }
- }
- return p.SetSize(p.width, p.height)
-}
-
-func (p *chatPage) switchPillSection(dir int) tea.Cmd {
- if !p.pillsExpanded {
- return nil
- }
- hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
- hasQueue := p.promptQueue > 0
-
- if dir < 0 && p.focusedPillSection == PillSectionQueue && hasIncompleteTodos {
- p.focusedPillSection = PillSectionTodos
- return p.SetSize(p.width, p.height)
- }
- if dir > 0 && p.focusedPillSection == PillSectionTodos && hasQueue {
- p.focusedPillSection = PillSectionQueue
- return p.SetSize(p.width, p.height)
- }
- return nil
-}
-
-func (p *chatPage) cancel() tea.Cmd {
- if p.isCanceling {
- p.isCanceling = false
- if p.app.AgentCoordinator != nil {
- p.app.AgentCoordinator.Cancel(p.session.ID)
- }
- return nil
- }
-
- if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
- p.app.AgentCoordinator.ClearQueue(p.session.ID)
- return nil
- }
- p.isCanceling = true
- return cancelTimerCmd()
-}
-
-func (p *chatPage) setShowDetails(show bool) {
- p.showingDetails = show
- p.header.SetDetailsOpen(p.showingDetails)
- if !p.compact {
- p.sidebar.SetCompactMode(false)
- }
-}
-
-func (p *chatPage) toggleDetails() {
- if p.session.ID == "" || !p.compact {
- return
- }
- p.setShowDetails(!p.showingDetails)
-}
-
-func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
- session := p.session
- var cmds []tea.Cmd
- if p.session.ID == "" {
- // XXX: The second argument here is the session name, which we leave
- // blank as it will be auto-generated. Ideally, we remove the need for
- // that argument entirely.
- newSession, err := p.app.Sessions.Create(context.Background(), "")
- if err != nil {
- return util.ReportError(err)
- }
- session = newSession
- cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
- }
- if p.app.AgentCoordinator == nil {
- return util.ReportError(fmt.Errorf("coder agent is not initialized"))
- }
- cmds = append(cmds, p.chat.GoToBottom())
- cmds = append(cmds, func() tea.Msg {
- _, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
- if err != nil {
- isCancelErr := errors.Is(err, context.Canceled)
- isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
- if isCancelErr || isPermissionErr {
- return nil
- }
- return util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: err.Error(),
- }
- }
- return nil
- })
- return tea.Batch(cmds...)
-}
-
-func (p *chatPage) Bindings() []key.Binding {
- bindings := []key.Binding{
- p.keyMap.NewSession,
- p.keyMap.AddAttachment,
- }
- if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
- cancelBinding := p.keyMap.Cancel
- if p.isCanceling {
- cancelBinding = key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "press again to cancel"),
- )
- }
- bindings = append([]key.Binding{cancelBinding}, bindings...)
- }
-
- switch p.focusedPane {
- case PanelTypeChat:
- bindings = append([]key.Binding{
- key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "focus editor"),
- ),
- }, bindings...)
- bindings = append(bindings, p.chat.Bindings()...)
- case PanelTypeEditor:
- bindings = append([]key.Binding{
- key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "focus chat"),
- ),
- }, bindings...)
- bindings = append(bindings, p.editor.Bindings()...)
- case PanelTypeSplash:
- bindings = append(bindings, p.splash.Bindings()...)
- }
-
- return bindings
-}
-
-func (p *chatPage) Help() help.KeyMap {
- var shortList []key.Binding
- var fullList [][]key.Binding
- switch {
- case p.isOnboarding:
- switch {
- case p.splash.IsShowingHyperOAuth2() || p.splash.IsShowingCopilotOAuth2():
- shortList = append(shortList,
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "copy url & open signup"),
- ),
- key.NewBinding(
- key.WithKeys("c"),
- key.WithHelp("c", "copy url"),
- ),
- )
- default:
- shortList = append(shortList,
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "submit"),
- ),
- )
- }
- shortList = append(shortList,
- // Quit
- key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("ctrl+c", "quit"),
- ),
- )
- // keep them the same
- for _, v := range shortList {
- fullList = append(fullList, []key.Binding{v})
- }
- case p.isOnboarding && !p.splash.IsShowingAPIKey():
- shortList = append(shortList,
- // Choose model
- key.NewBinding(
- key.WithKeys("up", "down"),
- key.WithHelp("β/β", "choose"),
- ),
- // Accept selection
- key.NewBinding(
- key.WithKeys("enter", "ctrl+y"),
- key.WithHelp("enter", "accept"),
- ),
- // Quit
- key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("ctrl+c", "quit"),
- ),
- )
- // keep them the same
- for _, v := range shortList {
- fullList = append(fullList, []key.Binding{v})
- }
- case p.isOnboarding && p.splash.IsShowingAPIKey():
- if p.splash.IsAPIKeyValid() {
- shortList = append(shortList,
- key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "continue"),
- ),
- )
- } else {
- shortList = append(shortList,
- // Go back
- key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "back"),
- ),
- )
- }
- shortList = append(shortList,
- // Quit
- key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("ctrl+c", "quit"),
- ),
- )
- // keep them the same
- for _, v := range shortList {
- fullList = append(fullList, []key.Binding{v})
- }
- case p.isProjectInit:
- shortList = append(shortList,
- key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("ctrl+c", "quit"),
- ),
- )
- // keep them the same
- for _, v := range shortList {
- fullList = append(fullList, []key.Binding{v})
- }
- default:
- if p.editor.IsCompletionsOpen() {
- shortList = append(shortList,
- key.NewBinding(
- key.WithKeys("tab", "enter"),
- key.WithHelp("tab/enter", "complete"),
- ),
- key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel"),
- ),
- key.NewBinding(
- key.WithKeys("up", "down"),
- key.WithHelp("β/β", "choose"),
- ),
- )
- for _, v := range shortList {
- fullList = append(fullList, []key.Binding{v})
- }
- return core.NewSimpleHelp(shortList, fullList)
- }
- if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
- cancelBinding := key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel"),
- )
- if p.isCanceling {
- cancelBinding = key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "press again to cancel"),
- )
- }
- if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
- cancelBinding = key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "clear queue"),
- )
- }
- shortList = append(shortList, cancelBinding)
- fullList = append(fullList,
- []key.Binding{
- cancelBinding,
- },
- )
- }
- globalBindings := []key.Binding{}
- // we are in a session
- if p.session.ID != "" {
- var tabKey key.Binding
- switch p.focusedPane {
- case PanelTypeEditor:
- tabKey = key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "focus chat"),
- )
- case PanelTypeChat:
- tabKey = key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "focus editor"),
- )
- default:
- tabKey = key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "focus chat"),
- )
- }
- shortList = append(shortList, tabKey)
- globalBindings = append(globalBindings, tabKey)
-
- // Show left/right to switch sections when expanded and both exist
- hasTodos := hasIncompleteTodos(p.session.Todos)
- hasQueue := p.promptQueue > 0
- if p.pillsExpanded && hasTodos && hasQueue {
- shortList = append(shortList, p.keyMap.PillLeft)
- globalBindings = append(globalBindings, p.keyMap.PillLeft)
- }
- }
- commandsBinding := key.NewBinding(
- key.WithKeys("ctrl+p"),
- key.WithHelp("ctrl+p", "commands"),
- )
- if p.focusedPane == PanelTypeEditor && p.editor.IsEmpty() {
- commandsBinding.SetHelp("/ or ctrl+p", "commands")
- }
- modelsBinding := key.NewBinding(
- key.WithKeys("ctrl+m", "ctrl+l"),
- key.WithHelp("ctrl+l", "models"),
- )
- if p.keyboardEnhancements.Flags > 0 {
- // non-zero flags mean we have at least key disambiguation
- modelsBinding.SetHelp("ctrl+m", "models")
- }
- helpBinding := key.NewBinding(
- key.WithKeys("ctrl+g"),
- key.WithHelp("ctrl+g", "more"),
- )
- globalBindings = append(globalBindings, commandsBinding, modelsBinding)
- globalBindings = append(globalBindings,
- key.NewBinding(
- key.WithKeys("ctrl+s"),
- key.WithHelp("ctrl+s", "sessions"),
- ),
- )
- if p.session.ID != "" {
- globalBindings = append(globalBindings,
- key.NewBinding(
- key.WithKeys("ctrl+n"),
- key.WithHelp("ctrl+n", "new sessions"),
- ))
- }
- shortList = append(shortList,
- // Commands
- commandsBinding,
- modelsBinding,
- )
- fullList = append(fullList, globalBindings)
-
- switch p.focusedPane {
- case PanelTypeChat:
- shortList = append(shortList,
- key.NewBinding(
- key.WithKeys("up", "down"),
- key.WithHelp("ββ", "scroll"),
- ),
- messages.CopyKey,
- )
- fullList = append(fullList,
- []key.Binding{
- key.NewBinding(
- key.WithKeys("up", "down"),
- key.WithHelp("ββ", "scroll"),
- ),
- key.NewBinding(
- key.WithKeys("shift+up", "shift+down"),
- key.WithHelp("shift+ββ", "next/prev item"),
- ),
- key.NewBinding(
- key.WithKeys("pgup", "b"),
- key.WithHelp("b/pgup", "page up"),
- ),
- key.NewBinding(
- key.WithKeys("pgdown", " ", "f"),
- key.WithHelp("f/pgdn", "page down"),
- ),
- },
- []key.Binding{
- key.NewBinding(
- key.WithKeys("u"),
- key.WithHelp("u", "half page up"),
- ),
- key.NewBinding(
- key.WithKeys("d"),
- key.WithHelp("d", "half page down"),
- ),
- key.NewBinding(
- key.WithKeys("g", "home"),
- key.WithHelp("g", "home"),
- ),
- key.NewBinding(
- key.WithKeys("G", "end"),
- key.WithHelp("G", "end"),
- ),
- },
- []key.Binding{
- messages.CopyKey,
- messages.ClearSelectionKey,
- },
- )
- case PanelTypeEditor:
- newLineBinding := key.NewBinding(
- key.WithKeys("shift+enter", "ctrl+j"),
- // "ctrl+j" is a common keybinding for newline in many editors. If
- // the terminal supports "shift+enter", we substitute the help text
- // to reflect that.
- key.WithHelp("ctrl+j", "newline"),
- )
- if p.keyboardEnhancements.Flags > 0 {
- // Non-zero flags mean we have at least key disambiguation.
- newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
- }
- shortList = append(shortList, newLineBinding)
- fullList = append(fullList,
- []key.Binding{
- newLineBinding,
- key.NewBinding(
- key.WithKeys("ctrl+f"),
- key.WithHelp("ctrl+f", "add image"),
- ),
- key.NewBinding(
- key.WithKeys("@"),
- key.WithHelp("@", "mention file"),
- ),
- key.NewBinding(
- key.WithKeys("ctrl+o"),
- key.WithHelp("ctrl+o", "open editor"),
- ),
- })
-
- if p.editor.HasAttachments() {
- fullList = append(fullList, []key.Binding{
- key.NewBinding(
- key.WithKeys("ctrl+r"),
- key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
- ),
- key.NewBinding(
- key.WithKeys("ctrl+r", "r"),
- key.WithHelp("ctrl+r+r", "delete all attachments"),
- ),
- key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel delete mode"),
- ),
- })
- }
- }
- shortList = append(shortList,
- // Quit
- key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("ctrl+c", "quit"),
- ),
- // Help
- helpBinding,
- )
- fullList = append(fullList, []key.Binding{
- key.NewBinding(
- key.WithKeys("ctrl+g"),
- key.WithHelp("ctrl+g", "less"),
- ),
- })
- }
-
- return core.NewSimpleHelp(shortList, fullList)
-}
-
-func (p *chatPage) IsChatFocused() bool {
- return p.focusedPane == PanelTypeChat
-}
-
-// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
-// Returns true if the mouse is over the chat area, false otherwise.
-func (p *chatPage) isMouseOverChat(x, y int) bool {
- // No session means no chat area
- if p.session.ID == "" {
- return false
- }
-
- var chatX, chatY, chatWidth, chatHeight int
-
- if p.compact {
- // In compact mode: chat area starts after header and spans full width
- chatX = 0
- chatY = HeaderHeight
- chatWidth = p.width
- chatHeight = p.height - EditorHeight - HeaderHeight
- } else {
- // In non-compact mode: chat area spans from left edge to sidebar
- chatX = 0
- chatY = 0
- chatWidth = p.width - SideBarWidth
- chatHeight = p.height - EditorHeight
- }
-
- // Check if mouse coordinates are within chat bounds
- return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
-}
-
-func (p *chatPage) hasInProgressTodo() bool {
- for _, todo := range p.session.Todos {
- if todo.Status == session.TodoStatusInProgress {
- return true
- }
- }
- return false
-}
@@ -1,53 +0,0 @@
-package chat
-
-import (
- "charm.land/bubbles/v2/key"
-)
-
-type KeyMap struct {
- NewSession key.Binding
- AddAttachment key.Binding
- Cancel key.Binding
- Tab key.Binding
- Details key.Binding
- TogglePills key.Binding
- PillLeft key.Binding
- PillRight key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- NewSession: key.NewBinding(
- key.WithKeys("ctrl+n"),
- key.WithHelp("ctrl+n", "new session"),
- ),
- AddAttachment: key.NewBinding(
- key.WithKeys("ctrl+f"),
- key.WithHelp("ctrl+f", "add attachment"),
- ),
- Cancel: key.NewBinding(
- key.WithKeys("esc", "alt+esc"),
- key.WithHelp("esc", "cancel"),
- ),
- Tab: key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "change focus"),
- ),
- Details: key.NewBinding(
- key.WithKeys("ctrl+d"),
- key.WithHelp("ctrl+d", "toggle details"),
- ),
- TogglePills: key.NewBinding(
- key.WithKeys("ctrl+space"),
- key.WithHelp("ctrl+space", "toggle tasks"),
- ),
- PillLeft: key.NewBinding(
- key.WithKeys("left"),
- key.WithHelp("β/β", "switch section"),
- ),
- PillRight: key.NewBinding(
- key.WithKeys("right"),
- key.WithHelp("β/β", "switch section"),
- ),
- }
-}
@@ -1,125 +0,0 @@
-package chat
-
-import (
- "fmt"
- "strings"
-
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/session"
- "github.com/charmbracelet/crush/internal/tui/components/chat/todos"
- "github.com/charmbracelet/crush/internal/tui/styles"
-)
-
-func hasIncompleteTodos(todos []session.Todo) bool {
- for _, todo := range todos {
- if todo.Status != session.TodoStatusCompleted {
- return true
- }
- }
- return false
-}
-
-const (
- pillHeightWithBorder = 3
- maxTaskDisplayLength = 40
- maxQueueDisplayLength = 60
-)
-
-func queuePill(queue int, focused, pillsPanelFocused bool, t *styles.Theme) string {
- if queue <= 0 {
- return ""
- }
- triangles := styles.ForegroundGrad("βΆβΆβΆβΆβΆβΆβΆβΆβΆ", false, t.RedDark, t.Accent)
- if queue < 10 {
- triangles = triangles[:queue]
- }
-
- content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue)
-
- style := t.S().Base.PaddingLeft(1).PaddingRight(1)
- if !pillsPanelFocused || focused {
- style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay)
- } else {
- style = style.BorderStyle(lipgloss.HiddenBorder())
- }
- return style.Render(content)
-}
-
-func todoPill(todos []session.Todo, spinnerView string, focused, pillsPanelFocused bool, t *styles.Theme) string {
- if !hasIncompleteTodos(todos) {
- return ""
- }
-
- completed := 0
- var currentTodo *session.Todo
- for i := range todos {
- switch todos[i].Status {
- case session.TodoStatusCompleted:
- completed++
- case session.TodoStatusInProgress:
- if currentTodo == nil {
- currentTodo = &todos[i]
- }
- }
- }
-
- total := len(todos)
-
- label := "To-Do"
- progress := t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("%d/%d", completed, total))
-
- var content string
- if pillsPanelFocused {
- content = fmt.Sprintf("%s %s", label, progress)
- } else if currentTodo != nil {
- taskText := currentTodo.Content
- if currentTodo.ActiveForm != "" {
- taskText = currentTodo.ActiveForm
- }
- if len(taskText) > maxTaskDisplayLength {
- taskText = taskText[:maxTaskDisplayLength-1] + "β¦"
- }
- task := t.S().Base.Foreground(t.FgSubtle).Render(taskText)
- content = fmt.Sprintf("%s %s %s %s", spinnerView, label, progress, task)
- } else {
- content = fmt.Sprintf("%s %s", label, progress)
- }
-
- style := t.S().Base.PaddingLeft(1).PaddingRight(1)
- if !pillsPanelFocused || focused {
- style = style.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.BgOverlay)
- } else {
- style = style.BorderStyle(lipgloss.HiddenBorder())
- }
- return style.Render(content)
-}
-
-func todoList(sessionTodos []session.Todo, spinnerView string, t *styles.Theme, width int) string {
- return todos.FormatTodosList(sessionTodos, spinnerView, t, width)
-}
-
-func queueList(queueItems []string, t *styles.Theme) string {
- if len(queueItems) == 0 {
- return ""
- }
-
- var lines []string
- for _, item := range queueItems {
- text := item
- if len(text) > maxQueueDisplayLength {
- text = text[:maxQueueDisplayLength-1] + "β¦"
- }
- prefix := t.S().Base.Foreground(t.FgMuted).Render(" β’") + " "
- lines = append(lines, prefix+t.S().Base.Foreground(t.FgMuted).Render(text))
- }
-
- return strings.Join(lines, "\n")
-}
-
-func sectionLine(availableWidth int, t *styles.Theme) string {
- if availableWidth <= 0 {
- return ""
- }
- line := strings.Repeat("β", availableWidth)
- return t.S().Base.Foreground(t.Border).Render(line)
-}
@@ -1,8 +0,0 @@
-package page
-
-type PageID string
-
-// PageChangeMsg is used to change the current page
-type PageChangeMsg struct {
- ID PageID
-}
@@ -1,83 +0,0 @@
-package styles
-
-import (
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/exp/charmtone"
-)
-
-func NewCharmtoneTheme() *Theme {
- t := &Theme{
- Name: "charmtone",
- IsDark: true,
-
- Primary: charmtone.Charple,
- Secondary: charmtone.Dolly,
- Tertiary: charmtone.Bok,
- Accent: charmtone.Zest,
-
- // Backgrounds
- BgBase: charmtone.Pepper,
- BgBaseLighter: charmtone.BBQ,
- BgSubtle: charmtone.Charcoal,
- BgOverlay: charmtone.Iron,
-
- // Foregrounds
- FgBase: charmtone.Ash,
- FgMuted: charmtone.Squid,
- FgHalfMuted: charmtone.Smoke,
- FgSubtle: charmtone.Oyster,
- FgSelected: charmtone.Salt,
-
- // Borders
- Border: charmtone.Charcoal,
- BorderFocus: charmtone.Charple,
-
- // Status
- Success: charmtone.Guac,
- Error: charmtone.Sriracha,
- Warning: charmtone.Zest,
- Info: charmtone.Malibu,
-
- // Colors
- White: charmtone.Butter,
-
- BlueLight: charmtone.Sardine,
- BlueDark: charmtone.Damson,
- Blue: charmtone.Malibu,
-
- Yellow: charmtone.Mustard,
- Citron: charmtone.Citron,
-
- Green: charmtone.Julep,
- GreenDark: charmtone.Guac,
- GreenLight: charmtone.Bok,
-
- Red: charmtone.Coral,
- RedDark: charmtone.Sriracha,
- RedLight: charmtone.Salmon,
- Cherry: charmtone.Cherry,
- }
-
- // Text selection.
- t.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
-
- // LSP and MCP status.
- t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("β")
- t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron)
- t.ItemErrorIcon = t.ItemOfflineIcon.Foreground(charmtone.Coral)
- t.ItemOnlineIcon = t.ItemOfflineIcon.Foreground(charmtone.Guac)
-
- // Editor: Yolo Mode.
- t.YoloIconFocused = lipgloss.NewStyle().Foreground(charmtone.Oyster).Background(charmtone.Citron).Bold(true).SetString(" ! ")
- t.YoloIconBlurred = t.YoloIconFocused.Foreground(charmtone.Pepper).Background(charmtone.Squid)
- t.YoloDotsFocused = lipgloss.NewStyle().Foreground(charmtone.Zest).SetString(":::")
- t.YoloDotsBlurred = t.YoloDotsFocused.Foreground(charmtone.Squid)
-
- // oAuth Chooser.
- t.AuthBorderSelected = lipgloss.NewStyle().BorderForeground(charmtone.Guac)
- t.AuthTextSelected = lipgloss.NewStyle().Foreground(charmtone.Julep)
- t.AuthBorderUnselected = lipgloss.NewStyle().BorderForeground(charmtone.Iron)
- t.AuthTextUnselected = lipgloss.NewStyle().Foreground(charmtone.Squid)
-
- return t
-}
@@ -1,79 +0,0 @@
-package styles
-
-import (
- "charm.land/glamour/v2/ansi"
- "github.com/alecthomas/chroma/v2"
-)
-
-func chromaStyle(style ansi.StylePrimitive) string {
- var s string
-
- if style.Color != nil {
- s = *style.Color
- }
- if style.BackgroundColor != nil {
- if s != "" {
- s += " "
- }
- s += "bg:" + *style.BackgroundColor
- }
- if style.Italic != nil && *style.Italic {
- if s != "" {
- s += " "
- }
- s += "italic"
- }
- if style.Bold != nil && *style.Bold {
- if s != "" {
- s += " "
- }
- s += "bold"
- }
- if style.Underline != nil && *style.Underline {
- if s != "" {
- s += " "
- }
- s += "underline"
- }
-
- return s
-}
-
-func GetChromaTheme() chroma.StyleEntries {
- t := CurrentTheme()
- rules := t.S().Markdown.CodeBlock
-
- return chroma.StyleEntries{
- chroma.Text: chromaStyle(rules.Chroma.Text),
- chroma.Error: chromaStyle(rules.Chroma.Error),
- chroma.Comment: chromaStyle(rules.Chroma.Comment),
- chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc),
- chroma.Keyword: chromaStyle(rules.Chroma.Keyword),
- chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved),
- chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace),
- chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType),
- chroma.Operator: chromaStyle(rules.Chroma.Operator),
- chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation),
- chroma.Name: chromaStyle(rules.Chroma.Name),
- chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin),
- chroma.NameTag: chromaStyle(rules.Chroma.NameTag),
- chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute),
- chroma.NameClass: chromaStyle(rules.Chroma.NameClass),
- chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant),
- chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator),
- chroma.NameException: chromaStyle(rules.Chroma.NameException),
- chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction),
- chroma.NameOther: chromaStyle(rules.Chroma.NameOther),
- chroma.Literal: chromaStyle(rules.Chroma.Literal),
- chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber),
- chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate),
- chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString),
- chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
- chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted),
- chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph),
- chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted),
- chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong),
- chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading),
- chroma.Background: chromaStyle(rules.Chroma.Background),
- }
-}
@@ -1,48 +0,0 @@
-package styles
-
-const (
- CheckIcon string = "β"
- ErrorIcon string = "Γ"
- WarningIcon string = "β "
- InfoIcon string = "β"
- HintIcon string = "β΅"
- SpinnerIcon string = "..."
- ArrowRightIcon string = "β"
- CenterSpinnerIcon string = "β―"
- LoadingIcon string = "β³"
- ImageIcon string = "β "
- TextIcon string = "β°"
- ModelIcon string = "β"
-
- // Tool call icons
- ToolPending string = "β"
- ToolSuccess string = "β"
- ToolError string = "Γ"
-
- BorderThin string = "β"
- BorderThick string = "β"
-
- // Todo icons
- TodoCompletedIcon string = "β"
- TodoPendingIcon string = "β’"
-)
-
-var SelectionIgnoreIcons = []string{
- // CheckIcon,
- // ErrorIcon,
- // WarningIcon,
- // InfoIcon,
- // HintIcon,
- // SpinnerIcon,
- // LoadingIcon,
- // DocumentIcon,
- // ModelIcon,
- //
- // // Tool call icons
- // ToolPending,
- // ToolSuccess,
- // ToolError,
-
- BorderThin,
- BorderThick,
-}
@@ -1,205 +0,0 @@
-package styles
-
-import (
- "fmt"
- "image/color"
-
- "charm.land/glamour/v2"
- "charm.land/glamour/v2/ansi"
-)
-
-// lipglossColorToHex converts a color.Color to hex string
-func lipglossColorToHex(c color.Color) string {
- r, g, b, _ := c.RGBA()
- return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
-}
-
-// Helper functions for style pointers
-func boolPtr(b bool) *bool { return &b }
-func stringPtr(s string) *string { return &s }
-func uintPtr(u uint) *uint { return &u }
-
-// returns a glamour TermRenderer configured with the current theme
-func GetMarkdownRenderer(width int) *glamour.TermRenderer {
- t := CurrentTheme()
- r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(t.S().Markdown),
- glamour.WithWordWrap(width),
- )
- return r
-}
-
-// returns a glamour TermRenderer with no colors (plain text with structure)
-func GetPlainMarkdownRenderer(width int) *glamour.TermRenderer {
- r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(PlainMarkdownStyle()),
- glamour.WithWordWrap(width),
- )
- return r
-}
-
-// PlainMarkdownStyle returns a glamour style config with no colors
-func PlainMarkdownStyle() ansi.StyleConfig {
- t := CurrentTheme()
- bgColor := stringPtr(lipglossColorToHex(t.BgBaseLighter))
- fgColor := stringPtr(lipglossColorToHex(t.FgMuted))
- return ansi.StyleConfig{
- Document: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- },
- BlockQuote: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- Indent: uintPtr(1),
- IndentToken: stringPtr("β "),
- },
- List: ansi.StyleList{
- LevelIndent: defaultListIndent,
- },
- Heading: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BlockSuffix: "\n",
- Bold: boolPtr(true),
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- },
- H1: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: " ",
- Suffix: " ",
- Bold: boolPtr(true),
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- },
- H2: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "## ",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- },
- H3: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "### ",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- },
- H4: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "#### ",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- },
- H5: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "##### ",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- },
- H6: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "###### ",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- },
- Strikethrough: ansi.StylePrimitive{
- CrossedOut: boolPtr(true),
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- Emph: ansi.StylePrimitive{
- Italic: boolPtr(true),
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- Strong: ansi.StylePrimitive{
- Bold: boolPtr(true),
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- HorizontalRule: ansi.StylePrimitive{
- Format: "\n--------\n",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- Item: ansi.StylePrimitive{
- BlockPrefix: "β’ ",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- Enumeration: ansi.StylePrimitive{
- BlockPrefix: ". ",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- Task: ansi.StyleTask{
- StylePrimitive: ansi.StylePrimitive{
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- Ticked: "[β] ",
- Unticked: "[ ] ",
- },
- Link: ansi.StylePrimitive{
- Underline: boolPtr(true),
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- LinkText: ansi.StylePrimitive{
- Bold: boolPtr(true),
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- Image: ansi.StylePrimitive{
- Underline: boolPtr(true),
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- ImageText: ansi.StylePrimitive{
- Format: "Image: {{.text}} β",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- Code: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: " ",
- Suffix: " ",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- },
- CodeBlock: ansi.StyleCodeBlock{
- StyleBlock: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- Margin: uintPtr(defaultMargin),
- },
- },
- Table: ansi.StyleTable{
- StyleBlock: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- },
- },
- DefinitionDescription: ansi.StylePrimitive{
- BlockPrefix: "\n ",
- Color: fgColor,
- BackgroundColor: bgColor,
- },
- }
-}
@@ -1,709 +0,0 @@
-package styles
-
-import (
- "fmt"
- "image/color"
- "strings"
- "sync"
-
- "charm.land/bubbles/v2/filepicker"
- "charm.land/bubbles/v2/help"
- "charm.land/bubbles/v2/textarea"
- "charm.land/bubbles/v2/textinput"
- tea "charm.land/bubbletea/v2"
- "charm.land/glamour/v2/ansi"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/tui/exp/diffview"
- "github.com/charmbracelet/x/exp/charmtone"
- "github.com/lucasb-eyer/go-colorful"
- "github.com/rivo/uniseg"
-)
-
-const (
- defaultListIndent = 2
- defaultListLevelIndent = 4
- defaultMargin = 2
-)
-
-type Theme struct {
- Name string
- IsDark bool
-
- Primary color.Color
- Secondary color.Color
- Tertiary color.Color
- Accent color.Color
-
- BgBase color.Color
- BgBaseLighter color.Color
- BgSubtle color.Color
- BgOverlay color.Color
-
- FgBase color.Color
- FgMuted color.Color
- FgHalfMuted color.Color
- FgSubtle color.Color
- FgSelected color.Color
-
- Border color.Color
- BorderFocus color.Color
-
- Success color.Color
- Error color.Color
- Warning color.Color
- Info color.Color
-
- // Colors
- // White
- White color.Color
-
- // Blues
- BlueLight color.Color
- BlueDark color.Color
- Blue color.Color
-
- // Yellows
- Yellow color.Color
- Citron color.Color
-
- // Greens
- Green color.Color
- GreenDark color.Color
- GreenLight color.Color
-
- // Reds
- Red color.Color
- RedDark color.Color
- RedLight color.Color
- Cherry color.Color
-
- // Text selection.
- TextSelection lipgloss.Style
-
- // LSP and MCP status indicators.
- ItemOfflineIcon lipgloss.Style
- ItemBusyIcon lipgloss.Style
- ItemErrorIcon lipgloss.Style
- ItemOnlineIcon lipgloss.Style
-
- // Editor: Yolo Mode.
- YoloIconFocused lipgloss.Style
- YoloIconBlurred lipgloss.Style
- YoloDotsFocused lipgloss.Style
- YoloDotsBlurred lipgloss.Style
-
- // oAuth Chooser.
- AuthBorderSelected lipgloss.Style
- AuthTextSelected lipgloss.Style
- AuthBorderUnselected lipgloss.Style
- AuthTextUnselected lipgloss.Style
-
- styles *Styles
- stylesOnce sync.Once
-}
-
-type Styles struct {
- Base lipgloss.Style
- SelectedBase lipgloss.Style
-
- Title lipgloss.Style
- Subtitle lipgloss.Style
- Text lipgloss.Style
- TextSelected lipgloss.Style
- Muted lipgloss.Style
- Subtle lipgloss.Style
-
- Success lipgloss.Style
- Error lipgloss.Style
- Warning lipgloss.Style
- Info lipgloss.Style
-
- // Markdown & Chroma
- Markdown ansi.StyleConfig
-
- // Inputs
- TextInput textinput.Styles
- TextArea textarea.Styles
-
- // Help
- Help help.Styles
-
- // Diff
- Diff diffview.Style
-
- // FilePicker
- FilePicker filepicker.Styles
-}
-
-func (t *Theme) S() *Styles {
- t.stylesOnce.Do(func() {
- t.styles = t.buildStyles()
- })
- return t.styles
-}
-
-func (t *Theme) buildStyles() *Styles {
- base := lipgloss.NewStyle().
- Foreground(t.FgBase)
- return &Styles{
- Base: base,
-
- SelectedBase: base.Background(t.Primary),
-
- Title: base.
- Foreground(t.Accent).
- Bold(true),
-
- Subtitle: base.
- Foreground(t.Secondary).
- Bold(true),
-
- Text: base,
- TextSelected: base.Background(t.Primary).Foreground(t.FgSelected),
-
- Muted: base.Foreground(t.FgMuted),
-
- Subtle: base.Foreground(t.FgSubtle),
-
- Success: base.Foreground(t.Success),
-
- Error: base.Foreground(t.Error),
-
- Warning: base.Foreground(t.Warning),
-
- Info: base.Foreground(t.Info),
-
- TextInput: textinput.Styles{
- Focused: textinput.StyleState{
- Text: base,
- Placeholder: base.Foreground(t.FgSubtle),
- Prompt: base.Foreground(t.Tertiary),
- Suggestion: base.Foreground(t.FgSubtle),
- },
- Blurred: textinput.StyleState{
- Text: base.Foreground(t.FgMuted),
- Placeholder: base.Foreground(t.FgSubtle),
- Prompt: base.Foreground(t.FgMuted),
- Suggestion: base.Foreground(t.FgSubtle),
- },
- Cursor: textinput.CursorStyle{
- Color: t.Secondary,
- Shape: tea.CursorBlock,
- Blink: true,
- },
- },
- TextArea: textarea.Styles{
- Focused: textarea.StyleState{
- Base: base,
- Text: base,
- LineNumber: base.Foreground(t.FgSubtle),
- CursorLine: base,
- CursorLineNumber: base.Foreground(t.FgSubtle),
- Placeholder: base.Foreground(t.FgSubtle),
- Prompt: base.Foreground(t.Tertiary),
- },
- Blurred: textarea.StyleState{
- Base: base,
- Text: base.Foreground(t.FgMuted),
- LineNumber: base.Foreground(t.FgMuted),
- CursorLine: base,
- CursorLineNumber: base.Foreground(t.FgMuted),
- Placeholder: base.Foreground(t.FgSubtle),
- Prompt: base.Foreground(t.FgMuted),
- },
- Cursor: textarea.CursorStyle{
- Color: t.Secondary,
- Shape: tea.CursorBlock,
- Blink: true,
- },
- },
-
- Markdown: ansi.StyleConfig{
- Document: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- // BlockPrefix: "\n",
- // BlockSuffix: "\n",
- Color: stringPtr(charmtone.Smoke.Hex()),
- },
- // Margin: uintPtr(defaultMargin),
- },
- BlockQuote: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{},
- Indent: uintPtr(1),
- IndentToken: stringPtr("β "),
- },
- List: ansi.StyleList{
- LevelIndent: defaultListIndent,
- },
- Heading: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- BlockSuffix: "\n",
- Color: stringPtr(charmtone.Malibu.Hex()),
- Bold: boolPtr(true),
- },
- },
- H1: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: " ",
- Suffix: " ",
- Color: stringPtr(charmtone.Zest.Hex()),
- BackgroundColor: stringPtr(charmtone.Charple.Hex()),
- Bold: boolPtr(true),
- },
- },
- H2: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "## ",
- },
- },
- H3: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "### ",
- },
- },
- H4: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "#### ",
- },
- },
- H5: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "##### ",
- },
- },
- H6: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: "###### ",
- Color: stringPtr(charmtone.Guac.Hex()),
- Bold: boolPtr(false),
- },
- },
- Strikethrough: ansi.StylePrimitive{
- CrossedOut: boolPtr(true),
- },
- Emph: ansi.StylePrimitive{
- Italic: boolPtr(true),
- },
- Strong: ansi.StylePrimitive{
- Bold: boolPtr(true),
- },
- HorizontalRule: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Charcoal.Hex()),
- Format: "\n--------\n",
- },
- Item: ansi.StylePrimitive{
- BlockPrefix: "β’ ",
- },
- Enumeration: ansi.StylePrimitive{
- BlockPrefix: ". ",
- },
- Task: ansi.StyleTask{
- StylePrimitive: ansi.StylePrimitive{},
- Ticked: "[β] ",
- Unticked: "[ ] ",
- },
- Link: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Zinc.Hex()),
- Underline: boolPtr(true),
- },
- LinkText: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Guac.Hex()),
- Bold: boolPtr(true),
- },
- Image: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Cheeky.Hex()),
- Underline: boolPtr(true),
- },
- ImageText: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Squid.Hex()),
- Format: "Image: {{.text}} β",
- },
- Code: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Prefix: " ",
- Suffix: " ",
- Color: stringPtr(charmtone.Coral.Hex()),
- BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
- },
- },
- CodeBlock: ansi.StyleCodeBlock{
- StyleBlock: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Charcoal.Hex()),
- },
- Margin: uintPtr(defaultMargin),
- },
- Chroma: &ansi.Chroma{
- Text: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Smoke.Hex()),
- },
- Error: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Butter.Hex()),
- BackgroundColor: stringPtr(charmtone.Sriracha.Hex()),
- },
- Comment: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Oyster.Hex()),
- },
- CommentPreproc: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Bengal.Hex()),
- },
- Keyword: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Malibu.Hex()),
- },
- KeywordReserved: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Pony.Hex()),
- },
- KeywordNamespace: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Pony.Hex()),
- },
- KeywordType: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Guppy.Hex()),
- },
- Operator: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Salmon.Hex()),
- },
- Punctuation: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Zest.Hex()),
- },
- Name: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Smoke.Hex()),
- },
- NameBuiltin: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Cheeky.Hex()),
- },
- NameTag: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Mauve.Hex()),
- },
- NameAttribute: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Hazy.Hex()),
- },
- NameClass: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Salt.Hex()),
- Underline: boolPtr(true),
- Bold: boolPtr(true),
- },
- NameDecorator: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Citron.Hex()),
- },
- NameFunction: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Guac.Hex()),
- },
- LiteralNumber: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Julep.Hex()),
- },
- LiteralString: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Cumin.Hex()),
- },
- LiteralStringEscape: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Bok.Hex()),
- },
- GenericDeleted: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Coral.Hex()),
- },
- GenericEmph: ansi.StylePrimitive{
- Italic: boolPtr(true),
- },
- GenericInserted: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Guac.Hex()),
- },
- GenericStrong: ansi.StylePrimitive{
- Bold: boolPtr(true),
- },
- GenericSubheading: ansi.StylePrimitive{
- Color: stringPtr(charmtone.Squid.Hex()),
- },
- Background: ansi.StylePrimitive{
- BackgroundColor: stringPtr(charmtone.Charcoal.Hex()),
- },
- },
- },
- Table: ansi.StyleTable{
- StyleBlock: ansi.StyleBlock{
- StylePrimitive: ansi.StylePrimitive{},
- },
- },
- DefinitionDescription: ansi.StylePrimitive{
- BlockPrefix: "\n ",
- },
- },
-
- Help: help.Styles{
- ShortKey: base.Foreground(t.FgMuted),
- ShortDesc: base.Foreground(t.FgSubtle),
- ShortSeparator: base.Foreground(t.Border),
- Ellipsis: base.Foreground(t.Border),
- FullKey: base.Foreground(t.FgMuted),
- FullDesc: base.Foreground(t.FgSubtle),
- FullSeparator: base.Foreground(t.Border),
- },
-
- Diff: diffview.Style{
- DividerLine: diffview.LineStyle{
- LineNumber: lipgloss.NewStyle().
- Foreground(t.FgHalfMuted).
- Background(t.BgBaseLighter),
- Code: lipgloss.NewStyle().
- Foreground(t.FgHalfMuted).
- Background(t.BgBaseLighter),
- },
- MissingLine: diffview.LineStyle{
- LineNumber: lipgloss.NewStyle().
- Background(t.BgBaseLighter),
- Code: lipgloss.NewStyle().
- Background(t.BgBaseLighter),
- },
- EqualLine: diffview.LineStyle{
- LineNumber: lipgloss.NewStyle().
- Foreground(t.FgMuted).
- Background(t.BgBase),
- Code: lipgloss.NewStyle().
- Foreground(t.FgMuted).
- Background(t.BgBase),
- },
- InsertLine: diffview.LineStyle{
- LineNumber: lipgloss.NewStyle().
- Foreground(lipgloss.Color("#629657")).
- Background(lipgloss.Color("#2b322a")),
- Symbol: lipgloss.NewStyle().
- Foreground(lipgloss.Color("#629657")).
- Background(lipgloss.Color("#323931")),
- Code: lipgloss.NewStyle().
- Background(lipgloss.Color("#323931")),
- },
- DeleteLine: diffview.LineStyle{
- LineNumber: lipgloss.NewStyle().
- Foreground(lipgloss.Color("#a45c59")).
- Background(lipgloss.Color("#312929")),
- Symbol: lipgloss.NewStyle().
- Foreground(lipgloss.Color("#a45c59")).
- Background(lipgloss.Color("#383030")),
- Code: lipgloss.NewStyle().
- Background(lipgloss.Color("#383030")),
- },
- },
- FilePicker: filepicker.Styles{
- DisabledCursor: base.Foreground(t.FgMuted),
- Cursor: base.Foreground(t.FgBase),
- Symlink: base.Foreground(t.FgSubtle),
- Directory: base.Foreground(t.Primary),
- File: base.Foreground(t.FgBase),
- DisabledFile: base.Foreground(t.FgMuted),
- DisabledSelected: base.Background(t.BgOverlay).Foreground(t.FgMuted),
- Permission: base.Foreground(t.FgMuted),
- Selected: base.Background(t.Primary).Foreground(t.FgBase),
- FileSize: base.Foreground(t.FgMuted),
- EmptyDirectory: base.Foreground(t.FgMuted).PaddingLeft(2).SetString("Empty directory"),
- },
- }
-}
-
-type Manager struct {
- themes map[string]*Theme
- current *Theme
-}
-
-var (
- defaultManager *Manager
- defaultManagerOnce sync.Once
-)
-
-func initDefaultManager() *Manager {
- defaultManagerOnce.Do(func() {
- defaultManager = newManager()
- })
- return defaultManager
-}
-
-func SetDefaultManager(m *Manager) {
- defaultManager = m
-}
-
-func DefaultManager() *Manager {
- return initDefaultManager()
-}
-
-func CurrentTheme() *Theme {
- return initDefaultManager().Current()
-}
-
-func newManager() *Manager {
- m := &Manager{
- themes: make(map[string]*Theme),
- }
-
- t := NewCharmtoneTheme() // default theme
- m.Register(t)
- m.current = m.themes[t.Name]
-
- return m
-}
-
-func (m *Manager) Register(theme *Theme) {
- m.themes[theme.Name] = theme
-}
-
-func (m *Manager) Current() *Theme {
- return m.current
-}
-
-func (m *Manager) SetTheme(name string) error {
- if theme, ok := m.themes[name]; ok {
- m.current = theme
- return nil
- }
- return fmt.Errorf("theme %s not found", name)
-}
-
-func (m *Manager) List() []string {
- names := make([]string, 0, len(m.themes))
- for name := range m.themes {
- names = append(names, name)
- }
- return names
-}
-
-// ParseHex converts hex string to color
-func ParseHex(hex string) color.Color {
- var r, g, b uint8
- fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
- return color.RGBA{R: r, G: g, B: b, A: 255}
-}
-
-// Alpha returns a color with transparency
-func Alpha(c color.Color, alpha uint8) color.Color {
- r, g, b, _ := c.RGBA()
- return color.RGBA{
- R: uint8(r >> 8),
- G: uint8(g >> 8),
- B: uint8(b >> 8),
- A: alpha,
- }
-}
-
-// Darken makes a color darker by percentage (0-100)
-func Darken(c color.Color, percent float64) color.Color {
- r, g, b, a := c.RGBA()
- factor := 1.0 - percent/100.0
- return color.RGBA{
- R: uint8(float64(r>>8) * factor),
- G: uint8(float64(g>>8) * factor),
- B: uint8(float64(b>>8) * factor),
- A: uint8(a >> 8),
- }
-}
-
-// Lighten makes a color lighter by percentage (0-100)
-func Lighten(c color.Color, percent float64) color.Color {
- r, g, b, a := c.RGBA()
- factor := percent / 100.0
- return color.RGBA{
- R: uint8(min(255, float64(r>>8)+255*factor)),
- G: uint8(min(255, float64(g>>8)+255*factor)),
- B: uint8(min(255, float64(b>>8)+255*factor)),
- A: uint8(a >> 8),
- }
-}
-
-func ForegroundGrad(input string, bold bool, color1, color2 color.Color) []string {
- if input == "" {
- return []string{""}
- }
- t := CurrentTheme()
- if len(input) == 1 {
- style := t.S().Base.Foreground(color1)
- if bold {
- style.Bold(true)
- }
- return []string{style.Render(input)}
- }
- var clusters []string
- gr := uniseg.NewGraphemes(input)
- for gr.Next() {
- clusters = append(clusters, string(gr.Runes()))
- }
-
- ramp := blendColors(len(clusters), color1, color2)
- for i, c := range ramp {
- style := t.S().Base.Foreground(c)
- if bold {
- style.Bold(true)
- }
- clusters[i] = style.Render(clusters[i])
- }
- return clusters
-}
-
-// ApplyForegroundGrad renders a given string with a horizontal gradient
-// foreground.
-func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
- if input == "" {
- return ""
- }
- var o strings.Builder
- clusters := ForegroundGrad(input, false, color1, color2)
- for _, c := range clusters {
- fmt.Fprint(&o, c)
- }
- return o.String()
-}
-
-// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
-// foreground.
-func ApplyBoldForegroundGrad(input string, color1, color2 color.Color) string {
- if input == "" {
- return ""
- }
- var o strings.Builder
- clusters := ForegroundGrad(input, true, color1, color2)
- for _, c := range clusters {
- fmt.Fprint(&o, c)
- }
- return o.String()
-}
-
-// blendColors returns a slice of colors blended between the given keys.
-// Blending is done in Hcl to stay in gamut.
-func blendColors(size int, stops ...color.Color) []color.Color {
- if len(stops) < 2 {
- return nil
- }
-
- stopsPrime := make([]colorful.Color, len(stops))
- for i, k := range stops {
- stopsPrime[i], _ = colorful.MakeColor(k)
- }
-
- numSegments := len(stopsPrime) - 1
- blended := make([]color.Color, 0, size)
-
- // Calculate how many colors each segment should have.
- segmentSizes := make([]int, numSegments)
- baseSize := size / numSegments
- remainder := size % numSegments
-
- // Distribute the remainder across segments.
- for i := range numSegments {
- segmentSizes[i] = baseSize
- if i < remainder {
- segmentSizes[i]++
- }
- }
-
- // Generate colors for each segment.
- for i := range numSegments {
- c1 := stopsPrime[i]
- c2 := stopsPrime[i+1]
- segmentSize := segmentSizes[i]
-
- for j := range segmentSize {
- var t float64
- if segmentSize > 1 {
- t = float64(j) / float64(segmentSize-1)
- }
- c := c1.BlendHcl(c2, t)
- blended = append(blended, c)
- }
- }
-
- return blended
-}
@@ -1,712 +0,0 @@
-package tui
-
-import (
- "context"
- "fmt"
- "math/rand"
- "regexp"
- "slices"
- "strings"
- "time"
-
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/agent/tools/mcp"
- "github.com/charmbracelet/crush/internal/app"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/event"
- "github.com/charmbracelet/crush/internal/home"
- "github.com/charmbracelet/crush/internal/permission"
- "github.com/charmbracelet/crush/internal/pubsub"
- cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
- "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
- "github.com/charmbracelet/crush/internal/tui/components/completions"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/components/core/status"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
- "github.com/charmbracelet/crush/internal/tui/page"
- "github.com/charmbracelet/crush/internal/tui/page/chat"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- xstrings "github.com/charmbracelet/x/exp/strings"
- "golang.org/x/mod/semver"
- "golang.org/x/text/cases"
- "golang.org/x/text/language"
-)
-
-var lastMouseEvent time.Time
-
-func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
- switch msg.(type) {
- case tea.MouseWheelMsg, tea.MouseMotionMsg:
- now := time.Now()
- // trackpad is sending too many requests
- if now.Sub(lastMouseEvent) < 15*time.Millisecond {
- return nil
- }
- lastMouseEvent = now
- }
- return msg
-}
-
-// appModel represents the main application model that manages pages, dialogs, and UI state.
-type appModel struct {
- wWidth, wHeight int // Window dimensions
- width, height int
- keyMap KeyMap
-
- currentPage page.PageID
- previousPage page.PageID
- pages map[page.PageID]util.Model
- loadedPages map[page.PageID]bool
-
- // Status
- status status.StatusCmp
- showingFullHelp bool
-
- app *app.App
-
- dialog dialogs.DialogCmp
- completions completions.Completions
- isConfigured bool
-
- // Chat Page Specific
- selectedSessionID string // The ID of the currently selected session
-
- // sendProgressBar instructs the TUI to send progress bar updates to the
- // terminal.
- sendProgressBar bool
-
- // QueryVersion instructs the TUI to query for the terminal version when it
- // starts.
- QueryVersion bool
-}
-
-// Init initializes the application model and returns initial commands.
-func (a appModel) Init() tea.Cmd {
- item, ok := a.pages[a.currentPage]
- if !ok {
- return nil
- }
-
- var cmds []tea.Cmd
- cmd := item.Init()
- cmds = append(cmds, cmd)
- a.loadedPages[a.currentPage] = true
-
- cmd = a.status.Init()
- cmds = append(cmds, cmd)
- if a.QueryVersion {
- cmds = append(cmds, tea.RequestTerminalVersion)
- }
-
- return tea.Batch(cmds...)
-}
-
-// Update handles incoming messages and updates the application state.
-func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- var cmd tea.Cmd
- a.isConfigured = config.HasInitialDataConfig()
-
- switch msg := msg.(type) {
- case tea.EnvMsg:
- // Is this Windows Terminal?
- if !a.sendProgressBar {
- a.sendProgressBar = slices.Contains(msg, "WT_SESSION")
- }
- case tea.TerminalVersionMsg:
- if a.sendProgressBar {
- return a, nil
- }
- termVersion := strings.ToLower(msg.Name)
- switch {
- case xstrings.ContainsAnyOf(termVersion, "ghostty", "rio"):
- a.sendProgressBar = true
- case strings.Contains(termVersion, "iterm2"):
- // iTerm2 supports progress bars from version v3.6.6
- matches := regexp.MustCompile(`^iterm2 (\d+\.\d+\.\d+)$`).FindStringSubmatch(termVersion)
- if len(matches) == 2 && semver.Compare("v"+matches[1], "v3.6.6") >= 0 {
- a.sendProgressBar = true
- }
- }
- return a, nil
- case tea.KeyboardEnhancementsMsg:
- // A non-zero value means we have key disambiguation support.
- if msg.Flags > 0 {
- a.keyMap.Models.SetHelp("ctrl+m", "models")
- }
- for id, page := range a.pages {
- m, pageCmd := page.Update(msg)
- a.pages[id] = m
-
- if pageCmd != nil {
- cmds = append(cmds, pageCmd)
- }
- }
- return a, tea.Batch(cmds...)
- case tea.WindowSizeMsg:
- a.wWidth, a.wHeight = msg.Width, msg.Height
- a.completions.Update(msg)
- return a, a.handleWindowResize(msg.Width, msg.Height)
-
- case pubsub.Event[mcp.Event]:
- switch msg.Payload.Type {
- case mcp.EventStateChanged:
- return a, a.handleStateChanged(context.Background())
- case mcp.EventPromptsListChanged:
- return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name)
- case mcp.EventToolsListChanged:
- return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name)
- }
-
- // Completions messages
- case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
- completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
- u, completionCmd := a.completions.Update(msg)
- if model, ok := u.(completions.Completions); ok {
- a.completions = model
- }
-
- return a, completionCmd
-
- // Dialog messages
- case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
- u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
- a.completions = u.(completions.Completions)
- u, dialogCmd := a.dialog.Update(msg)
- a.dialog = u.(dialogs.DialogCmp)
- return a, tea.Batch(completionCmd, dialogCmd)
- case commands.ShowArgumentsDialogMsg:
- var args []commands.Argument
- for _, arg := range msg.ArgNames {
- args = append(args, commands.Argument{
- Name: arg,
- Title: cases.Title(language.English).String(arg),
- Required: true,
- })
- }
- return a, util.CmdHandler(
- dialogs.OpenDialogMsg{
- Model: commands.NewCommandArgumentsDialog(
- msg.CommandID,
- msg.CommandID,
- msg.CommandID,
- msg.Description,
- args,
- msg.OnSubmit,
- ),
- },
- )
- case commands.ShowMCPPromptArgumentsDialogMsg:
- args := make([]commands.Argument, 0, len(msg.Prompt.Arguments))
- for _, arg := range msg.Prompt.Arguments {
- args = append(args, commands.Argument(*arg))
- }
- dialog := commands.NewCommandArgumentsDialog(
- msg.Prompt.Name,
- msg.Prompt.Title,
- msg.Prompt.Name,
- msg.Prompt.Description,
- args,
- msg.OnSubmit,
- )
- return a, util.CmdHandler(
- dialogs.OpenDialogMsg{
- Model: dialog,
- },
- )
- // Page change messages
- case page.PageChangeMsg:
- return a, a.moveToPage(msg.ID)
-
- // Status Messages
- case util.InfoMsg, util.ClearStatusMsg:
- s, statusCmd := a.status.Update(msg)
- a.status = s.(status.StatusCmp)
- cmds = append(cmds, statusCmd)
- return a, tea.Batch(cmds...)
-
- // Session
- case cmpChat.SessionSelectedMsg:
- a.selectedSessionID = msg.ID
- case cmpChat.SessionClearedMsg:
- a.selectedSessionID = ""
- // Commands
- case commands.SwitchSessionsMsg:
- return a, func() tea.Msg {
- allSessions, _ := a.app.Sessions.List(context.Background())
- return dialogs.OpenDialogMsg{
- Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
- }
- }
-
- case commands.SwitchModelMsg:
- return a, util.CmdHandler(
- dialogs.OpenDialogMsg{
- Model: models.NewModelDialogCmp(),
- },
- )
- // Compact
- case commands.CompactMsg:
- return a, func() tea.Msg {
- err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
- if err != nil {
- return util.ReportError(err)()
- }
- return nil
- }
- case commands.QuitMsg:
- return a, util.CmdHandler(dialogs.OpenDialogMsg{
- Model: quit.NewQuitDialog(),
- })
- case commands.ToggleYoloModeMsg:
- a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
- case commands.ToggleHelpMsg:
- a.status.ToggleFullHelp()
- a.showingFullHelp = !a.showingFullHelp
- return a, a.handleWindowResize(a.wWidth, a.wHeight)
- // Model Switch
- case models.ModelSelectedMsg:
- if a.app.AgentCoordinator.IsBusy() {
- return a, util.ReportWarn("Agent is busy, please wait...")
- }
-
- cfg := config.Get()
- if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
- return a, util.ReportError(err)
- }
-
- go a.app.UpdateAgentModel(context.TODO())
-
- modelTypeName := "large"
- if msg.ModelType == config.SelectedModelTypeSmall {
- modelTypeName = "small"
- }
- return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
-
- // File Picker
- case commands.OpenFilePickerMsg:
- event.FilePickerOpened()
-
- if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
- // If the commands dialog is already open, close it
- return a, util.CmdHandler(dialogs.CloseDialogMsg{})
- }
- return a, util.CmdHandler(dialogs.OpenDialogMsg{
- Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
- })
- // Permissions
- case pubsub.Event[permission.PermissionNotification]:
- item, ok := a.pages[a.currentPage]
- if !ok {
- return a, nil
- }
-
- // Forward to view.
- updated, itemCmd := item.Update(msg)
- a.pages[a.currentPage] = updated
-
- return a, itemCmd
- case pubsub.Event[permission.PermissionRequest]:
- return a, util.CmdHandler(dialogs.OpenDialogMsg{
- Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
- DiffMode: config.Get().Options.TUI.DiffMode,
- }),
- })
- case permissions.PermissionResponseMsg:
- switch msg.Action {
- case permissions.PermissionAllow:
- a.app.Permissions.Grant(msg.Permission)
- case permissions.PermissionAllowForSession:
- a.app.Permissions.GrantPersistent(msg.Permission)
- case permissions.PermissionDeny:
- a.app.Permissions.Deny(msg.Permission)
- }
- return a, nil
- case splash.OnboardingCompleteMsg:
- item, ok := a.pages[a.currentPage]
- if !ok {
- return a, nil
- }
-
- a.isConfigured = config.HasInitialDataConfig()
- updated, pageCmd := item.Update(msg)
- a.pages[a.currentPage] = updated
-
- cmds = append(cmds, pageCmd)
- return a, tea.Batch(cmds...)
-
- case tea.KeyPressMsg:
- return a, a.handleKeyPressMsg(msg)
-
- case tea.MouseWheelMsg:
- if a.dialog.HasDialogs() {
- u, dialogCmd := a.dialog.Update(msg)
- a.dialog = u.(dialogs.DialogCmp)
- cmds = append(cmds, dialogCmd)
- } else {
- item, ok := a.pages[a.currentPage]
- if !ok {
- return a, nil
- }
-
- updated, pageCmd := item.Update(msg)
- a.pages[a.currentPage] = updated
-
- cmds = append(cmds, pageCmd)
- }
- return a, tea.Batch(cmds...)
- case tea.PasteMsg:
- if a.dialog.HasDialogs() {
- u, dialogCmd := a.dialog.Update(msg)
- if model, ok := u.(dialogs.DialogCmp); ok {
- a.dialog = model
- }
-
- cmds = append(cmds, dialogCmd)
- } else {
- item, ok := a.pages[a.currentPage]
- if !ok {
- return a, nil
- }
-
- updated, pageCmd := item.Update(msg)
- a.pages[a.currentPage] = updated
-
- cmds = append(cmds, pageCmd)
- }
- return a, tea.Batch(cmds...)
- // Update Available
- case app.UpdateAvailableMsg:
- // Show update notification in status bar
- statusMsg := fmt.Sprintf("Crush update available: v%s β v%s.", msg.CurrentVersion, msg.LatestVersion)
- if msg.IsDevelopment {
- statusMsg = fmt.Sprintf("This is a development version of Crush. The latest version is v%s.", msg.LatestVersion)
- }
- s, statusCmd := a.status.Update(util.InfoMsg{
- Type: util.InfoTypeUpdate,
- Msg: statusMsg,
- TTL: 10 * time.Second,
- })
- a.status = s.(status.StatusCmp)
- return a, statusCmd
- }
- s, _ := a.status.Update(msg)
- a.status = s.(status.StatusCmp)
-
- item, ok := a.pages[a.currentPage]
- if !ok {
- return a, nil
- }
-
- updated, cmd := item.Update(msg)
- a.pages[a.currentPage] = updated
-
- if a.dialog.HasDialogs() {
- u, dialogCmd := a.dialog.Update(msg)
- if model, ok := u.(dialogs.DialogCmp); ok {
- a.dialog = model
- }
-
- cmds = append(cmds, dialogCmd)
- }
- cmds = append(cmds, cmd)
- return a, tea.Batch(cmds...)
-}
-
-// handleWindowResize processes window resize events and updates all components.
-func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
- var cmds []tea.Cmd
-
- // TODO: clean up these magic numbers.
- if a.showingFullHelp {
- height -= 5
- } else {
- height -= 2
- }
-
- a.width, a.height = width, height
- // Update status bar
- s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
- if model, ok := s.(status.StatusCmp); ok {
- a.status = model
- }
- cmds = append(cmds, cmd)
-
- // Update the current view.
- for p, page := range a.pages {
- updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
- a.pages[p] = updated
-
- cmds = append(cmds, pageCmd)
- }
-
- // Update the dialogs
- dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
- if model, ok := dialog.(dialogs.DialogCmp); ok {
- a.dialog = model
- }
-
- cmds = append(cmds, cmd)
-
- return tea.Batch(cmds...)
-}
-
-// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
-func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
- // Check this first as the user should be able to quit no matter what.
- if key.Matches(msg, a.keyMap.Quit) {
- if a.dialog.ActiveDialogID() == quit.QuitDialogID {
- return tea.Quit
- }
- return util.CmdHandler(dialogs.OpenDialogMsg{
- Model: quit.NewQuitDialog(),
- })
- }
-
- if a.completions.Open() {
- // completions
- keyMap := a.completions.KeyMap()
- switch {
- case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
- key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
- key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
- u, cmd := a.completions.Update(msg)
- a.completions = u.(completions.Completions)
- return cmd
- }
- }
- if a.dialog.HasDialogs() {
- u, dialogCmd := a.dialog.Update(msg)
- a.dialog = u.(dialogs.DialogCmp)
- return dialogCmd
- }
- switch {
- // help
- case key.Matches(msg, a.keyMap.Help):
- a.status.ToggleFullHelp()
- a.showingFullHelp = !a.showingFullHelp
- return a.handleWindowResize(a.wWidth, a.wHeight)
- // dialogs
- case key.Matches(msg, a.keyMap.Commands):
- // if the app is not configured show no commands
- if !a.isConfigured {
- return nil
- }
- if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
- return util.CmdHandler(dialogs.CloseDialogMsg{})
- }
- if a.dialog.HasDialogs() {
- return nil
- }
- return util.CmdHandler(dialogs.OpenDialogMsg{
- Model: commands.NewCommandDialog(a.selectedSessionID),
- })
- case key.Matches(msg, a.keyMap.Models):
- // if the app is not configured show no models
- if !a.isConfigured {
- return nil
- }
- if a.dialog.ActiveDialogID() == models.ModelsDialogID {
- return util.CmdHandler(dialogs.CloseDialogMsg{})
- }
- if a.dialog.HasDialogs() {
- return nil
- }
- return util.CmdHandler(dialogs.OpenDialogMsg{
- Model: models.NewModelDialogCmp(),
- })
- case key.Matches(msg, a.keyMap.Sessions):
- // if the app is not configured show no sessions
- if !a.isConfigured {
- return nil
- }
- if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
- return util.CmdHandler(dialogs.CloseDialogMsg{})
- }
- if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
- return nil
- }
- var cmds []tea.Cmd
- cmds = append(cmds,
- func() tea.Msg {
- allSessions, _ := a.app.Sessions.List(context.Background())
- return dialogs.OpenDialogMsg{
- Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
- }
- },
- )
- return tea.Sequence(cmds...)
- case key.Matches(msg, a.keyMap.Suspend):
- if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
- return util.ReportWarn("Agent is busy, please wait...")
- }
- return tea.Suspend
- default:
- item, ok := a.pages[a.currentPage]
- if !ok {
- return nil
- }
-
- updated, cmd := item.Update(msg)
- a.pages[a.currentPage] = updated
- return cmd
- }
-}
-
-// moveToPage handles navigation between different pages in the application.
-func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
- if a.app.AgentCoordinator.IsBusy() {
- // TODO: maybe remove this : For now we don't move to any page if the agent is busy
- return util.ReportWarn("Agent is busy, please wait...")
- }
-
- var cmds []tea.Cmd
- if _, ok := a.loadedPages[pageID]; !ok {
- cmd := a.pages[pageID].Init()
- cmds = append(cmds, cmd)
- a.loadedPages[pageID] = true
- }
- a.previousPage = a.currentPage
- a.currentPage = pageID
- if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
- cmd := sizable.SetSize(a.width, a.height)
- cmds = append(cmds, cmd)
- }
-
- return tea.Batch(cmds...)
-}
-
-// View renders the complete application interface including pages, dialogs, and overlays.
-func (a *appModel) View() tea.View {
- var view tea.View
- t := styles.CurrentTheme()
- view.AltScreen = true
- view.MouseMode = tea.MouseModeCellMotion
- view.BackgroundColor = t.BgBase
- view.WindowTitle = "crush " + home.Short(config.Get().WorkingDir())
- if a.wWidth < 25 || a.wHeight < 15 {
- view.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight).
- Align(lipgloss.Center, lipgloss.Center).
- Render(t.S().Base.
- Padding(1, 4).
- Foreground(t.White).
- BorderStyle(lipgloss.RoundedBorder()).
- BorderForeground(t.Primary).
- Render("Window too small!"),
- )
- return view
- }
-
- page := a.pages[a.currentPage]
- if withHelp, ok := page.(core.KeyMapHelp); ok {
- a.status.SetKeyMap(withHelp.Help())
- }
- pageView := page.View()
- components := []string{
- pageView,
- }
- components = append(components, a.status.View())
-
- appView := lipgloss.JoinVertical(lipgloss.Top, components...)
- layers := []*lipgloss.Layer{
- lipgloss.NewLayer(appView),
- }
- if a.dialog.HasDialogs() {
- layers = append(
- layers,
- a.dialog.GetLayers()...,
- )
- }
-
- var cursor *tea.Cursor
- if v, ok := page.(util.Cursor); ok {
- cursor = v.Cursor()
- // Hide the cursor if it's positioned outside the textarea
- statusHeight := a.height - strings.Count(pageView, "\n") + 1
- if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
- cursor = nil
- }
- }
- activeView := a.dialog.ActiveModel()
- if activeView != nil {
- cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
- if v, ok := activeView.(util.Cursor); ok {
- cursor = v.Cursor()
- }
- }
-
- if a.completions.Open() && cursor != nil {
- cmp := a.completions.View()
- x, y := a.completions.Position()
- layers = append(
- layers,
- lipgloss.NewLayer(cmp).X(x).Y(y),
- )
- }
-
- comp := lipgloss.NewCompositor(layers...)
- view.Content = comp.Render()
- view.Cursor = cursor
-
- if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
- // HACK: use a random percentage to prevent ghostty from hiding it
- // after a timeout.
- view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
- }
- return view
-}
-
-func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
- return func() tea.Msg {
- a.app.UpdateAgentModel(ctx)
- return nil
- }
-}
-
-func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
- return func() tea.Msg {
- mcp.RefreshPrompts(ctx, name)
- return nil
- }
-}
-
-func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
- return func() tea.Msg {
- mcp.RefreshTools(ctx, name)
- return nil
- }
-}
-
-// New creates and initializes a new TUI application model.
-func New(app *app.App) *appModel {
- chatPage := chat.New(app)
- keyMap := DefaultKeyMap()
- keyMap.pageBindings = chatPage.Bindings()
-
- model := &appModel{
- currentPage: chat.ChatPageID,
- app: app,
- status: status.NewStatusCmp(),
- loadedPages: make(map[page.PageID]bool),
- keyMap: keyMap,
-
- pages: map[page.PageID]util.Model{
- chat.ChatPageID: chatPage,
- },
-
- dialog: dialogs.NewDialogCmp(),
- completions: completions.New(),
- }
-
- return model
-}
@@ -1,15 +0,0 @@
-package util
-
-import (
- "context"
-
- tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/uiutil"
-)
-
-// ExecShell parses a shell command string and executes it with exec.Command.
-// Uses shell.Fields for proper handling of shell syntax like quotes and
-// arguments while preserving TTY handling for terminal editors.
-func ExecShell(ctx context.Context, cmdStr string, callback tea.ExecCallback) tea.Cmd {
- return uiutil.ExecShell(ctx, cmdStr, callback)
-}
@@ -1,45 +0,0 @@
-package util
-
-import (
- tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/uiutil"
-)
-
-type Cursor = uiutil.Cursor
-
-type Model interface {
- Init() tea.Cmd
- Update(tea.Msg) (Model, tea.Cmd)
- View() string
-}
-
-func CmdHandler(msg tea.Msg) tea.Cmd {
- return uiutil.CmdHandler(msg)
-}
-
-func ReportError(err error) tea.Cmd {
- return uiutil.ReportError(err)
-}
-
-type InfoType = uiutil.InfoType
-
-const (
- InfoTypeInfo = uiutil.InfoTypeInfo
- InfoTypeSuccess = uiutil.InfoTypeSuccess
- InfoTypeWarn = uiutil.InfoTypeWarn
- InfoTypeError = uiutil.InfoTypeError
- InfoTypeUpdate = uiutil.InfoTypeUpdate
-)
-
-func ReportInfo(info string) tea.Cmd {
- return uiutil.ReportInfo(info)
-}
-
-func ReportWarn(warn string) tea.Cmd {
- return uiutil.ReportWarn(warn)
-}
-
-type (
- InfoMsg = uiutil.InfoMsg
- ClearStatusMsg = uiutil.ClearStatusMsg
-)
@@ -10,7 +10,7 @@ import (
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/styles"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
uv "github.com/charmbracelet/ultraviolet"
)
@@ -95,6 +95,6 @@ func CopyToClipboardWithCallback(text, successMessage string, callback tea.Cmd)
return nil
},
callback,
- uiutil.ReportInfo(successMessage),
+ util.ReportInfo(successMessage),
)
}
@@ -2,7 +2,7 @@ package common
import (
"github.com/alecthomas/chroma/v2"
- "github.com/charmbracelet/crush/internal/tui/exp/diffview"
+ "github.com/charmbracelet/crush/internal/ui/diffview"
"github.com/charmbracelet/crush/internal/ui/styles"
)
@@ -15,7 +15,7 @@ import (
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/common"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
)
// ActionClose is a message to close the current dialog.
@@ -131,22 +131,22 @@ func (a ActionFilePickerSelected) Cmd() tea.Cmd {
return func() tea.Msg {
isFileLarge, err := common.IsFileTooBig(path, common.MaxAttachmentSize)
if err != nil {
- return uiutil.InfoMsg{
- Type: uiutil.InfoTypeError,
+ return util.InfoMsg{
+ Type: util.InfoTypeError,
Msg: fmt.Sprintf("unable to read the image: %v", err),
}
}
if isFileLarge {
- return uiutil.InfoMsg{
- Type: uiutil.InfoTypeError,
+ return util.InfoMsg{
+ Type: util.InfoTypeError,
Msg: "file too large, max 5MB",
}
}
content, err := os.ReadFile(path)
if err != nil {
- return uiutil.InfoMsg{
- Type: uiutil.InfoTypeError,
+ return util.InfoMsg{
+ Type: util.InfoTypeError,
Msg: fmt.Sprintf("unable to read the image: %v", err),
}
}
@@ -14,7 +14,7 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/exp/charmtone"
)
@@ -316,7 +316,7 @@ func (m *APIKeyInput) saveKeyAndContinue() Action {
err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value())
if err != nil {
- return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
+ return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
}
return ActionSelectModel{
@@ -15,7 +15,7 @@ import (
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/ui/common"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
uv "github.com/charmbracelet/ultraviolet"
)
@@ -202,7 +202,7 @@ func (a *Arguments) HandleMsg(msg tea.Msg) Action {
for i, arg := range a.arguments {
args[arg.ID] = a.inputs[i].Value()
if arg.Required && strings.TrimSpace(a.inputs[i].Value()) == "" {
- warning = uiutil.ReportWarn("Required argument '" + arg.Title + "' is missing.")
+ warning = util.ReportWarn("Required argument '" + arg.Title + "' is missing.")
break
}
}
@@ -13,7 +13,7 @@ import (
"charm.land/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/ui/common"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
uv "github.com/charmbracelet/ultraviolet"
)
@@ -207,7 +207,7 @@ func (m *Models) HandleMsg(msg tea.Msg) Action {
m.modelType = ModelTypeLarge
}
if err := m.setProviderItems(); err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
default:
var cmd tea.Cmd
@@ -14,7 +14,7 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/oauth"
"github.com/charmbracelet/crush/internal/ui/common"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
uv "github.com/charmbracelet/ultraviolet"
"github.com/pkg/browser"
)
@@ -173,7 +173,7 @@ func (m *OAuth) HandleMsg(msg tea.Msg) Action {
case ActionOAuthErrored:
m.State = OAuthStateError
- cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error))
+ cmd := tea.Batch(m.oAuthProvider.stopPolling, util.ReportError(msg.Error))
return ActionCmd{cmd}
}
return nil
@@ -352,7 +352,7 @@ func (d *OAuth) copyCode() tea.Cmd {
}
return tea.Sequence(
tea.SetClipboard(d.userCode),
- uiutil.ReportInfo("Code copied to clipboard"),
+ util.ReportInfo("Code copied to clipboard"),
)
}
@@ -368,7 +368,7 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
}
return nil
},
- uiutil.ReportInfo("Code copied and URL opened"),
+ util.ReportInfo("Code copied and URL opened"),
)
}
@@ -377,7 +377,7 @@ func (m *OAuth) saveKeyAndContinue() Action {
err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token)
if err != nil {
- return ActionCmd{uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))}
+ return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
}
return ActionSelectModel{
@@ -12,7 +12,7 @@ import (
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/list"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
uv "github.com/charmbracelet/ultraviolet"
)
@@ -182,7 +182,7 @@ func (s *Session) HandleMsg(msg tea.Msg) Action {
s.list.SetItems(sessionItems(s.com.Styles, sessionsModeUpdating, s.sessions...)...)
case key.Matches(msg, s.keyMap.Delete):
if s.isCurrentSessionBusy() {
- return ActionCmd{uiutil.ReportWarn("Agent is busy, please wait...")}
+ return ActionCmd{util.ReportWarn("Agent is busy, please wait...")}
}
s.sessionsMode = sessionsModeDeleting
s.list.SetItems(sessionItems(s.com.Styles, sessionsModeDeleting, s.sessions...)...)
@@ -353,7 +353,7 @@ func (s *Session) deleteSessionCmd(id string) tea.Cmd {
return func() tea.Msg {
err := s.com.App.Sessions.Delete(context.TODO(), id)
if err != nil {
- return uiutil.NewErrorMsg(err)
+ return util.NewErrorMsg(err)
}
return nil
}
@@ -389,7 +389,7 @@ func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
return func() tea.Msg {
_, err := s.com.App.Sessions.Save(context.TODO(), session)
if err != nil {
- return uiutil.NewErrorMsg(err)
+ return util.NewErrorMsg(err)
}
return nil
}
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/alecthomas/chroma/v2/styles"
- "github.com/charmbracelet/crush/internal/tui/exp/diffview"
+ "github.com/charmbracelet/crush/internal/ui/diffview"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/golden"
)
@@ -12,7 +12,7 @@ import (
"sync"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/ansi/kitty"
"github.com/disintegration/imaging"
@@ -169,8 +169,8 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i
},
}); err != nil {
slog.Error("Failed to encode image for kitty graphics", "err", err)
- return uiutil.InfoMsg{
- Type: uiutil.InfoTypeError,
+ return util.InfoMsg{
+ Type: util.InfoTypeError,
Msg: "failed to encode image",
}
}
@@ -8,7 +8,7 @@ import (
"charm.land/lipgloss/v2"
"github.com/MakeNowJust/heredoc"
- "github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/ui/styles"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/exp/slice"
)
@@ -34,7 +34,7 @@ type Opts struct {
//
// The compact argument determines whether it renders compact for the sidebar
// or wider for the main pane.
-func Render(version string, compact bool, o Opts) string {
+func Render(s *styles.Styles, version string, compact bool, o Opts) string {
const charm = " Charmβ’"
fg := func(c color.Color, s string) string {
@@ -59,7 +59,7 @@ func Render(version string, compact bool, o Opts) string {
crushWidth := lipgloss.Width(crush)
b := new(strings.Builder)
for r := range strings.SplitSeq(crush, "\n") {
- fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+ fmt.Fprintln(b, styles.ApplyForegroundGrad(s, r, o.TitleColorA, o.TitleColorB))
}
crush = b.String()
@@ -117,14 +117,13 @@ func Render(version string, compact bool, o Opts) string {
// SmallRender renders a smaller version of the Crush logo, suitable for
// smaller windows or sidebar usage.
-func SmallRender(width int) string {
- t := styles.CurrentTheme()
- title := t.S().Base.Foreground(t.Secondary).Render("Charmβ’")
- title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
+func SmallRender(t *styles.Styles, width int) string {
+ title := t.Base.Foreground(t.Secondary).Render("Charmβ’")
+ title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad(t, "Crush", t.Secondary, t.Primary))
remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
if remainingWidth > 0 {
lines := strings.Repeat("β±", remainingWidth)
- title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
+ title = fmt.Sprintf("%s %s", title, t.Base.Foreground(t.Primary).Render(lines))
}
return title
}
@@ -0,0 +1,22 @@
+package model
+
+import (
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+var lastMouseEvent time.Time
+
+func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
+ switch msg.(type) {
+ case tea.MouseWheelMsg, tea.MouseMotionMsg:
+ now := time.Now()
+ // trackpad is sending too many requests
+ if now.Sub(lastMouseEvent) < 15*time.Millisecond {
+ return nil
+ }
+ lastMouseEvent = now
+ }
+ return msg
+}
@@ -13,7 +13,7 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/home"
"github.com/charmbracelet/crush/internal/ui/common"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
)
// markProjectInitialized marks the current project as initialized in the config.
@@ -57,7 +57,7 @@ func (m *UI) initializeProject() tea.Cmd {
initialize := func() tea.Msg {
initPrompt, err := agent.InitializePrompt(*cfg)
if err != nil {
- return uiutil.InfoMsg{Type: uiutil.InfoTypeError, Msg: err.Error()}
+ return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
}
return sendMessageMsg{Content: initPrompt}
}
@@ -15,7 +15,7 @@ import (
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/styles"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
"github.com/charmbracelet/x/ansi"
)
@@ -44,13 +44,13 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
session, err := m.com.App.Sessions.Get(context.Background(), sessionID)
if err != nil {
// TODO: better error handling
- return uiutil.ReportError(err)()
+ return util.ReportError(err)()
}
files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
if err != nil {
// TODO: better error handling
- return uiutil.ReportError(err)()
+ return util.ReportError(err)()
}
filesByPath := make(map[string][]history.File)
@@ -117,7 +117,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
sidebarLogo := m.sidebarLogo
if height < logoHeightBreakpoint {
- sidebarLogo = logo.SmallRender(width)
+ sidebarLogo = logo.SmallRender(m.com.Styles, width)
}
blocks := []string{
sidebarLogo,
@@ -7,7 +7,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/ui/common"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
)
@@ -21,7 +21,7 @@ type Status struct {
hideHelp bool
help help.Model
helpKm help.KeyMap
- msg uiutil.InfoMsg
+ msg util.InfoMsg
}
// NewStatus creates a new status bar and help model.
@@ -35,13 +35,13 @@ func NewStatus(com *common.Common, km help.KeyMap) *Status {
}
// SetInfoMsg sets the status info message.
-func (s *Status) SetInfoMsg(msg uiutil.InfoMsg) {
+func (s *Status) SetInfoMsg(msg util.InfoMsg) {
s.msg = msg
}
// ClearInfoMsg clears the status info message.
func (s *Status) ClearInfoMsg() {
- s.msg = uiutil.InfoMsg{}
+ s.msg = util.InfoMsg{}
}
// SetWidth sets the width of the status bar and help view.
@@ -79,19 +79,19 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
var indStyle lipgloss.Style
var msgStyle lipgloss.Style
switch s.msg.Type {
- case uiutil.InfoTypeError:
+ case util.InfoTypeError:
indStyle = s.com.Styles.Status.ErrorIndicator
msgStyle = s.com.Styles.Status.ErrorMessage
- case uiutil.InfoTypeWarn:
+ case util.InfoTypeWarn:
indStyle = s.com.Styles.Status.WarnIndicator
msgStyle = s.com.Styles.Status.WarnMessage
- case uiutil.InfoTypeUpdate:
+ case util.InfoTypeUpdate:
indStyle = s.com.Styles.Status.UpdateIndicator
msgStyle = s.com.Styles.Status.UpdateMessage
- case uiutil.InfoTypeInfo:
+ case util.InfoTypeInfo:
indStyle = s.com.Styles.Status.InfoIndicator
msgStyle = s.com.Styles.Status.InfoMessage
- case uiutil.InfoTypeSuccess:
+ case util.InfoTypeSuccess:
indStyle = s.com.Styles.Status.SuccessIndicator
msgStyle = s.com.Styles.Status.SuccessMessage
}
@@ -109,6 +109,6 @@ func (s *Status) Draw(scr uv.Screen, area uv.Rectangle) {
// given TTL.
func clearInfoMsgCmd(ttl time.Duration) tea.Cmd {
return tea.Tick(ttl, func(time.Time) tea.Msg {
- return uiutil.ClearStatusMsg{}
+ return util.ClearStatusMsg{}
})
}
@@ -43,7 +43,7 @@ import (
"github.com/charmbracelet/crush/internal/ui/dialog"
"github.com/charmbracelet/crush/internal/ui/logo"
"github.com/charmbracelet/crush/internal/ui/styles"
- "github.com/charmbracelet/crush/internal/uiutil"
+ "github.com/charmbracelet/crush/internal/ui/util"
"github.com/charmbracelet/crush/internal/version"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/ultraviolet/screen"
@@ -391,7 +391,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.sessionFiles = msg.files
msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
if err != nil {
- cmds = append(cmds, uiutil.ReportError(err))
+ cmds = append(cmds, util.ReportError(err))
break
}
if cmd := m.setSessionMessages(msgs); cmd != nil {
@@ -697,14 +697,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd != nil {
cmds = append(cmds, cmd)
}
- case uiutil.InfoMsg:
+ case util.InfoMsg:
m.status.SetInfoMsg(msg)
ttl := msg.TTL
if ttl <= 0 {
ttl = DefaultStatusTTL
}
cmds = append(cmds, clearInfoMsgCmd(ttl))
- case uiutil.ClearStatusMsg:
+ case util.ClearStatusMsg:
m.status.ClearInfoMsg()
case completions.FilesLoadedMsg:
// Handle async file loading for completions.
@@ -1154,7 +1154,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionNewSession:
if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+ cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
break
}
if cmd := m.newSession(); cmd != nil {
@@ -1163,13 +1163,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionSummarize:
if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+ cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
break
}
cmds = append(cmds, func() tea.Msg {
err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
if err != nil {
- return uiutil.ReportError(err)()
+ return util.ReportError(err)()
}
return nil
})
@@ -1179,7 +1179,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionExternalEditor:
if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
+ cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
break
}
cmds = append(cmds, m.openEditor(m.textarea.Value()))
@@ -1191,32 +1191,32 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
cmds = append(cmds, func() tea.Msg {
cfg := m.com.Config()
if cfg == nil {
- return uiutil.ReportError(errors.New("configuration not found"))()
+ return util.ReportError(errors.New("configuration not found"))()
}
agentCfg, ok := cfg.Agents[config.AgentCoder]
if !ok {
- return uiutil.ReportError(errors.New("agent configuration not found"))()
+ return util.ReportError(errors.New("agent configuration not found"))()
}
currentModel := cfg.Models[agentCfg.Model]
currentModel.Think = !currentModel.Think
if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
- return uiutil.ReportError(err)()
+ return util.ReportError(err)()
}
m.com.App.UpdateAgentModel(context.TODO())
status := "disabled"
if currentModel.Think {
status = "enabled"
}
- return uiutil.NewInfoMsg("Thinking mode " + status)
+ return util.NewInfoMsg("Thinking mode " + status)
})
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionQuit:
cmds = append(cmds, tea.Quit)
case dialog.ActionInitializeProject:
if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+ cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
break
}
cmds = append(cmds, m.initializeProject())
@@ -1224,13 +1224,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
case dialog.ActionSelectModel:
if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+ cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
break
}
cfg := m.com.Config()
if cfg == nil {
- cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+ cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
break
}
@@ -1254,23 +1254,23 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
}
if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
- cmds = append(cmds, uiutil.ReportError(err))
+ cmds = append(cmds, util.ReportError(err))
} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
// Ensure small model is set is unset.
smallModel := m.com.App.GetDefaultSmallModel(providerID)
if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil {
- cmds = append(cmds, uiutil.ReportError(err))
+ cmds = append(cmds, util.ReportError(err))
}
}
cmds = append(cmds, func() tea.Msg {
if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
- return uiutil.NewInfoMsg(modelMsg)
+ return util.NewInfoMsg(modelMsg)
})
m.dialog.CloseDialog(dialog.APIKeyInputID)
@@ -1281,37 +1281,37 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
m.setState(uiLanding, uiFocusEditor)
m.com.Config().SetupAgents()
if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
- cmds = append(cmds, uiutil.ReportError(err))
+ cmds = append(cmds, util.ReportError(err))
}
}
case dialog.ActionSelectReasoningEffort:
if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+ cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
break
}
cfg := m.com.Config()
if cfg == nil {
- cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
+ cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
break
}
agentCfg, ok := cfg.Agents[config.AgentCoder]
if !ok {
- cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
+ cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
break
}
currentModel := cfg.Models[agentCfg.Model]
currentModel.ReasoningEffort = msg.Effort
if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
- cmds = append(cmds, uiutil.ReportError(err))
+ cmds = append(cmds, util.ReportError(err))
break
}
cmds = append(cmds, func() tea.Msg {
m.com.App.UpdateAgentModel(context.TODO())
- return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
+ return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
})
m.dialog.CloseDialog(dialog.ReasoningID)
case dialog.ActionPermissionResponse:
@@ -1372,7 +1372,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
}
cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
default:
- cmds = append(cmds, uiutil.CmdHandler(msg))
+ cmds = append(cmds, util.CmdHandler(msg))
}
return tea.Batch(cmds...)
@@ -1464,7 +1464,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
case key.Matches(msg, m.keyMap.Suspend):
if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
+ cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
return true
}
cmds = append(cmds, tea.Suspend)
@@ -1566,7 +1566,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
break
}
if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+ cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
break
}
if cmd := m.newSession(); cmd != nil {
@@ -1581,7 +1581,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
}
case key.Matches(msg, m.keyMap.Editor.OpenEditor):
if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
+ cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
break
}
cmds = append(cmds, m.openEditor(m.textarea.Value()))
@@ -1681,7 +1681,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
break
}
if m.isAgentBusy() {
- cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+ cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
break
}
m.focus = uiFocusEditor
@@ -2152,7 +2152,7 @@ func (m *UI) toggleCompactMode() tea.Cmd {
err := m.com.Config().SetCompactMode(m.forceCompactMode)
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
m.updateLayoutAndSize()
@@ -2382,11 +2382,11 @@ type layout struct {
func (m *UI) openEditor(value string) tea.Cmd {
tmpfile, err := os.CreateTemp("", "msg_*.md")
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
defer tmpfile.Close() //nolint:errcheck
if _, err := tmpfile.WriteString(value); err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
cmd, err := editor.Command(
"crush",
@@ -2397,18 +2397,18 @@ func (m *UI) openEditor(value string) tea.Cmd {
),
)
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
return tea.ExecProcess(cmd, func(err error) tea.Msg {
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
if len(content) == 0 {
- return uiutil.ReportWarn("Message is empty")
+ return util.ReportWarn("Message is empty")
}
os.Remove(tmpfile.Name())
return openEditorMsg{
@@ -2607,14 +2607,14 @@ func (m *UI) cacheSidebarLogo(width int) {
// sendMessage sends a message with the given content and attachments.
func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
if m.com.App.AgentCoordinator == nil {
- return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
+ return util.ReportError(fmt.Errorf("coder agent is not initialized"))
}
var cmds []tea.Cmd
if !m.hasSession() {
newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
if m.forceCompactMode {
m.isCompact = true
@@ -2640,8 +2640,8 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
if isCancelErr || isPermissionErr {
return nil
}
- return uiutil.InfoMsg{
- Type: uiutil.InfoTypeError,
+ return util.InfoMsg{
+ Type: util.InfoTypeError,
Msg: err.Error(),
}
}
@@ -2748,7 +2748,7 @@ func (m *UI) openModelsDialog() tea.Cmd {
isOnboarding := m.state == uiOnboarding
modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
m.dialog.OpenDialog(modelsDialog)
@@ -2771,7 +2771,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
m.dialog.OpenDialog(commands)
@@ -2788,7 +2788,7 @@ func (m *UI) openReasoningDialog() tea.Cmd {
reasoningDialog, err := dialog.NewReasoning(m.com)
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
m.dialog.OpenDialog(reasoningDialog)
@@ -2812,7 +2812,7 @@ func (m *UI) openSessionsDialog() tea.Cmd {
dialog, err := dialog.NewSessions(m.com, selectedSessionID)
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
m.dialog.OpenDialog(dialog)
@@ -2902,7 +2902,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
return func() tea.Msg {
content := []byte(msg.Content)
if int64(len(content)) > common.MaxAttachmentSize {
- return uiutil.ReportWarn("Paste is too big (>5mb)")
+ return util.ReportWarn("Paste is too big (>5mb)")
}
name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
mimeBufferSize := min(512, len(content))
@@ -2961,18 +2961,18 @@ func (m *UI) handleFilePathPaste(path string) tea.Cmd {
return func() tea.Msg {
fileInfo, err := os.Stat(path)
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
if fileInfo.IsDir() {
- return uiutil.ReportWarn("Cannot attach a directory")
+ return util.ReportWarn("Cannot attach a directory")
}
if fileInfo.Size() > common.MaxAttachmentSize {
- return uiutil.ReportWarn("File is too big (>5mb)")
+ return util.ReportWarn("File is too big (>5mb)")
}
content, err := os.ReadFile(path)
if err != nil {
- return uiutil.ReportError(err)
+ return util.ReportError(err)
}
mimeBufferSize := min(512, len(content))
@@ -3059,7 +3059,7 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
if err != nil {
// TODO: make this better
- return uiutil.ReportError(err)()
+ return util.ReportError(err)()
}
if prompt == "" {
@@ -3095,7 +3095,7 @@ func (m *UI) copyChatHighlight() tea.Cmd {
// renderLogo renders the Crush logo with the given styles and dimensions.
func renderLogo(t *styles.Styles, compact bool, width int) string {
- return logo.Render(version.Version, compact, logo.Opts{
+ return logo.Render(t, version.Version, compact, logo.Opts{
FieldColor: t.LogoFieldColor,
TitleColorA: t.LogoTitleColorA,
TitleColorB: t.LogoTitleColorB,
@@ -12,7 +12,7 @@ import (
"charm.land/glamour/v2/ansi"
"charm.land/lipgloss/v2"
"github.com/alecthomas/chroma/v2"
- "github.com/charmbracelet/crush/internal/tui/exp/diffview"
+ "github.com/charmbracelet/crush/internal/ui/diffview"
"github.com/charmbracelet/x/exp/charmtone"
)
@@ -1,7 +1,5 @@
-// Package uiutil provides utility functions for UI message handling.
-// TODO: Move to internal/ui/<appropriate_location> once the new UI migration
-// is finalized.
-package uiutil
+// Package util provides utility functions for UI message handling.
+package util
import (
"context"
@@ -1,314 +0,0 @@
-// Package uicmd provides functionality to load and handle custom commands
-// from markdown files and MCP prompts.
-// TODO: Move this into internal/ui after refactoring.
-// TODO: DELETE when we delete the old tui
-package uicmd
-
-import (
- "cmp"
- "context"
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "regexp"
- "strings"
-
- tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/agent/tools/mcp"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/home"
- "github.com/charmbracelet/crush/internal/tui/components/chat"
- "github.com/charmbracelet/crush/internal/tui/util"
-)
-
-type CommandType uint
-
-func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
-
-const (
- SystemCommands CommandType = iota
- UserCommands
- MCPPrompts
-)
-
-// Command represents a command that can be executed
-type Command struct {
- ID string
- Title string
- Description string
- Shortcut string // Optional shortcut for the command
- Handler func(cmd Command) tea.Cmd
-}
-
-// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
-type ShowArgumentsDialogMsg struct {
- CommandID string
- Description string
- ArgNames []string
- OnSubmit func(args map[string]string) tea.Cmd
-}
-
-// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
-type CloseArgumentsDialogMsg struct {
- Submit bool
- CommandID string
- Content string
- Args map[string]string
-}
-
-const (
- userCommandPrefix = "user:"
- projectCommandPrefix = "project:"
-)
-
-var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
-
-type commandLoader struct {
- sources []commandSource
-}
-
-type commandSource struct {
- path string
- prefix string
-}
-
-func LoadCustomCommands() ([]Command, error) {
- return LoadCustomCommandsFromConfig(config.Get())
-}
-
-func LoadCustomCommandsFromConfig(cfg *config.Config) ([]Command, error) {
- if cfg == nil {
- return nil, fmt.Errorf("config not loaded")
- }
-
- loader := &commandLoader{
- sources: buildCommandSources(cfg),
- }
-
- return loader.loadAll()
-}
-
-func buildCommandSources(cfg *config.Config) []commandSource {
- var sources []commandSource
-
- // XDG config directory
- if dir := getXDGCommandsDir(); dir != "" {
- sources = append(sources, commandSource{
- path: dir,
- prefix: userCommandPrefix,
- })
- }
-
- // Home directory
- if home := home.Dir(); home != "" {
- sources = append(sources, commandSource{
- path: filepath.Join(home, ".crush", "commands"),
- prefix: userCommandPrefix,
- })
- }
-
- // Project directory
- sources = append(sources, commandSource{
- path: filepath.Join(cfg.Options.DataDirectory, "commands"),
- prefix: projectCommandPrefix,
- })
-
- return sources
-}
-
-func getXDGCommandsDir() string {
- xdgHome := os.Getenv("XDG_CONFIG_HOME")
- if xdgHome == "" {
- if home := home.Dir(); home != "" {
- xdgHome = filepath.Join(home, ".config")
- }
- }
- if xdgHome != "" {
- return filepath.Join(xdgHome, "crush", "commands")
- }
- return ""
-}
-
-func (l *commandLoader) loadAll() ([]Command, error) {
- var commands []Command
-
- for _, source := range l.sources {
- if cmds, err := l.loadFromSource(source); err == nil {
- commands = append(commands, cmds...)
- }
- }
-
- return commands, nil
-}
-
-func (l *commandLoader) loadFromSource(source commandSource) ([]Command, error) {
- if err := ensureDir(source.path); err != nil {
- return nil, err
- }
-
- var commands []Command
-
- err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
- if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
- return err
- }
-
- cmd, err := l.loadCommand(path, source.path, source.prefix)
- if err != nil {
- return nil // Skip invalid files
- }
-
- commands = append(commands, cmd)
- return nil
- })
-
- return commands, err
-}
-
-func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, error) {
- content, err := os.ReadFile(path)
- if err != nil {
- return Command{}, err
- }
-
- id := buildCommandID(path, baseDir, prefix)
- desc := fmt.Sprintf("Custom command from %s", filepath.Base(path))
-
- return Command{
- ID: id,
- Title: id,
- Description: desc,
- Handler: createCommandHandler(id, desc, string(content)),
- }, nil
-}
-
-func buildCommandID(path, baseDir, prefix string) string {
- relPath, _ := filepath.Rel(baseDir, path)
- parts := strings.Split(relPath, string(filepath.Separator))
-
- // Remove .md extension from last part
- if len(parts) > 0 {
- lastIdx := len(parts) - 1
- parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
- }
-
- return prefix + strings.Join(parts, ":")
-}
-
-func createCommandHandler(id, desc, content string) func(Command) tea.Cmd {
- return func(cmd Command) tea.Cmd {
- args := extractArgNames(content)
-
- if len(args) == 0 {
- return util.CmdHandler(CommandRunCustomMsg{
- Content: content,
- })
- }
- return util.CmdHandler(ShowArgumentsDialogMsg{
- CommandID: id,
- Description: desc,
- ArgNames: args,
- OnSubmit: func(args map[string]string) tea.Cmd {
- return execUserPrompt(content, args)
- },
- })
- }
-}
-
-func execUserPrompt(content string, args map[string]string) tea.Cmd {
- return func() tea.Msg {
- for name, value := range args {
- placeholder := "$" + name
- content = strings.ReplaceAll(content, placeholder, value)
- }
- return CommandRunCustomMsg{
- Content: content,
- }
- }
-}
-
-func extractArgNames(content string) []string {
- matches := namedArgPattern.FindAllStringSubmatch(content, -1)
- if len(matches) == 0 {
- return nil
- }
-
- seen := make(map[string]bool)
- var args []string
-
- for _, match := range matches {
- arg := match[1]
- if !seen[arg] {
- seen[arg] = true
- args = append(args, arg)
- }
- }
-
- return args
-}
-
-func ensureDir(path string) error {
- if _, err := os.Stat(path); os.IsNotExist(err) {
- return os.MkdirAll(path, 0o755)
- }
- return nil
-}
-
-func isMarkdownFile(name string) bool {
- return strings.HasSuffix(strings.ToLower(name), ".md")
-}
-
-type CommandRunCustomMsg struct {
- Content string
-}
-
-func LoadMCPPrompts() []Command {
- var commands []Command
- for mcpName, prompts := range mcp.Prompts() {
- for _, prompt := range prompts {
- key := mcpName + ":" + prompt.Name
- commands = append(commands, Command{
- ID: key,
- Title: cmp.Or(prompt.Title, prompt.Name),
- Description: prompt.Description,
- Handler: createMCPPromptHandler(mcpName, prompt.Name, prompt),
- })
- }
- }
-
- return commands
-}
-
-func createMCPPromptHandler(mcpName, promptName string, prompt *mcp.Prompt) func(Command) tea.Cmd {
- return func(cmd Command) tea.Cmd {
- if len(prompt.Arguments) == 0 {
- return execMCPPrompt(mcpName, promptName, nil)
- }
- return util.CmdHandler(ShowMCPPromptArgumentsDialogMsg{
- Prompt: prompt,
- OnSubmit: func(args map[string]string) tea.Cmd {
- return execMCPPrompt(mcpName, promptName, args)
- },
- })
- }
-}
-
-func execMCPPrompt(clientName, promptName string, args map[string]string) tea.Cmd {
- return func() tea.Msg {
- ctx := context.Background()
- result, err := mcp.GetPromptMessages(ctx, clientName, promptName, args)
- if err != nil {
- return util.ReportError(err)
- }
-
- return chat.SendMsg{
- Text: strings.Join(result, " "),
- }
- }
-}
-
-type ShowMCPPromptArgumentsDialogMsg struct {
- Prompt *mcp.Prompt
- OnSubmit func(arg map[string]string) tea.Cmd
-}