diff --git a/go.mod b/go.mod index 681f6fb76fd1cad2c0906c2c893a959e8551fc72..35907121af5791acc5cfc5f3aa07f10df9eba763 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index e2c6681c23b3d619447269b97866f20e302b2af8..50e30a46d4a47cb210add9c3fe61f0c9fb8e6c26 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go index a9a503641cd473e7b53c1318c63ba2391b510b9f..36f0777d0547474c6b4fa8053436f3cf7b274406 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() != "" { diff --git a/internal/format/spinner.go b/internal/format/spinner.go index 739fac1b27c99b9c4c4da030943500eda79f957c..9377bd3b4c145fc6866ac1e0f4e63dff8ab51619 100644 --- a/internal/format/spinner.go +++ b/internal/format/spinner.go @@ -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 } diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index e6f354b7562bac99ffb8d380eb72fb7780b28240..2185715c813dbdcb288bddde0fe70d63046cf731 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -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("::: ") diff --git a/vendor/github.com/charmbracelet/bubbles/v2/textarea/textarea.go b/vendor/github.com/charmbracelet/bubbles/v2/textarea/textarea.go index 673985e67c6f53e82324c64c0a640671805600d1..f68db029188ba330e82c40b8cc89ecb30a0b258f 100644 --- a/vendor/github.com/charmbracelet/bubbles/v2/textarea/textarea.go +++ b/vendor/github.com/charmbracelet/bubbles/v2/textarea/textarea.go @@ -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) diff --git a/vendor/modules.txt b/vendor/modules.txt index 6fffdb0186be991baa4d563f432ed60c92ca3125..33d95285eebb41a1038aa2d95233bbcc96a87151 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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