on main help sign in
feat(bang): show pending spinner
6db54b3Kieran Klukas
internal/ui/anim/anim.go+38−11
@@ -100,6 +100,12 @@ type Settings struct {
100 GradColorA color.Color
100 GradColorA color.Color
101 GradColorB color.Color
101 GradColorB color.Color
102 CycleColors bool
102 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
103}
109}
104
110
105// Default settings.
111// Default settings.
@@ -145,9 +151,19 @@ func New(opts Settings) *Anim {
145 } else {
151 } else {
146 a.id = fmt.Sprintf("%d", nextID())
152 a.id = fmt.Sprintf("%d", nextID())
147 }
153 }
154+ if opts.NoScramble {
155+ a.cyclingCharWidth = 0
156+ } else {
148− a.cyclingCharWidth = opts.Size
157+ a.cyclingCharWidth = opts.Size
158+ }
149 a.labelColor = opts.LabelColor
159 a.labelColor = opts.LabelColor
150
160
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+
151 // Check cache first
167 // Check cache first
152 cacheKey := settingsHash(opts)
168 cacheKey := settingsHash(opts)
153 cached, exists := animCacheMap.Get(cacheKey)
169 cached, exists := animCacheMap.Get(cacheKey)
@@ -164,10 +180,14 @@ func New(opts Settings) *Anim {
164 // Generate new values and cache them
180 // Generate new values and cache them
165 a.labelWidth = lipgloss.Width(opts.Label)
181 a.labelWidth = lipgloss.Width(opts.Label)
166
182
167− // Total width of anim, in cells.
183+ // Total width of anim, in cells. When NoScramble is set there
168− a.width = opts.Size
184+ // are no cycling chars so the label gap is unnecessary.
185+ a.width = a.cyclingCharWidth
169 if opts.Label != "" {
186 if opts.Label != "" {
187+ if a.cyclingCharWidth > 0 {
170− a.width += labelGapWidth + lipgloss.Width(opts.Label)
188+ a.width += labelGapWidth
189+ }
190+ a.width += lipgloss.Width(opts.Label)
171 }
191 }
172
192
173 // Render the label
193 // Render the label
@@ -282,10 +302,13 @@ func New(opts Settings) *Anim {
282func (a *Anim) SetLabel(newLabel string) {
302func (a *Anim) SetLabel(newLabel string) {
283 a.labelWidth = lipgloss.Width(newLabel)
303 a.labelWidth = lipgloss.Width(newLabel)
284
304
285− // Update total width
305+ // Update total width. Skip the label gap when there are no cycling chars.
286 a.width = a.cyclingCharWidth
306 a.width = a.cyclingCharWidth
287 if newLabel != "" {
307 if newLabel != "" {
308+ if a.cyclingCharWidth > 0 {
288− a.width += labelGapWidth + a.labelWidth
309+ a.width += labelGapWidth
310+ }
311+ a.width += a.labelWidth
289 }
312 }
290
313
291 // Re-render the label
314 // Re-render the label
@@ -379,12 +402,16 @@ func (a *Anim) Render() string {
379 case i < a.cyclingCharWidth:
402 case i < a.cyclingCharWidth:
380 // Render a cycling character.
403 // Render a cycling character.
381 b.WriteString(a.cyclingFrames[step][i])
404 b.WriteString(a.cyclingFrames[step][i])
382− case i == a.cyclingCharWidth:
405+ case i == a.cyclingCharWidth && a.cyclingCharWidth > 0:
383− // Render label gap.
406+ // Render label gap (only when there are cycling chars).
384 b.WriteString(labelGap)
407 b.WriteString(labelGap)
385− case i > a.cyclingCharWidth:
408+ default:
386− // Label.
409+ // Label. Offset past cycling chars and gap (if any).
410+ offset := a.cyclingCharWidth
411+ if a.cyclingCharWidth > 0 {
412+ offset += labelGapWidth
413+ }
387− if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
414+ if labelChar, ok := a.label.Get(i - offset); ok {
388 b.WriteString(labelChar)
415 b.WriteString(labelChar)
389 }
416 }
390 }
417 }
internal/ui/chat/shell.go+60−1
@@ -8,6 +8,7 @@ import (
8 tea "charm.land/bubbletea/v2"
8 tea "charm.land/bubbletea/v2"
9 "charm.land/lipgloss/v2"
9 "charm.land/lipgloss/v2"
10
10
11+ "github.com/charmbracelet/crush/internal/ui/anim"
11 "github.com/charmbracelet/crush/internal/ui/common"
12 "github.com/charmbracelet/crush/internal/ui/common"
12 "github.com/charmbracelet/crush/internal/ui/list"
13 "github.com/charmbracelet/crush/internal/ui/list"
13 "github.com/charmbracelet/crush/internal/ui/styles"
14 "github.com/charmbracelet/crush/internal/ui/styles"
@@ -39,12 +40,15 @@ type ShellItem struct {
39 xOffset int
40 xOffset int
40 maxLineWidth int // computed during render, used to clamp xOffset
41 maxLineWidth int // computed during render, used to clamp xOffset
41 sty *styles.Styles
42 sty *styles.Styles
43+ pending bool
44+ anim *anim.Anim
42}
45}
43
46
44var (
47var (
45 _ Expandable = (*ShellItem)(nil)
48 _ Expandable = (*ShellItem)(nil)
46 _ list.Highlightable = (*ShellItem)(nil)
49 _ list.Highlightable = (*ShellItem)(nil)
47 _ KeyEventHandler = (*ShellItem)(nil)
50 _ KeyEventHandler = (*ShellItem)(nil)
51+ _ Animatable = (*ShellItem)(nil)
48)
52)
49
53
50// NewShellItem creates a new ShellItem for displaying bang-mode results.
54// 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
63 }
67 }
64}
68}
65
69
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+
66func (s *ShellItem) ID() string { return s.id }
104func (s *ShellItem) ID() string { return s.id }
67func (s *ShellItem) FilterValue() string { return s.command }
105func (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+}
69
124
70func (s *ShellItem) Render(width int) string {
125func (s *ShellItem) Render(width int) string {
71 innerWidth := max(0, width-MessageLeftPaddingTotal)
126 innerWidth := max(0, width-MessageLeftPaddingTotal)
@@ -143,6 +198,10 @@ func (s *ShellItem) RawRender(width int) string {
143 }
198 }
144 header := prompt + " " + highlighted
199 header := prompt + " " + highlighted
145
200
201+ if s.pending {
202+ return header + "\n" + s.anim.Render()
203+ }
204+
146 if s.exitCode != 0 {
205 if s.exitCode != 0 {
147 header += " " + s.sty.Messages.ShellExitCode.Render(fmt.Sprintf("(exit %d)", s.exitCode))
206 header += " " + s.sty.Messages.ShellExitCode.Render(fmt.Sprintf("(exit %d)", s.exitCode))
148 }
207 }
internal/ui/model/ui.go+53−9
@@ -113,9 +113,10 @@ type openEditorMsg struct {
113}
113}
114
114
115type shellResultMsg struct {
115type shellResultMsg struct {
116+ PendingID string // ID of the pending ShellItem to update.
116− Command string
117+ Command string
117− Output string
118+ Output string
118− ExitCode int
119+ ExitCode int
119}
120}
120
121
121type (
122type (
@@ -200,8 +201,9 @@ type UI struct {
200 isCanceling bool
201 isCanceling bool
201
202
202 // bangMode tracks whether the editor is in bang (!) shell mode.
203 // bangMode tracks whether the editor is in bang (!) shell mode.
203− bangMode bool
204+ bangMode bool
204− bangWasEmpty bool // true when bang prompt became empty on last keystroke
205+ bangWasEmpty bool // true when bang prompt became empty on last keystroke
206+ pendingShellItem *chat.ShellItem
205
207
206 header *header
208 header *header
207
209
@@ -607,6 +609,17 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
607 if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
609 if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
608 cmds = append(cmds, cmd)
610 cmds = append(cmds, cmd)
609 }
611 }
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+ }
610 if hasInProgressTodo(m.session.Todos) {
623 if hasInProgressTodo(m.session.Todos) {
611 // only start spinner if there is an in-progress todo
624 // only start spinner if there is an in-progress todo
612 if m.isAgentBusy() {
625 if m.isAgentBusy() {
@@ -941,6 +954,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
941 m.textarea.MoveToEnd()
954 m.textarea.MoveToEnd()
942 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
955 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
943 case shellResultMsg:
956 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+ }
944 item := chat.NewShellItem(m.com.Styles, msg.Command, msg.Output, msg.ExitCode)
969 item := chat.NewShellItem(m.com.Styles, msg.Command, msg.Output, msg.ExitCode)
945 m.chat.AppendMessages(item)
970 m.chat.AppendMessages(item)
946 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
971 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
@@ -3387,7 +3412,8 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
3387// the LLM. The result is displayed as a tool-style item in the chat.
3412// the LLM. The result is displayed as a tool-style item in the chat.
3388func (m *UI) runShellCommand(command string) tea.Cmd {
3413func (m *UI) runShellCommand(command string) tea.Cmd {
3389 var cmds []tea.Cmd
3414 var cmds []tea.Cmd
3390− if !m.hasSession() {
3415+ creatingSession := !m.hasSession()
3416+ if creatingSession {
3391 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3417 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3392 if err != nil {
3418 if err != nil {
3393 return util.ReportError(err)
3419 return util.ReportError(err)
@@ -3404,6 +3430,23 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
3404
3430
3405 sessionID := m.session.ID
3431 sessionID := m.session.ID
3406 contentWidth := min(m.layout.main.Dx()-2, 120)
3432 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+
3407 cmds = append(cmds, func() tea.Msg {
3450 cmds = append(cmds, func() tea.Msg {
3408 resp, err := m.com.Workspace.AgentRunShellCommand(context.Background(), sessionID, command, contentWidth)
3451 resp, err := m.com.Workspace.AgentRunShellCommand(context.Background(), sessionID, command, contentWidth)
3409 if err != nil {
3452 if err != nil {
@@ -3413,9 +3456,10 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
3413 }
3456 }
3414 }
3457 }
3415 return shellResultMsg{
3458 return shellResultMsg{
3459+ PendingID: pendingItem.ID(),
3416− Command: command,
3460+ Command: command,
3417− Output: resp.Output,
3461+ Output: resp.Output,
3418− ExitCode: resp.ExitCode,
3462+ ExitCode: resp.ExitCode,
3419 }
3463 }
3420 })
3464 })
3421 return tea.Batch(cmds...)
3465 return tea.Batch(cmds...)