@@ -208,9 +208,14 @@ type UI struct {
208 isCanceling bool
208 isCanceling bool
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
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)
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 = ""
631− if cmd := m.pendingShellItem.StartAnimation(); cmd != nil {
632− cmds = append(cmds, cmd)
634− m.pendingShellItem = nil
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
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
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)
1020− m.pendingShellItem = nil
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)
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))
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...)
3491 sessionID := m.session.ID
3486 sessionID := m.session.ID
@@ -3493,18 +3488,12 @@ func (m *UI) runShellCommand(command string) tea.Cmd {
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
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)
3505− if cmd := pendingItem.StartAnimation(); cmd != nil {
3495+ if cmd := pendingItem.StartAnimation(); cmd != nil {
3506− cmds = append(cmds, cmd)
3496+ cmds = append(cmds, cmd)
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
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{