on main help sign in
fix(bangmode): move ansi remaping into common and cleanup bangmode

intalization

commitcf0d7c2f5e9f2b1acdb4fd387361021119298705 authorKieran Klukas <kieran@dunkirk.sh> dateFri 19 Jun 2026, 15:32 (4 months ago) parent8305eb0
9 files changed+48−125
internal/ui/chat/ansi16_preview_test.go−45 internal/ui/chat/shell.go+1−1 internal/ui/chat/tools.go+1−1 internal/ui/chat/ansi16.go → internal/ui/common/ansi16.go+3−3 internal/ui/chat/ansi16_test.go → internal/ui/common/ansi16_test.go+3−3 internal/ui/model/ui.go+32−43 internal/workspace/app_workspace.go+6−27 internal/workspace/client_workspace.go+1−1 internal/workspace/workspace.go+1−1
internal/ui/chat/ansi16_preview_test.godeleted−45
@@ -1,45 +0,0 @@
1−package chat
2−
3−import (
4− "fmt"
5− "strconv"
6− "testing"
7−)
8−
9−// TestPreviewANSI16Palette prints a swatch grid using the raw ANSI 16
10−// color codes (the codes terminal programs actually emit). It is not an
11−// assertion test; it exists so you can eyeball how the codes render:
12−//
13−// go test ./internal/ui/chat/ -run TestPreviewANSI16Palette -v
14−//
15−// These are the unmodified SGR codes, so what you see is your terminal's
16−// own palette, not Crush's remap.
17−func TestPreviewANSI16Palette(t *testing.T) {
18− names := []string{
19− "black", "red", "green", "yellow",
20− "blue", "magenta", "cyan", "white",
21− }
22−
23− fmt.Println("\nForeground (normal | bright):")
24− for i, name := range names {
25− normal := "\x1b[" + strconv.Itoa(30+i) + "m" + pad(name) + "\x1b[0m"
26− bright := "\x1b[" + strconv.Itoa(90+i) + "m" + pad("bright "+name) + "\x1b[0m"
27− fmt.Printf(" %s %s\n", normal, bright)
28− }
29−
30− fmt.Println("\nBackground (normal | bright):")
31− for i, name := range names {
32− normal := "\x1b[30;" + strconv.Itoa(40+i) + "m" + pad(name) + "\x1b[0m"
33− bright := "\x1b[30;" + strconv.Itoa(100+i) + "m" + pad("bright "+name) + "\x1b[0m"
34− fmt.Printf(" %s %s\n", normal, bright)
35− }
36− fmt.Println()
37−}
38−
39−func pad(s string) string {
40− const w = 16
41− for len(s) < w {
42− s += " "
43− }
44− return " " + s
45−}
internal/ui/chat/shell.go+1−1
@@ -233,7 +233,7 @@ func (s *ShellItem) RawRender(width int) string {
233233 }
234234 raw = trimmed
235235 }
236− output := remapANSI16(raw, s.sty.ANSI)
236+ output := common.RemapANSI16(raw, s.sty.ANSI)
237237 lines := strings.Split(output, "\n")
238238
239239 // While streaming, show the tail of the output so the most recent
internal/ui/chat/tools.go+1−1
@@ -621,7 +621,7 @@ func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, n
621621
622622// toolOutputPlainContent renders plain text with optional expansion support.
623623func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
624− content = remapANSI16(stringext.NormalizeSpace(content), sty.ANSI)
624+ content = common.RemapANSI16(stringext.NormalizeSpace(content), sty.ANSI)
625625 lines := strings.Split(content, "\n")
626626
627627 maxLines := responseContextHeight
internal/ui/chat/ansi16.go → internal/ui/common/ansi16.gorenamed+3−3
@@ -1,4 +1,4 @@
1−package chat
1+package common
22
33import (
44 "image/color"
@@ -8,7 +8,7 @@ import (
88 "github.com/charmbracelet/x/ansi"
99)
1010
11−// remapANSI16 replaces basic ANSI 16-color SGR codes with 24-bit
11+// RemapANSI16 replaces basic ANSI 16-color SGR codes with 24-bit
1212// truecolor from palette. Programs emit \x1b[31m etc. and trust the
1313// terminal to pick the color; inside Crush's TUI those defaults are
1414// often illegible on our dark background. Rewriting them to explicit
@@ -16,7 +16,7 @@ import (
1616//
1717// Uses [ansi.DecodeSequence] for parsing (same approach as
1818// [colorprofile.Writer]) since there is no upstream palette-remap API.
19−func remapANSI16(s string, palette [16]color.Color) string {
19+func RemapANSI16(s string, palette [16]color.Color) string {
2020 if !strings.ContainsRune(s, 0x1b) {
2121 return s
2222 }
internal/ui/chat/ansi16_test.go → internal/ui/common/ansi16_test.gorenamed+3−3
@@ -1,4 +1,4 @@
1−package chat
1+package common
22
33import (
44 "image/color"
@@ -91,7 +91,7 @@ func TestRemapANSI16(t *testing.T) {
9191 for _, tt := range tests {
9292 t.Run(tt.name, func(t *testing.T) {
9393 t.Parallel()
94− require.Equal(t, tt.want, remapANSI16(tt.in, pal))
94+ require.Equal(t, tt.want, RemapANSI16(tt.in, pal))
9595 })
9696 }
9797}
@@ -102,5 +102,5 @@ func TestRemapANSI16NilColorFallsBack(t *testing.T) {
102102 var pal [16]color.Color // all nil
103103 // With a nil palette entry, the introducer is emitted bare so the
104104 // terminal default applies rather than crashing.
105− require.Equal(t, "\x1b[38mx\x1b[0m", remapANSI16("\x1b[31mx\x1b[0m", pal))
105+ require.Equal(t, "\x1b[38mx\x1b[0m", RemapANSI16("\x1b[31mx\x1b[0m", pal))
106106}
internal/ui/model/ui.go+32−43
@@ -208,9 +208,14 @@ type UI struct {
208208 isCanceling bool
209209
210210 // bangMode tracks whether the editor is in bang (!) shell mode.
211− bangMode bool
212− bangWasEmpty bool // true when bang prompt became empty on last keystroke
213− pendingShellItem *chat.ShellItem
211+ bangMode bool
212+ bangWasEmpty bool // true when bang prompt became empty on last keystroke
213+
214+ // pendingBangCommand holds a shell command that was issued before
215+ // the session finished loading. The loadSessionMsg handler creates
216+ // the pending UI item and starts execution once the chat list is
217+ // stable, eliminating races between session load and shell output.
218+ pendingBangCommand string
214219
215220 // bangCancel cancels a running bang-mode shell command. Nil when no
216221 // bang command is in progress. Set by runShellCommand, cleared by
@@ -622,16 +627,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
622627 if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
623628 cmds = append(cmds, cmd)
624629 }
625− // Re-append pending shell item if session load wiped it.
626− if m.pendingShellItem != nil {
627− m.chat.AppendMessages(m.pendingShellItem)
628− if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
629− cmds = append(cmds, cmd)
630− }
631− if cmd := m.pendingShellItem.StartAnimation(); cmd != nil {
632− cmds = append(cmds, cmd)
633− }
634− m.pendingShellItem = nil
630+ // If a bang command was issued before the session finished
631+ // loading, start it now that the chat list is stable.
632+ if m.pendingBangCommand != "" {
633+ cmds = append(cmds, m.runShellCommandInternal(m.pendingBangCommand, true))
634+ m.pendingBangCommand = ""
635635 }
636636 if hasInProgressTodo(m.session.Todos) {
637637 // only start spinner if there is an in-progress todo
@@ -994,8 +994,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
994994 m.bangCancel = nil
995995 }
996996 // Complete the pending shell item if it exists, otherwise create a new one.
997− // During session creation the pending item lives in m.pendingShellItem
998− // rather than the chat list, so check both locations.
999997 completed := false
1000998 if msg.PendingID != "" {
1001999 if item := m.chat.MessageItem(msg.PendingID); item != nil {
@@ -1007,19 +1005,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
10071005 completed = true
10081006 }
10091007 }
1010− if !completed && m.pendingShellItem != nil && m.pendingShellItem.ID() == msg.PendingID {
1011− m.pendingShellItem.Complete(msg.Output, msg.ExitCode)
1012− // The item is still stashed; it will be appended when the
1013− // session load handler fires. Append it now so the result
1014− // is visible immediately and clear the stash to prevent
1015− // a duplicate append later.
1016− m.chat.AppendMessages(m.pendingShellItem)
1017− if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1018− cmds = append(cmds, cmd)
1019− }
1020− m.pendingShellItem = nil
1021− completed = true
1022− }
10231008 }
10241009 if !completed {
10251010 item := chat.NewShellItem(m.com.Styles, msg.Command, msg.Output, msg.ExitCode)
@@ -3471,9 +3456,15 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
34713456// runShellCommand executes a shell command server-side without triggering
34723457// the LLM. The result is displayed as a tool-style item in the chat.
34733458func (m *UI) runShellCommand(command string) tea.Cmd {
3459+ return m.runShellCommandInternal(command, false)
3460+}
3461+
3462+// runShellCommandInternal is the shared implementation for bang-mode shell
3463+// execution. isFirstMessage indicates the command is the first user message
3464+// in a newly created session, which triggers title generation.
3465+func (m *UI) runShellCommandInternal(command string, isFirstMessage bool) tea.Cmd {
34743466 var cmds []tea.Cmd
3475− creatingSession := !m.hasSession()
3476− if creatingSession {
3467+ if !m.hasSession() {
34773468 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
34783469 if err != nil {
34793470 return util.ReportError(err)
@@ -3486,6 +3477,10 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
34863477 cmds = append(cmds, m.loadSession(newSession.ID))
34873478 }
34883479 m.setState(uiChat, m.focus)
3480+ // Defer shell execution until loadSessionMsg fires so the chat
3481+ // list is stable before we add items or start streaming.
3482+ m.pendingBangCommand = command
3483+ return tea.Batch(cmds...)
34893484 }
34903485
34913486 sessionID := m.session.ID
@@ -3493,18 +3488,12 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
34933488
34943489 // Append a pending shell item immediately so the user sees feedback.
34953490 pendingItem := chat.NewPendingShellItem(m.com.Styles, command)
3496− if creatingSession {
3497− // loadSession will wipe the chat list via SetMessages.
3498− // Stash the item and re-append after messages load.
3499− m.pendingShellItem = pendingItem
3500− } else {
3501− m.chat.AppendMessages(pendingItem)
3502− if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
3503− cmds = append(cmds, cmd)
3504− }
3505− if cmd := pendingItem.StartAnimation(); cmd != nil {
3506− cmds = append(cmds, cmd)
3507− }
3491+ m.chat.AppendMessages(pendingItem)
3492+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
3493+ cmds = append(cmds, cmd)
3494+ }
3495+ if cmd := pendingItem.StartAnimation(); cmd != nil {
3496+ cmds = append(cmds, cmd)
35083497 }
35093498
35103499 // Stream output via channel. The progress callback writes chunks
@@ -3533,7 +3522,7 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
35333522 m.bangCancel = cancel
35343523
35353524 cmds = append(cmds, func() tea.Msg {
3536− resp, err := m.com.Workspace.AgentRunShellCommand(ctx, sessionID, command, contentWidth, onProgress)
3525+ resp, err := m.com.Workspace.AgentRunShellCommand(ctx, sessionID, command, contentWidth, onProgress, isFirstMessage)
35373526 close(streamCh)
35383527 if err != nil && !errors.Is(err, context.Canceled) {
35393528 return util.InfoMsg{
internal/workspace/app_workspace.go+6−27
@@ -108,23 +108,11 @@ func (w *AppWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, a
108108 return err
109109}
110110
111−func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, command string, termWidth int, onProgress func(string)) (proto.ShellCommandResponse, error) {
112− var isFirstMessage bool
111+func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, command string, termWidth int, onProgress func(string), isFirstMessage bool) (proto.ShellCommandResponse, error) {
113112 var persist shell.PersistFunc
114113 if sessionID != "" {
115114 persist = func(cmd, output string, exitCode int) error {
116− // Check if this is the first user message before persisting.
117− msgs, err := w.app.Messages.List(ctx, sessionID)
118− if err == nil {
119− isFirstMessage = true
120− for _, msg := range msgs {
121− if msg.Role == message.User {
122− isFirstMessage = false
123− break
124− }
125− }
126− }
127− _, err = w.app.Messages.Create(ctx, sessionID, message.CreateMessageParams{
115+ _, err := w.app.Messages.Create(ctx, sessionID, message.CreateMessageParams{
128116 Role: message.User,
129117 Parts: []message.ContentPart{message.ShellCommand{
130118 Command: cmd,
@@ -146,7 +134,6 @@ func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, comm
146134 var err error
147135
148136 if onProgress != nil {
149− // Use streaming execution with progress callback.
150137 result, err = shell.RunAndCaptureStream(ctx, opts, onProgress)
151138 } else {
152139 result, err = shell.RunAndPersist(ctx, opts, persist)
@@ -156,7 +143,7 @@ func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, comm
156143 return proto.ShellCommandResponse{}, err
157144 }
158145
159− // Persist if we used streaming path (persist wasn't called by RunAndPersist).
146+ // Persist if we used the streaming path (persist wasn't called by RunAndPersist).
160147 if onProgress != nil && persist != nil {
161148 if persistErr := persist(command, result.Output, result.ExitCode); persistErr != nil {
162149 slog.Error("Failed to persist shell command output", "error", persistErr, "command", command)
@@ -164,17 +151,9 @@ func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, comm
164151 }
165152
166153 // Generate a title from the shell command if it was the first message.
167− if isFirstMessage {
168− go func() {
169− titleCtx := context.WithoutCancel(ctx)
170− // Wait for the agent to be ready before generating.
171− for i := 0; i < 50 && w.app.AgentCoordinator == nil; i++ {
172− time.Sleep(100 * time.Millisecond)
173− }
174− if w.app.AgentCoordinator != nil {
175− w.app.AgentCoordinator.GenerateTitle(titleCtx, sessionID, "$ "+command)
176− }
177− }()
154+ if isFirstMessage && w.app.AgentCoordinator != nil {
155+ titleCtx := context.WithoutCancel(ctx)
156+ w.app.AgentCoordinator.GenerateTitle(titleCtx, sessionID, "$ "+command)
178157 }
179158
180159 return proto.ShellCommandResponse{
internal/workspace/client_workspace.go+1−1
@@ -186,7 +186,7 @@ func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string
186186 return w.client.SendMessage(ctx, w.workspaceID(), sessionID, "", prompt, attachments...)
187187}
188188
189−func (w *ClientWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, command string, termWidth int, _ func(string)) (proto.ShellCommandResponse, error) {
189+func (w *ClientWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, command string, termWidth int, _ func(string), _ bool) (proto.ShellCommandResponse, error) {
190190 return w.client.RunShellCommand(ctx, w.workspaceID(), sessionID, command, termWidth)
191191}
192192
internal/workspace/workspace.go+1−1
@@ -83,7 +83,7 @@ type Workspace interface {
8383
8484 // Agent
8585 AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error
86− AgentRunShellCommand(ctx context.Context, sessionID, command string, termWidth int, onProgress func(string)) (proto.ShellCommandResponse, error)
86+ AgentRunShellCommand(ctx context.Context, sessionID, command string, termWidth int, onProgress func(string), isFirstMessage bool) (proto.ShellCommandResponse, error)
8787 AgentCancel(sessionID string)
8888 AgentIsBusy() bool
8989 AgentIsSessionBusy(sessionID string) bool