on main help sign in
fix(bangmode): move ansi remaping into common and cleanup bangmode
cf0d7c2Kieran Klukas
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 {
233 }
233 }
234 raw = trimmed
234 raw = trimmed
235 }
235 }
236− output := remapANSI16(raw, s.sty.ANSI)
236+ output := common.RemapANSI16(raw, s.sty.ANSI)
237 lines := strings.Split(output, "\n")
237 lines := strings.Split(output, "\n")
238
238
239 // While streaming, show the tail of the output so the most recent
239 // 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
621
621
622// toolOutputPlainContent renders plain text with optional expansion support.
622// toolOutputPlainContent renders plain text with optional expansion support.
623func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
623func 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)
625 lines := strings.Split(content, "\n")
625 lines := strings.Split(content, "\n")
626
626
627 maxLines := responseContextHeight
627 maxLines := responseContextHeight
internal/ui/chat/ansi16.go → internal/ui/common/ansi16.gorenamed+3−3
@@ -1,4 +1,4 @@
1−package chat
1+package common
2
2
3import (
3import (
4 "image/color"
4 "image/color"
@@ -8,7 +8,7 @@ import (
8 "github.com/charmbracelet/x/ansi"
8 "github.com/charmbracelet/x/ansi"
9)
9)
10
10
11−// remapANSI16 replaces basic ANSI 16-color SGR codes with 24-bit
11+// RemapANSI16 replaces basic ANSI 16-color SGR codes with 24-bit
12// truecolor from palette. Programs emit \x1b[31m etc. and trust the
12// truecolor from palette. Programs emit \x1b[31m etc. and trust the
13// terminal to pick the color; inside Crush's TUI those defaults are
13// terminal to pick the color; inside Crush's TUI those defaults are
14// often illegible on our dark background. Rewriting them to explicit
14// often illegible on our dark background. Rewriting them to explicit
@@ -16,7 +16,7 @@ import (
16//
16//
17// Uses [ansi.DecodeSequence] for parsing (same approach as
17// Uses [ansi.DecodeSequence] for parsing (same approach as
18// [colorprofile.Writer]) since there is no upstream palette-remap API.
18// [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 {
20 if !strings.ContainsRune(s, 0x1b) {
20 if !strings.ContainsRune(s, 0x1b) {
21 return s
21 return s
22 }
22 }
internal/ui/chat/ansi16_test.go → internal/ui/common/ansi16_test.gorenamed+3−3
@@ -1,4 +1,4 @@
1−package chat
1+package common
2
2
3import (
3import (
4 "image/color"
4 "image/color"
@@ -91,7 +91,7 @@ func TestRemapANSI16(t *testing.T) {
91 for _, tt := range tests {
91 for _, tt := range tests {
92 t.Run(tt.name, func(t *testing.T) {
92 t.Run(tt.name, func(t *testing.T) {
93 t.Parallel()
93 t.Parallel()
94− require.Equal(t, tt.want, remapANSI16(tt.in, pal))
94+ require.Equal(t, tt.want, RemapANSI16(tt.in, pal))
95 })
95 })
96 }
96 }
97}
97}
@@ -102,5 +102,5 @@ func TestRemapANSI16NilColorFallsBack(t *testing.T) {
102 var pal [16]color.Color // all nil
102 var pal [16]color.Color // all nil
103 // With a nil palette entry, the introducer is emitted bare so the
103 // With a nil palette entry, the introducer is emitted bare so the
104 // terminal default applies rather than crashing.
104 // 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))
106}
106}
internal/ui/model/ui.go+32−43
@@ -208,9 +208,14 @@ type UI struct {
208 isCanceling bool
208 isCanceling bool
209
209
210 // bangMode tracks whether the editor is in bang (!) shell mode.
210 // bangMode tracks whether the editor is in bang (!) shell mode.
211− bangMode bool
211+ bangMode bool
212− bangWasEmpty bool // true when bang prompt became empty on last keystroke
212+ bangWasEmpty bool // true when bang prompt became empty on last keystroke
213− pendingShellItem *chat.ShellItem
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
214
219
215 // bangCancel cancels a running bang-mode shell command. Nil when no
220 // bangCancel cancels a running bang-mode shell command. Nil when no
216 // bang command is in progress. Set by runShellCommand, cleared by
221 // 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) {
622 if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
627 if cmd := m.autoExpandPillsIfReasonable(); cmd != nil {
623 cmds = append(cmds, cmd)
628 cmds = append(cmds, cmd)
624 }
629 }
625− // Re-append pending shell item if session load wiped it.
630+ // If a bang command was issued before the session finished
626− if m.pendingShellItem != nil {
631+ // loading, start it now that the chat list is stable.
627− m.chat.AppendMessages(m.pendingShellItem)
632+ if m.pendingBangCommand != "" {
628− if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
633+ cmds = append(cmds, m.runShellCommandInternal(m.pendingBangCommand, true))
629− cmds = append(cmds, cmd)
634+ m.pendingBangCommand = ""
630− }
631− if cmd := m.pendingShellItem.StartAnimation(); cmd != nil {
632− cmds = append(cmds, cmd)
633− }
634− m.pendingShellItem = nil
635 }
635 }
636 if hasInProgressTodo(m.session.Todos) {
636 if hasInProgressTodo(m.session.Todos) {
637 // only start spinner if there is an in-progress todo
637 // 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) {
994 m.bangCancel = nil
994 m.bangCancel = nil
995 }
995 }
996 // Complete the pending shell item if it exists, otherwise create a new one.
996 // 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.
999 completed := false
997 completed := false
1000 if msg.PendingID != "" {
998 if msg.PendingID != "" {
1001 if item := m.chat.MessageItem(msg.PendingID); item != nil {
999 if item := m.chat.MessageItem(msg.PendingID); item != nil {
@@ -1007,19 +1005,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1007 completed = true
1005 completed = true
1008 }
1006 }
1009 }
1007 }
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− }
1023 }
1008 }
1024 if !completed {
1009 if !completed {
1025 item := chat.NewShellItem(m.com.Styles, msg.Command, msg.Output, msg.ExitCode)
1010 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.
3471// runShellCommand executes a shell command server-side without triggering
3456// runShellCommand executes a shell command server-side without triggering
3472// the LLM. The result is displayed as a tool-style item in the chat.
3457// the LLM. The result is displayed as a tool-style item in the chat.
3473func (m *UI) runShellCommand(command string) tea.Cmd {
3458func (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 {
3474 var cmds []tea.Cmd
3466 var cmds []tea.Cmd
3475− creatingSession := !m.hasSession()
3467+ if !m.hasSession() {
3476− if creatingSession {
3477 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3468 newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3478 if err != nil {
3469 if err != nil {
3479 return util.ReportError(err)
3470 return util.ReportError(err)
@@ -3486,6 +3477,10 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
3486 cmds = append(cmds, m.loadSession(newSession.ID))
3477 cmds = append(cmds, m.loadSession(newSession.ID))
3487 }
3478 }
3488 m.setState(uiChat, m.focus)
3479 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...)
3489 }
3484 }
3490
3485
3491 sessionID := m.session.ID
3486 sessionID := m.session.ID
@@ -3493,18 +3488,12 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
3493
3488
3494 // Append a pending shell item immediately so the user sees feedback.
3489 // Append a pending shell item immediately so the user sees feedback.
3495 pendingItem := chat.NewPendingShellItem(m.com.Styles, command)
3490 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)
3491+ m.chat.AppendMessages(pendingItem)
3502− if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
3492+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
3503− cmds = append(cmds, cmd)
3493+ cmds = append(cmds, cmd)
3504− }
3494+ }
3505− if cmd := pendingItem.StartAnimation(); cmd != nil {
3495+ if cmd := pendingItem.StartAnimation(); cmd != nil {
3506− cmds = append(cmds, cmd)
3496+ cmds = append(cmds, cmd)
3507− }
3508 }
3497 }
3509
3498
3510 // Stream output via channel. The progress callback writes chunks
3499 // Stream output via channel. The progress callback writes chunks
@@ -3533,7 +3522,7 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
3533 m.bangCancel = cancel
3522 m.bangCancel = cancel
3534
3523
3535 cmds = append(cmds, func() tea.Msg {
3524 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)
3537 close(streamCh)
3526 close(streamCh)
3538 if err != nil && !errors.Is(err, context.Canceled) {
3527 if err != nil && !errors.Is(err, context.Canceled) {
3539 return util.InfoMsg{
3528 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
108 return err
108 return err
109}
109}
110
110
111−func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, command string, termWidth int, onProgress func(string)) (proto.ShellCommandResponse, error) {
111+func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, command string, termWidth int, onProgress func(string), isFirstMessage bool) (proto.ShellCommandResponse, error) {
112− var isFirstMessage bool
113 var persist shell.PersistFunc
112 var persist shell.PersistFunc
114 if sessionID != "" {
113 if sessionID != "" {
115 persist = func(cmd, output string, exitCode int) error {
114 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{
128 Role: message.User,
116 Role: message.User,
129 Parts: []message.ContentPart{message.ShellCommand{
117 Parts: []message.ContentPart{message.ShellCommand{
130 Command: cmd,
118 Command: cmd,
@@ -146,7 +134,6 @@ func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, comm
146 var err error
134 var err error
147
135
148 if onProgress != nil {
136 if onProgress != nil {
149− // Use streaming execution with progress callback.
150 result, err = shell.RunAndCaptureStream(ctx, opts, onProgress)
137 result, err = shell.RunAndCaptureStream(ctx, opts, onProgress)
151 } else {
138 } else {
152 result, err = shell.RunAndPersist(ctx, opts, persist)
139 result, err = shell.RunAndPersist(ctx, opts, persist)
@@ -156,7 +143,7 @@ func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, comm
156 return proto.ShellCommandResponse{}, err
143 return proto.ShellCommandResponse{}, err
157 }
144 }
158
145
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).
160 if onProgress != nil && persist != nil {
147 if onProgress != nil && persist != nil {
161 if persistErr := persist(command, result.Output, result.ExitCode); persistErr != nil {
148 if persistErr := persist(command, result.Output, result.ExitCode); persistErr != nil {
162 slog.Error("Failed to persist shell command output", "error", persistErr, "command", command)
149 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
164 }
151 }
165
152
166 // Generate a title from the shell command if it was the first message.
153 // Generate a title from the shell command if it was the first message.
167− if isFirstMessage {
154+ if isFirstMessage && w.app.AgentCoordinator != nil {
168− go func() {
169− titleCtx := context.WithoutCancel(ctx)
155+ 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)
156+ w.app.AgentCoordinator.GenerateTitle(titleCtx, sessionID, "$ "+command)
176− }
177− }()
178 }
157 }
179
158
180 return proto.ShellCommandResponse{
159 return proto.ShellCommandResponse{
internal/workspace/client_workspace.go+1−1
@@ -186,7 +186,7 @@ func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string
186 return w.client.SendMessage(ctx, w.workspaceID(), sessionID, "", prompt, attachments...)
186 return w.client.SendMessage(ctx, w.workspaceID(), sessionID, "", prompt, attachments...)
187}
187}
188
188
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) {
190 return w.client.RunShellCommand(ctx, w.workspaceID(), sessionID, command, termWidth)
190 return w.client.RunShellCommand(ctx, w.workspaceID(), sessionID, command, termWidth)
191}
191}
192
192
internal/workspace/workspace.go+1−1
@@ -83,7 +83,7 @@ type Workspace interface {
83
83
84 // Agent
84 // Agent
85 AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error
85 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)
87 AgentCancel(sessionID string)
87 AgentCancel(sessionID string)
88 AgentIsBusy() bool
88 AgentIsBusy() bool
89 AgentIsSessionBusy(sessionID string) bool
89 AgentIsSessionBusy(sessionID string) bool