From 8e38daa0e45646bcd93be5404f58155c40293cc3 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 8 Jul 2025 17:04:26 -0400 Subject: [PATCH 1/5] feat(tui): set the textarea value back after closing the editor After closing the editor, the textarea value becomes the text that was written in the editor. This allows users to edit the text they wrote in the editor before sending it. --- internal/tui/components/chat/editor/editor.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index ee374a6c1e0e68aaebef74fee07edb62292d68fe..272ef2f8f78020a16e88e5eac73756ea8473fe4b 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -67,6 +67,10 @@ const ( maxAttachments = 5 ) +type openEditorMsg struct { + Text string +} + func (m *editorCmp) openEditor(value string) tea.Cmd { editor := os.Getenv("EDITOR") if editor == "" { @@ -102,11 +106,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)), } }) } @@ -184,6 +185,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) + return m, nil case tea.KeyPressMsg: switch { // Completions From 3ec5d0178209658b9dcc8154419416eb5e50db80 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Jul 2025 11:46:51 -0400 Subject: [PATCH 2/5] fix(tui): editor: make sure we update the textarea after closing the editor --- internal/tui/components/chat/editor/editor.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 272ef2f8f78020a16e88e5eac73756ea8473fe4b..952dfda51f2423b891f0bacce8fea7f788249deb 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -187,7 +187,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case openEditorMsg: m.textarea.SetValue(msg.Text) - return m, nil case tea.KeyPressMsg: switch { // Completions From 6a0282e4fcd755ceac97122d0e3b54d9416c2880 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Jul 2025 12:30:57 -0400 Subject: [PATCH 3/5] chore: upgrade bubbles v2 to latest v2-exp commit --- go.mod | 2 +- go.sum | 2 ++ .../bubbles/v2/textarea/textarea.go | 28 +++++++++++++------ vendor/modules.txt | 2 +- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 7f4d7493c34e10a1d62bb311afc71b12e930ecd1..544db849da4d0fd6d28883e51f547985d01e33e0 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 d35233348b9c20b1d5556294a2f2bce4b2f9e2a9..a71b0161019e9e69acdd383c20f0db2c5750c2ff 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr 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-20250708152737-144080f3d891 h1:wh6N1dR4XkDh6XsiZh1/tImJAZvYB0yVLmaUKvJXvK0= github.com/charmbracelet/bubbletea-internal/v2 v2.0.0-20250708152737-144080f3d891/go.mod h1:SwBB+WoaQVMMOM9hknbN/7FNT86kgKG0LSHGTmLphX8= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 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 e256e639e4098adadaf25a32fa4a05937e2066d7..f82a351692a30aee627d3dd11175d1e414ddce9d 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 From 77e46cf415c70a084a3ccba9b0697d239e835a02 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Jul 2025 12:31:12 -0400 Subject: [PATCH 4/5] fix(tui): editor: make sure we move the cursor to the end of input --- internal/tui/components/chat/editor/editor.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 952dfda51f2423b891f0bacce8fea7f788249deb..a061cc44d355a2ff69d9cde4176861ca2c85a889 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -187,6 +187,7 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case openEditorMsg: m.textarea.SetValue(msg.Text) + m.textarea.MoveToEnd() case tea.KeyPressMsg: switch { // Completions @@ -389,11 +390,11 @@ func NewEditorCmp(app *app.App) util.Model { 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("::: ") From dd509684fceef6c0371668736bcc53f0bf62eefd Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 10 Jul 2025 16:05:11 -0300 Subject: [PATCH 5/5] fix: use the same spinner in non-interactive mode (#131) * fix: use the same spinner in non-interactive mode * fix: simplify * fix: move things around a bit --- internal/app/app.go | 13 ++--- internal/format/spinner.go | 98 ++++++++++++-------------------------- 2 files changed, 37 insertions(+), 74 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index da014df81367665caf1df793760e3d832c223648..aba2dd255d076566ec0b7412df654209d21350d5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -89,7 +89,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() } @@ -120,6 +120,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) @@ -128,11 +134,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 }