@@ -111,6 +111,12 @@ type openEditorMsg struct {
111 Text string
111 Text string
114+type shellResultMsg struct {
115 // cancelTimerExpiredMsg is sent when the cancel timer expires.
121 // cancelTimerExpiredMsg is sent when the cancel timer expires.
116 cancelTimerExpiredMsg struct{}
122 cancelTimerExpiredMsg struct{}
@@ -192,6 +198,10 @@ type UI struct {
192 // isCanceling tracks whether the user has pressed escape once to cancel.
198 // isCanceling tracks whether the user has pressed escape once to cancel.
193 isCanceling bool
199 isCanceling bool
201+ // bangMode tracks whether the editor is in bang (!) shell mode.
203+ bangWasEmpty bool // true when bang prompt became empty on last keystroke
195 header *header
205 header *header
197 // sendProgressBar instructs the TUI to send progress bar updates to the
207 // 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) {
866 // others send DeltaY=1.
876 // others send DeltaY=1.
867 switch m.state {
877 switch m.state {
868 case uiChat:
878 case uiChat:
879+ if msg.DeltaX != 0 {
880+ m.chat.ScrollSelectedShellHorizontal(int(msg.DeltaX))
869 lines := int(msg.DeltaY)
882 lines := int(msg.DeltaY)
870 if lines == 0 {
883 if lines == 0 {
@@ -926,6 +939,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
926 m.textarea.SetValue(msg.Text)
939 m.textarea.SetValue(msg.Text)
927 m.textarea.MoveToEnd()
940 m.textarea.MoveToEnd()
928 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
941 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)
929 case hyperRefreshDoneMsg:
948 case hyperRefreshDoneMsg:
930 if cmd := m.handleSelectModel(msg.action); cmd != nil {
949 if cmd := m.handleSelectModel(msg.action); cmd != nil {
931 cmds = append(cmds, cmd)
950 cmds = append(cmds, cmd)
@@ -979,12 +998,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
979 case uiFocusMain:
998 case uiFocusMain:
980 case uiFocusEditor:
999 case uiFocusEditor:
981 // Textarea placeholder logic
1000 // Textarea placeholder logic
1002+ m.textarea.Placeholder = "Run a shell command"
982− if m.isAgentBusy() {
1003+ } else if m.isAgentBusy() {
983 m.textarea.Placeholder = m.workingPlaceholder
1004 m.textarea.Placeholder = m.workingPlaceholder
984 } else {
1005 } else {
985 m.textarea.Placeholder = m.readyPlaceholder
1006 m.textarea.Placeholder = m.readyPlaceholder
987− if m.com.Workspace.PermissionSkipRequests() {
1008+ if !m.bangMode && m.com.Workspace.PermissionSkipRequests() {
988 m.textarea.Placeholder = "Yolo mode!"
1009 m.textarea.Placeholder = "Yolo mode!"
@@ -1118,6 +1139,18 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
1119 switch msg.Role {
1140 switch msg.Role {
1120 case message.User:
1141 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 {
1121 m.lastUserMessageTime = msg.CreatedAt
1154 m.lastUserMessageTime = msg.CreatedAt
1122 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1155 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
1123 for _, item := range items {
1156 for _, item := range items {
@@ -1972,6 +2005,15 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1972 return m.openQuitDialog()
2005 return m.openQuitDialog()
2008+ if m.bangMode && value != "" {
2010+ yolo := m.com.Workspace.PermissionSkipRequests()
2011+ m.setEditorPrompt(yolo)
2012+ m.randomizePlaceholders()
2014+ return tea.Batch(m.runShellCommand(value), m.loadPromptHistory())
1975 attachments := m.attachments.List()
2017 attachments := m.attachments.List()
1976 m.attachments.Reset()
2018 m.attachments.Reset()
1977 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
2019 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
@@ -2036,6 +2078,15 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
2081+ // Bang mode: backspace on already-empty prompt exits.
2082+ if m.bangMode && m.bangWasEmpty && msg.Code == tea.KeyBackspace {
2084+ m.bangWasEmpty = false
2085+ yolo := m.com.Workspace.PermissionSkipRequests()
2086+ m.setEditorPrompt(yolo)
2039 // Check for @ trigger before passing to textarea.
2090 // Check for @ trigger before passing to textarea.
2040 curValue := m.textarea.Value()
2091 curValue := m.textarea.Value()
2041 curIdx := len(curValue)
2092 curIdx := len(curValue)
@@ -2062,6 +2113,22 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
2062 prevHeight := m.textarea.Height()
2113 prevHeight := m.textarea.Height()
2063 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
2114 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
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) == "!" {
2121+ m.bangWasEmpty = true
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
2065 // Any text modification becomes the current draft.
2132 // Any text modification becomes the current draft.
2066 m.updateHistoryDraft(curValue)
2133 m.updateHistoryDraft(curValue)
@@ -2942,8 +3009,12 @@ func (m *UI) openEditor(value string) tea.Cmd {
2944// setEditorPrompt configures the textarea prompt function based on whether
3011// setEditorPrompt configures the textarea prompt function based on whether
2945−// yolo mode is enabled.
3012+// yolo mode or bang mode is enabled.
2946func (m *UI) setEditorPrompt(yolo bool) {
3013func (m *UI) setEditorPrompt(yolo bool) {
3015+ m.textarea.SetPromptFunc(4, m.bangPromptFunc)
2947 if yolo {
3018 if yolo {
2948 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
3019 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
@@ -2984,6 +3055,22 @@ func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2984 return t.Editor.PromptYoloDotsBlurred.Render()
3055 return t.Editor.PromptYoloDotsBlurred.Render()
3058+// bangPromptFunc returns the bang mode editor prompt style with Turtle-colored
3060+func (m *UI) bangPromptFunc(info textarea.PromptInfo) string {
3062+ if info.LineNumber == 0 {
3064+ return t.Editor.PromptBangIconFocused.Render()
3066+ return t.Editor.PromptBangIconBlurred.Render()
3069+ return t.Editor.PromptBangDotsFocused.Render()
3071+ return t.Editor.PromptBangDotsBlurred.Render()
2987// closeCompletions closes the completions popup and resets state.
3074// closeCompletions closes the completions popup and resets state.
2988func (m *UI) closeCompletions() {
3075func (m *UI) closeCompletions() {
2989 m.completionsOpen = false
3076 m.completionsOpen = false
@@ -3295,6 +3382,44 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
3295 return tea.Batch(cmds...)
3382 return tea.Batch(cmds...)
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 {
3389+ if !m.hasSession() {
3390+ newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
3392+ return util.ReportError(err)
3394+ if m.forceCompactMode {
3397+ if newSession.ID != "" {
3398+ m.session = &newSession
3399+ cmds = append(cmds, m.loadSession(newSession.ID))
3401+ m.setState(uiChat, m.focus)
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)
3409+ return util.InfoMsg{
3410+ Type: util.InfoTypeError,
3411+ Msg: fmt.Sprintf("shell: %v", err),
3414+ return shellResultMsg{
3416+ Output: resp.Output,
3417+ ExitCode: resp.ExitCode,
3420+ return tea.Batch(cmds...)
3298const cancelTimerDuration = 2 * time.Second
3423const cancelTimerDuration = 2 * time.Second
3300// cancelTimerCmd creates a command that expires the cancel timer.
3425// cancelTimerCmd creates a command that expires the cancel timer.