Detailed changes
@@ -16,7 +16,7 @@ require (
github.com/aymanbagabas/go-udiff v0.3.1
github.com/bmatcuk/doublestar/v4 v4.8.1
github.com/charlievieth/fastwalk v1.0.11
- github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e
+ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1
github.com/charmbracelet/fang v0.1.0
github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
@@ -68,8 +68,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8=
github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e h1:99Ugtt633rqauFsXjZobZmtkNpeaWialfj8dl6COC6A=
-github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198 h1:CkMS9Ah9ac1Ego5JDC5NJyZyAAqu23Z+O0yDwsa3IxM=
+github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595 h1:wLMjzOqrwoM7Em9UR9sGbn4375G8WuxcwFB3kjZiqHo=
github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250710185017-3c0ffd25e595/go.mod h1:+Tl7rePElw6OKt382t04zXwtPFoPXxAaJzNrYmtsLds=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
@@ -98,7 +98,7 @@ func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat
// Start spinner if not in quiet mode
var spinner *format.Spinner
if !quiet {
- spinner = format.NewSpinner("Thinking...")
+ spinner = format.NewSpinner(ctx, "Generating")
spinner.Start()
defer spinner.Stop()
}
@@ -129,6 +129,12 @@ func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat
}
result := <-done
+
+ // Stop spinner before printing output
+ if !quiet && spinner != nil {
+ spinner.Stop()
+ }
+
if result.Error != nil {
if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
slog.Info("Agent processing cancelled", "session_id", sess.ID)
@@ -137,11 +143,6 @@ func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat
return fmt.Errorf("agent processing failed: %w", result.Error)
}
- // Stop spinner before printing output
- if !quiet && spinner != nil {
- spinner.Stop()
- }
-
// Get the text content from the response
content := "No content available"
if result.Message.Content().String() != "" {
@@ -2,101 +2,63 @@ package format
import (
"context"
+ "errors"
"fmt"
"os"
- "github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/tui/components/anim"
+ "github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/x/ansi"
)
// Spinner wraps the bubbles spinner for non-interactive mode
type Spinner struct {
- model spinner.Model
- done chan struct{}
- prog *tea.Program
- ctx context.Context
- cancel context.CancelFunc
+ done chan struct{}
+ prog *tea.Program
}
-// spinnerModel is the tea.Model for the spinner
-type spinnerModel struct {
- spinner spinner.Model
- message string
- quitting bool
-}
-
-func (m spinnerModel) Init() tea.Cmd {
- return m.spinner.Tick
-}
-
-func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- m.quitting = true
- return m, tea.Quit
- case spinner.TickMsg:
- var cmd tea.Cmd
- m.spinner, cmd = m.spinner.Update(msg)
- return m, cmd
- case quitMsg:
- m.quitting = true
- return m, tea.Quit
- default:
- return m, nil
- }
-}
-
-func (m spinnerModel) View() string {
- if m.quitting {
- return ""
- }
- return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
-}
-
-// quitMsg is sent when we want to quit the spinner
-type quitMsg struct{}
-
// NewSpinner creates a new spinner with the given message
-func NewSpinner(message string) *Spinner {
- s := spinner.New()
- s.Spinner = spinner.Dot
- s.Style = s.Style.Foreground(s.Style.GetForeground())
-
- ctx, cancel := context.WithCancel(context.Background())
-
- model := spinnerModel{
- spinner: s,
- message: message,
- }
+func NewSpinner(ctx context.Context, message string) *Spinner {
+ t := styles.CurrentTheme()
+ model := anim.New(anim.Settings{
+ Size: 10,
+ Label: message,
+ LabelColor: t.FgBase,
+ GradColorA: t.Primary,
+ GradColorB: t.Secondary,
+ CycleColors: true,
+ })
- prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
+ prog := tea.NewProgram(
+ model,
+ tea.WithInput(nil),
+ tea.WithOutput(os.Stderr),
+ tea.WithContext(ctx),
+ tea.WithoutCatchPanics(),
+ )
return &Spinner{
- model: s,
- done: make(chan struct{}),
- prog: prog,
- ctx: ctx,
- cancel: cancel,
+ prog: prog,
+ done: make(chan struct{}, 1),
}
}
// Start begins the spinner animation
func (s *Spinner) Start() {
go func() {
- defer close(s.done)
- go func() {
- <-s.ctx.Done()
- s.prog.Send(quitMsg{})
- }()
_, err := s.prog.Run()
- if err != nil {
+ // ensures line is cleared
+ fmt.Fprint(os.Stderr, ansi.EraseEntireLine)
+ if err != nil && !errors.Is(err, context.Canceled) {
fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
}
+ close(s.done)
}()
}
// Stop ends the spinner animation
func (s *Spinner) Stop() {
- s.cancel()
+ s.prog.Quit()
<-s.done
}
@@ -80,6 +80,10 @@ const (
maxAttachments = 5
)
+type openEditorMsg struct {
+ Text string
+}
+
func (m *editorCmp) openEditor(value string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
@@ -115,11 +119,8 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
return util.ReportWarn("Message is empty")
}
os.Remove(tmpfile.Name())
- attachments := m.attachments
- m.attachments = nil
- return chat.SendMsg{
- Text: string(content),
- Attachments: attachments,
+ return openEditorMsg{
+ Text: strings.TrimSpace(string(content)),
}
})
}
@@ -189,6 +190,9 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.completionsStartIndex = 0
return m, nil
}
+ case openEditorMsg:
+ m.textarea.SetValue(msg.Text)
+ m.textarea.MoveToEnd()
case tea.KeyPressMsg:
switch {
// Completions
@@ -402,11 +406,11 @@ func New(app *app.App) Editor {
t := styles.CurrentTheme()
ta := textarea.New()
ta.SetStyles(t.S().TextArea)
- ta.SetPromptFunc(4, func(lineIndex int, focused bool) string {
- if lineIndex == 0 {
+ ta.SetPromptFunc(4, func(info textarea.PromptInfo) string {
+ if info.LineNumber == 0 {
return " > "
}
- if focused {
+ if info.Focused {
return t.S().Base.Foreground(t.GreenDark).Render("::: ")
} else {
return t.S().Muted.Render("::: ")
@@ -133,6 +133,13 @@ type LineInfo struct {
CharOffset int
}
+// PromptInfo is a struct that can be used to store information about the
+// prompt.
+type PromptInfo struct {
+ LineNumber int
+ Focused bool
+}
+
// CursorStyle is the style for real and virtual cursors.
type CursorStyle struct {
// Style styles the cursor block.
@@ -287,7 +294,7 @@ type Model struct {
// If promptFunc is set, it replaces Prompt as a generator for
// prompt strings at the beginning of each line.
- promptFunc func(line int, focused bool) string
+ promptFunc func(PromptInfo) string
// promptWidth is the width of the prompt.
promptWidth int
@@ -983,14 +990,14 @@ func (m Model) Width() int {
return m.width
}
-// moveToBegin moves the cursor to the beginning of the input.
-func (m *Model) moveToBegin() {
+// MoveToBegin moves the cursor to the beginning of the input.
+func (m *Model) MoveToBegin() {
m.row = 0
m.SetCursorColumn(0)
}
-// moveToEnd moves the cursor to the end of the input.
-func (m *Model) moveToEnd() {
+// MoveToEnd moves the cursor to the end of the input.
+func (m *Model) MoveToEnd() {
m.row = len(m.value) - 1
m.SetCursorColumn(len(m.value[m.row]))
}
@@ -1052,7 +1059,7 @@ func (m *Model) SetWidth(w int) {
// promptWidth, it will be padded to the left. If it returns a prompt that is
// longer, display artifacts may occur; the caller is responsible for computing
// an adequate promptWidth.
-func (m *Model) SetPromptFunc(promptWidth int, fn func(lineIndex int, focused bool) string) {
+func (m *Model) SetPromptFunc(promptWidth int, fn func(PromptInfo) string) {
m.promptFunc = fn
m.promptWidth = promptWidth
}
@@ -1170,9 +1177,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case key.Matches(msg, m.KeyMap.WordBackward):
m.wordLeft()
case key.Matches(msg, m.KeyMap.InputBegin):
- m.moveToBegin()
+ m.MoveToBegin()
case key.Matches(msg, m.KeyMap.InputEnd):
- m.moveToEnd()
+ m.MoveToEnd()
case key.Matches(msg, m.KeyMap.LowercaseWordForward):
m.lowercaseRight()
case key.Matches(msg, m.KeyMap.UppercaseWordForward):
@@ -1320,7 +1327,10 @@ func (m Model) promptView(displayLine int) (prompt string) {
if m.promptFunc == nil {
return prompt
}
- prompt = m.promptFunc(displayLine, m.focus)
+ prompt = m.promptFunc(PromptInfo{
+ LineNumber: displayLine,
+ Focused: m.focus,
+ })
width := lipgloss.Width(prompt)
if width < m.promptWidth {
prompt = fmt.Sprintf("%*s%s", m.promptWidth-width, "", prompt)
@@ -242,7 +242,7 @@ github.com/bmatcuk/doublestar/v4
github.com/charlievieth/fastwalk
github.com/charlievieth/fastwalk/internal/dirent
github.com/charlievieth/fastwalk/internal/fmtdirent
-# github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e
+# github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250710161907-a4c42b579198
## explicit; go 1.23.0
github.com/charmbracelet/bubbles/v2/cursor
github.com/charmbracelet/bubbles/v2/filepicker