@@ -111,6 +111,12 @@ type openEditorMsg struct {
111111 Text string
112112}
113113
114+type shellResultMsg struct {
115+ Command string
116+ Output string
117+ ExitCode int
118+}
119+
114120type (
115121 // cancelTimerExpiredMsg is sent when the cancel timer expires.
116122 cancelTimerExpiredMsg struct{}
@@ -192,6 +198,10 @@ type UI struct {
192198 // isCanceling tracks whether the user has pressed escape once to cancel.
193199 isCanceling bool
194200
201+ // bangMode tracks whether the editor is in bang (!) shell mode.
202+ bangMode bool
203+ bangWasEmpty bool // true when bang prompt became empty on last keystroke
204+
195205 header *header
196206
197207 // sendProgressBar instructs the TUI to send progress bar updates to the
@@ -866,6 +876,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
866876 // others send DeltaY=1.
867877 switch m.state {
868878 case uiChat:
879+ if msg.DeltaX != 0 {
880+ m.chat.ScrollSelectedShellHorizontal(int(msg.DeltaX))
881+ }
869882 lines := int(msg.DeltaY)
870883 if lines == 0 {
871884 break
@@ -926,6 +939,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
926939 m.textarea.SetValue(msg.Text)
927940 m.textarea.MoveToEnd()
928941 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
942+ case shellResultMsg:
943+ item := chat.NewShellItem(m.com.Styles, msg.Command, msg.Output, msg.ExitCode)
944+ m.chat.AppendMessages(item)
945+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
946+ cmds = append(cmds, cmd)
947+ }
929948 case hyperRefreshDoneMsg:
930949 if cmd := m.handleSelectModel(msg.action); cmd != nil {
931950 cmds = append(cmds, cmd)
@@ -979,12 +998,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
979998 case uiFocusMain:
980999 case uiFocusEditor:
9811000 // Textarea placeholder logic
982− if m.isAgentBusy() {
1001+ if m.bangMode {
1002+ m.textarea.Placeholder = "Run a shell command"
1003+ } else if m.isAgentBusy() {
9831004 m.textarea.Placeholder = m.workingPlaceholder
9841005 } else {
9851006 m.textarea.Placeholder = m.readyPlaceholder
9861007 }
987− if m.com.Workspace.PermissionSkipRequests() {
1008+ if !m.bangMode && m.com.Workspace.PermissionSkipRequests() {
9881009 m.textarea.Placeholder = "Yolo mode!"
9891010 }
9901011 }
@@ -1118,6 +1139,18 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
11181139
11191140 switch msg.Role {
11201141 case message.User:
1142+ // Shell commands are rendered live via shellResultMsg; skip
1143+ // the persisted duplicate.
1144+ hasShellCmd := false
1145+ for _, part := range msg.Parts {
1146+ if _, ok := part.(message.ShellCommand); ok {
1147+ hasShellCmd = true
1148+ break
1149+ }
1150+ }
1151+ if hasShellCmd {
1152+ return nil
1153+ }
11211154 m.lastUserMessageTime = msg.CreatedAt
11221155 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
11231156 for _, item := range items {
@@ -1972,6 +2005,15 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
19722005 return m.openQuitDialog()
19732006 }
19742007
2008+ if m.bangMode && value != "" {
2009+ m.bangMode = false
2010+ yolo := m.com.Workspace.PermissionSkipRequests()
2011+ m.setEditorPrompt(yolo)
2012+ m.randomizePlaceholders()
2013+ m.historyReset()
2014+ return tea.Batch(m.runShellCommand(value), m.loadPromptHistory())
2015+ }
2016+
19752017 attachments := m.attachments.List()
19762018 m.attachments.Reset()
19772019 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
@@ -2036,6 +2078,15 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
20362078 break
20372079 }
20382080
2081+ // Bang mode: backspace on already-empty prompt exits.
2082+ if m.bangMode && m.bangWasEmpty && msg.Code == tea.KeyBackspace {
2083+ m.bangMode = false
2084+ m.bangWasEmpty = false
2085+ yolo := m.com.Workspace.PermissionSkipRequests()
2086+ m.setEditorPrompt(yolo)
2087+ break
2088+ }
2089+
20392090 // Check for @ trigger before passing to textarea.
20402091 curValue := m.textarea.Value()
20412092 curIdx := len(curValue)
@@ -2062,6 +2113,22 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
20622113 prevHeight := m.textarea.Height()
20632114 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
20642115
2116+ // Bang mode: enter when "!" typed on empty prompt, exit on
2117+ // backspace clearing the last character.
2118+ newVal := m.textarea.Value()
2119+ if !m.bangMode && strings.TrimSpace(newVal) == "!" {
2120+ m.bangMode = true
2121+ m.bangWasEmpty = true
2122+ m.textarea.Reset()
2123+ yolo := m.com.Workspace.PermissionSkipRequests()
2124+ m.setEditorPrompt(yolo)
2125+ } else if m.bangMode && newVal == "" && curValue != "" {
2126+ // Just cleared last character; mark empty, stay in bang mode.
2127+ m.bangWasEmpty = true
2128+ } else if m.bangMode && newVal != "" {
2129+ m.bangWasEmpty = false
2130+ }
2131+
20652132 // Any text modification becomes the current draft.
20662133 m.updateHistoryDraft(curValue)
20672134
@@ -2942,8 +3009,12 @@ func (m *UI) openEditor(value string) tea.Cmd {
29423009}
29433010
29443011// setEditorPrompt configures the textarea prompt function based on whether
2945−// yolo mode is enabled.
3012+// yolo mode or bang mode is enabled.
29463013func (m *UI) setEditorPrompt(yolo bool) {
3014+ if m.bangMode {
3015+ m.textarea.SetPromptFunc(4, m.bangPromptFunc)
3016+ return
3017+ }
29473018 if yolo {
29483019 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
29493020 return
@@ -2984,6 +3055,22 @@ func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
29843055 return t.Editor.PromptYoloDotsBlurred.Render()
29853056}
29863057
3058+// bangPromptFunc returns the bang mode editor prompt style with Turtle-colored
3059+// icon and dots.
3060+func (m *UI) bangPromptFunc(info textarea.PromptInfo) string {
3061+ t := m.com.Styles
3062+ if info.LineNumber == 0 {
3063+ if info.Focused {
3064+ return t.Editor.PromptBangIconFocused.Render()
3065+ }
3066+ return t.Editor.PromptBangIconBlurred.Render()
3067+ }
3068+ if info.Focused {
3069+ return t.Editor.PromptBangDotsFocused.Render()
3070+ }
3071+ return t.Editor.PromptBangDotsBlurred.Render()
3072+}
3073+
29873074// closeCompletions closes the completions popup and resets state.
29883075func (m *UI) closeCompletions() {
29893076 m.completionsOpen = false
@@ -3295,6 +3382,44 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
32953382 return tea.Batch(cmds...)
32963383}
32973384
3385+// runShellCommand executes a shell command server-side without triggering
3386+// the LLM. The result is displayed as a tool-style item in the chat.
3387+func (m *UI) runShellCommand(command string) tea.Cmd {
3388+ var cmds []tea.Cmd
3389+ if !m.hasSession() {
3390+ newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3391+ if err != nil {
3392+ return util.ReportError(err)
3393+ }
3394+ if m.forceCompactMode {
3395+ m.isCompact = true
3396+ }
3397+ if newSession.ID != "" {
3398+ m.session = &newSession
3399+ cmds = append(cmds, m.loadSession(newSession.ID))
3400+ }
3401+ m.setState(uiChat, m.focus)
3402+ }
3403+
3404+ sessionID := m.session.ID
3405+ contentWidth := min(m.layout.main.Dx()-2, 120)
3406+ cmds = append(cmds, func() tea.Msg {
3407+ resp, err := m.com.Workspace.AgentRunShellCommand(context.Background(), sessionID, command, contentWidth)
3408+ if err != nil {
3409+ return util.InfoMsg{
3410+ Type: util.InfoTypeError,
3411+ Msg: fmt.Sprintf("shell: %v", err),
3412+ }
3413+ }
3414+ return shellResultMsg{
3415+ Command: command,
3416+ Output: resp.Output,
3417+ ExitCode: resp.ExitCode,
3418+ }
3419+ })
3420+ return tea.Batch(cmds...)
3421+}
3422+
32983423const cancelTimerDuration = 2 * time.Second
32993424
33003425// cancelTimerCmd creates a command that expires the cancel timer.