on main help sign in
feat(bang): show pending spinner
commit6db54b3a969389d97a414fb20f3d527a237d7565 authorKieran Klukas <kieran@dunkirk.sh> dateFri 19 Jun 2026, 10:33 (4 months ago) parent03bfdc6
3 files changed+151−21
internal/ui/anim/anim.go+38−11 internal/ui/chat/shell.go+60−1 internal/ui/model/ui.go+53−9
internal/ui/anim/anim.go+38−11
@@ -100,6 +100,12 @@ type Settings struct {
100100 GradColorA color.Color
101101 GradColorB color.Color
102102 CycleColors bool
103+
104+ // NoScramble disables the scrambled rune animation. The cycling
105+ // character region is removed entirely so only the label and its
106+ // animated ellipsis are visible. Useful for non-LLM contexts where
107+ // scrambled glyphs imply "thinking" rather than "running".
108+ NoScramble bool
103109}
104110
105111// Default settings.
@@ -145,9 +151,19 @@ func New(opts Settings) *Anim {
145151 } else {
146152 a.id = fmt.Sprintf("%d", nextID())
147153 }
148− a.cyclingCharWidth = opts.Size
154+ if opts.NoScramble {
155+ a.cyclingCharWidth = 0
156+ } else {
157+ a.cyclingCharWidth = opts.Size
158+ }
149159 a.labelColor = opts.LabelColor
150160
161+ // NoScramble means no cycling chars and no birth animation. Mark as
162+ // initialized immediately so the label renders without a fade-in.
163+ if opts.NoScramble {
164+ a.initialized.Store(true)
165+ }
166+
151167 // Check cache first
152168 cacheKey := settingsHash(opts)
153169 cached, exists := animCacheMap.Get(cacheKey)
@@ -164,10 +180,14 @@ func New(opts Settings) *Anim {
164180 // Generate new values and cache them
165181 a.labelWidth = lipgloss.Width(opts.Label)
166182
167− // Total width of anim, in cells.
168− a.width = opts.Size
183+ // Total width of anim, in cells. When NoScramble is set there
184+ // are no cycling chars so the label gap is unnecessary.
185+ a.width = a.cyclingCharWidth
169186 if opts.Label != "" {
170− a.width += labelGapWidth + lipgloss.Width(opts.Label)
187+ if a.cyclingCharWidth > 0 {
188+ a.width += labelGapWidth
189+ }
190+ a.width += lipgloss.Width(opts.Label)
171191 }
172192
173193 // Render the label
@@ -282,10 +302,13 @@ func New(opts Settings) *Anim {
282302func (a *Anim) SetLabel(newLabel string) {
283303 a.labelWidth = lipgloss.Width(newLabel)
284304
285− // Update total width
305+ // Update total width. Skip the label gap when there are no cycling chars.
286306 a.width = a.cyclingCharWidth
287307 if newLabel != "" {
288− a.width += labelGapWidth + a.labelWidth
308+ if a.cyclingCharWidth > 0 {
309+ a.width += labelGapWidth
310+ }
311+ a.width += a.labelWidth
289312 }
290313
291314 // Re-render the label
@@ -379,12 +402,16 @@ func (a *Anim) Render() string {
379402 case i < a.cyclingCharWidth:
380403 // Render a cycling character.
381404 b.WriteString(a.cyclingFrames[step][i])
382− case i == a.cyclingCharWidth:
383− // Render label gap.
405+ case i == a.cyclingCharWidth && a.cyclingCharWidth > 0:
406+ // Render label gap (only when there are cycling chars).
384407 b.WriteString(labelGap)
385− case i > a.cyclingCharWidth:
386− // Label.
387− if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
408+ default:
409+ // Label. Offset past cycling chars and gap (if any).
410+ offset := a.cyclingCharWidth
411+ if a.cyclingCharWidth > 0 {
412+ offset += labelGapWidth
413+ }
414+ if labelChar, ok := a.label.Get(i - offset); ok {
388415 b.WriteString(labelChar)
389416 }
390417 }
internal/ui/chat/shell.go+60−1
@@ -8,6 +8,7 @@ import (
88 tea "charm.land/bubbletea/v2"
99 "charm.land/lipgloss/v2"
1010
11+ "github.com/charmbracelet/crush/internal/ui/anim"
1112 "github.com/charmbracelet/crush/internal/ui/common"
1213 "github.com/charmbracelet/crush/internal/ui/list"
1314 "github.com/charmbracelet/crush/internal/ui/styles"
@@ -39,12 +40,15 @@ type ShellItem struct {
3940 xOffset int
4041 maxLineWidth int // computed during render, used to clamp xOffset
4142 sty *styles.Styles
43+ pending bool
44+ anim *anim.Anim
4245}
4346
4447var (
4548 _ Expandable = (*ShellItem)(nil)
4649 _ list.Highlightable = (*ShellItem)(nil)
4750 _ KeyEventHandler = (*ShellItem)(nil)
51+ _ Animatable = (*ShellItem)(nil)
4852)
4953
5054// NewShellItem creates a new ShellItem for displaying bang-mode results.
@@ -63,9 +67,60 @@ func NewShellItem(sty *styles.Styles, command, output string, exitCode int) Mess
6367 }
6468}
6569
70+// NewPendingShellItem creates a ShellItem in a pending/running state that
71+// displays a spinner until Complete is called with the results.
72+func NewPendingShellItem(sty *styles.Styles, command string) *ShellItem {
73+ v := list.NewVersioned()
74+ id := fmt.Sprintf("shell-%d-%s", shellSeq.Add(1), command)
75+ s := &ShellItem{
76+ Versioned: v,
77+ highlightableMessageItem: defaultHighlighter(sty, v),
78+ cachedMessageItem: &cachedMessageItem{},
79+ focusableMessageItem: newFocusableMessageItem(v),
80+ id: id,
81+ command: command,
82+ sty: sty,
83+ pending: true,
84+ }
85+ s.anim = anim.New(anim.Settings{
86+ ID: id,
87+ Label: "Running",
88+ LabelColor: sty.WorkingLabelColor,
89+ GradColorA: sty.WorkingGradFromColor,
90+ GradColorB: sty.WorkingGradToColor,
91+ NoScramble: true,
92+ })
93+ return s
94+}
95+
96+// Complete transitions a pending ShellItem to a finished state with output.
97+func (s *ShellItem) Complete(output string, exitCode int) {
98+ s.output = output
99+ s.exitCode = exitCode
100+ s.pending = false
101+ s.Bump()
102+}
103+
66104func (s *ShellItem) ID() string { return s.id }
67105func (s *ShellItem) FilterValue() string { return s.command }
68−func (s *ShellItem) Finished() bool { return true }
106+func (s *ShellItem) Finished() bool { return !s.pending }
107+
108+// StartAnimation starts the spinner animation for pending shell items.
109+func (s *ShellItem) StartAnimation() tea.Cmd {
110+ if !s.pending {
111+ return nil
112+ }
113+ return s.anim.Start()
114+}
115+
116+// Animate advances the spinner animation for pending shell items.
117+func (s *ShellItem) Animate(msg anim.StepMsg) tea.Cmd {
118+ if !s.pending {
119+ return nil
120+ }
121+ s.Bump()
122+ return s.anim.Animate(msg)
123+}
69124
70125func (s *ShellItem) Render(width int) string {
71126 innerWidth := max(0, width-MessageLeftPaddingTotal)
@@ -143,6 +198,10 @@ func (s *ShellItem) RawRender(width int) string {
143198 }
144199 header := prompt + " " + highlighted
145200
201+ if s.pending {
202+ return header + "\n" + s.anim.Render()
203+ }
204+
146205 if s.exitCode != 0 {
147206 header += " " + s.sty.Messages.ShellExitCode.Render(fmt.Sprintf("(exit %d)", s.exitCode))
148207 }
internal/ui/model/ui.go+53−9
@@ -113,9 +113,10 @@ type openEditorMsg struct {
113113}
114114
115115type shellResultMsg struct {
116− Command string
117− Output string
118− ExitCode int
116+ PendingID string // ID of the pending ShellItem to update.
117+ Command string
118+ Output string
119+ ExitCode int
119120}
120121
121122type (
@@ -200,8 +201,9 @@ type UI struct {
200201 isCanceling bool
201202
202203 // bangMode tracks whether the editor is in bang (!) shell mode.
203− bangMode bool
204− bangWasEmpty bool // true when bang prompt became empty on last keystroke
204+ bangMode bool
205+ bangWasEmpty bool // true when bang prompt became empty on last keystroke
206+ pendingShellItem *chat.ShellItem
205207
206208 header *header
207209
@@ -607,6 +609,17 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
607609 if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
608610 cmds = append(cmds, cmd)
609611 }
612+ // Re-append pending shell item if session load wiped it.
613+ if m.pendingShellItem != nil {
614+ m.chat.AppendMessages(m.pendingShellItem)
615+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
616+ cmds = append(cmds, cmd)
617+ }
618+ if cmd := m.pendingShellItem.StartAnimation(); cmd != nil {
619+ cmds = append(cmds, cmd)
620+ }
621+ m.pendingShellItem = nil
622+ }
610623 if hasInProgressTodo(m.session.Todos) {
611624 // only start spinner if there is an in-progress todo
612625 if m.isAgentBusy() {
@@ -941,6 +954,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
941954 m.textarea.MoveToEnd()
942955 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
943956 case shellResultMsg:
957+ // Complete the pending shell item if it exists, otherwise create a new one.
958+ if msg.PendingID != "" {
959+ if item := m.chat.MessageItem(msg.PendingID); item != nil {
960+ if shellItem, ok := item.(*chat.ShellItem); ok {
961+ shellItem.Complete(msg.Output, msg.ExitCode)
962+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
963+ cmds = append(cmds, cmd)
964+ }
965+ break
966+ }
967+ }
968+ }
944969 item := chat.NewShellItem(m.com.Styles, msg.Command, msg.Output, msg.ExitCode)
945970 m.chat.AppendMessages(item)
946971 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
@@ -3387,7 +3412,8 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
33873412// the LLM. The result is displayed as a tool-style item in the chat.
33883413func (m *UI) runShellCommand(command string) tea.Cmd {
33893414 var cmds []tea.Cmd
3390− if !m.hasSession() {
3415+ creatingSession := !m.hasSession()
3416+ if creatingSession {
33913417 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
33923418 if err != nil {
33933419 return util.ReportError(err)
@@ -3404,6 +3430,23 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
34043430
34053431 sessionID := m.session.ID
34063432 contentWidth := min(m.layout.main.Dx()-2, 120)
3433+
3434+ // Append a pending shell item immediately so the user sees feedback.
3435+ pendingItem := chat.NewPendingShellItem(m.com.Styles, command)
3436+ if creatingSession {
3437+ // loadSession will wipe the chat list via SetMessages.
3438+ // Stash the item and re-append after messages load.
3439+ m.pendingShellItem = pendingItem
3440+ } else {
3441+ m.chat.AppendMessages(pendingItem)
3442+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
3443+ cmds = append(cmds, cmd)
3444+ }
3445+ if cmd := pendingItem.StartAnimation(); cmd != nil {
3446+ cmds = append(cmds, cmd)
3447+ }
3448+ }
3449+
34073450 cmds = append(cmds, func() tea.Msg {
34083451 resp, err := m.com.Workspace.AgentRunShellCommand(context.Background(), sessionID, command, contentWidth)
34093452 if err != nil {
@@ -3413,9 +3456,10 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
34133456 }
34143457 }
34153458 return shellResultMsg{
3416− Command: command,
3417− Output: resp.Output,
3418− ExitCode: resp.ExitCode,
3459+ PendingID: pendingItem.ID(),
3460+ Command: command,
3461+ Output: resp.Output,
3462+ ExitCode: resp.ExitCode,
34193463 }
34203464 })
34213465 return tea.Batch(cmds...)