on main help sign in
feat: add bang mode for direct shell command execution (#3013)

Prefix any input with ! to run it as a shell command without triggering the LLM. The command and output are persisted as a user message so the LLM has context on follow-up prompts. --------- Co-authored-by: Kieran Klukas <kieran@dunkirk.sh> Co-authored-by: Christian Rocha <christian@rocha.is>

commit99a5fad5370d6c47a94791f803a275348fae891e authorTai Groot <tai@taigrr.com> dateThu 18 Jun 2026, 19:02 (4 months ago) parent3b277ae
32 files changed+875−78
AGENTS.md+40 internal/agent/agent.go+22−5 internal/agent/coordinator.go+9 internal/agent/coordinator_test.go+1 internal/backend/agent.go+42 internal/backend/agent_runcomplete_test.go+1 internal/backend/agent_test.go+1 internal/client/proto.go+20 internal/message/content.go+40 internal/message/message.go+16−7 internal/proto/message.go+25−7 internal/proto/proto.go+13 internal/server/agent_cancel_test.go+3−2 internal/server/e2e_agent_test.go+9−8 internal/server/events.go+6 internal/server/proto.go+34 internal/server/server.go+1 internal/server/sessions_isbusy_test.go+3−2 internal/session/session.go+23−4 internal/shell/run.go+94 internal/ui/chat/messages.go+10 internal/ui/chat/shell.go+198 internal/ui/model/chat.go+15 internal/ui/model/header.go+1−1 internal/ui/model/keys.go+10 internal/ui/model/ui.go+128−3 internal/ui/styles/quickstyle.go+21−3 internal/ui/styles/styles.go+18−2 internal/ui/styles/themes.go+26−34 internal/workspace/app_workspace.go+33 internal/workspace/client_workspace.go+10 internal/workspace/workspace.go+2
AGENTS.md+40
@@ -181,3 +181,43 @@ func TestYourFunction(t *testing.T) {
181181
182182Anytime you need to work on the TUI, read `internal/ui/AGENTS.md` before
183183starting work.
184+
185+## Styling System
186+
187+The styling system lives in `internal/ui/styles/` and is organized into
188+three layers:
189+
190+- **`quickstyle.go`**: The stable base theme builder. `quickStyle(opts)`
191+ constructs a `Styles` struct from `quickStyleOpts` — a palette of
192+ design tokens (primary, secondary, fgBase, bgBase, success, error, etc.).
193+ `quickStyle` must be fully token-driven: never hardcode specific
194+ `charmtone.*` colors here (except Chroma syntax highlighting, which is
195+ pending tokenization). This lets any theme reuse the base without
196+ inheriting Charmtone-specific colors.
197+- **`themes.go`**: Defines concrete themes. Each theme function (e.g.
198+ `CharmtonePantera`) calls `quickStyle` with its palette, then applies
199+ theme-specific overrides as needed.
200+- **`styles.go`**: Defines the `Styles` struct and its documentation —
201+ the shape of what `quickStyle` produces.
202+
203+**Adding theme-specific overrides**: When a style genuinely needs a
204+color that doesn't fit the token model (e.g. the bang prompt uses
205+Salt/Hazy/Larple), keep `quickStyle` on the closest semantic token and
206+override only the differing colors in the theme function:
207+
208+```go
209+func CharmtonePantera() Styles {
210+ s := quickStyle(quickStyleOpts{ /* palette */ })
211+
212+ // Override only the colors that differ from the token defaults.
213+ s.Editor.PromptBangIconFocused = s.Editor.PromptBangIconFocused.
214+ Foreground(charmtone.Salt).
215+ Background(charmtone.Hazy)
216+
217+ return s
218+}
219+```
220+
221+**Adding a new theme**: Add a function in `themes.go` that returns the
222+result of `quickStyle` with a `quickStyleOpts` palette (plus any needed
223+overrides), then wire it into `ThemeForProvider`.
internal/agent/agent.go+22−5
@@ -138,6 +138,7 @@ type SessionAgent interface {
138138 ClearQueue(sessionID string)
139139 Summarize(context.Context, string, fantasy.ProviderOptions) error
140140 Model() Model
141+ GenerateTitle(ctx context.Context, sessionID, userPrompt string)
141142}
142143
143144type Model struct {
@@ -681,11 +682,11 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
681682 }
682683
683684 var wg sync.WaitGroup
684− // Generate title if first message.
685− if len(msgs) == 0 {
685+ // Generate title from the first real (non-shell) user prompt.
686+ if !hasUserTextMessage(msgs) {
686687 titleCtx := ctx // Copy to avoid race with ctx reassignment below.
687688 wg.Go(func() {
688− a.generateTitle(titleCtx, call.SessionID, call.Prompt)
689+ a.GenerateTitle(titleCtx, call.SessionID, call.Prompt)
689690 })
690691 }
691692 defer wg.Wait()
@@ -1631,8 +1632,24 @@ func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.S
16311632 return msgs, nil
16321633}
16331634
1634−// generateTitle generates a session titled based on the initial prompt.
1635−func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, userPrompt string) {
1635+// hasUserTextMessage reports whether any user message in msgs contains
1636+// text content (as opposed to only shell commands or other non-text parts).
1637+func hasUserTextMessage(msgs []message.Message) bool {
1638+ for _, msg := range msgs {
1639+ if msg.Role != message.User {
1640+ continue
1641+ }
1642+ for _, part := range msg.Parts {
1643+ if tc, ok := part.(message.TextContent); ok && tc.Text != "" {
1644+ return true
1645+ }
1646+ }
1647+ }
1648+ return false
1649+}
1650+
1651+// GenerateTitle generates a session title based on the initial prompt.
1652+func (a *sessionAgent) GenerateTitle(ctx context.Context, sessionID string, userPrompt string) {
16361653 if userPrompt == "" {
16371654 return
16381655 }
internal/agent/coordinator.go+9
@@ -101,6 +101,7 @@ type Coordinator interface {
101101 Summarize(context.Context, string) error
102102 Model() Model
103103 UpdateModels(ctx context.Context) error
104+ GenerateTitle(ctx context.Context, sessionID, prompt string)
104105}
105106
106107type coordinator struct {
@@ -1148,6 +1149,14 @@ func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
11481149 return c.runWithUnauthorizedRetry(ctx, providerCfg, summarize)
11491150}
11501151
1152+// GenerateTitle generates a session title using the current agent.
1153+func (c *coordinator) GenerateTitle(ctx context.Context, sessionID, prompt string) {
1154+ if c.currentAgent == nil {
1155+ return
1156+ }
1157+ c.currentAgent.GenerateTitle(ctx, sessionID, prompt)
1158+}
1159+
11511160// refreshTokenIfExpired proactively refreshes the OAuth token if it has expired.
11521161func (c *coordinator) refreshTokenIfExpired(ctx context.Context, providerCfg config.ProviderConfig) error {
11531162 if providerCfg.OAuthToken == nil || !providerCfg.OAuthToken.IsExpired() {
internal/agent/coordinator_test.go+1
@@ -45,6 +45,7 @@ func (m *mockSessionAgent) ClearQueue(sessionID string) {}
4545func (m *mockSessionAgent) Summarize(context.Context, string, fantasy.ProviderOptions) error {
4646 return nil
4747}
48+func (m *mockSessionAgent) GenerateTitle(context.Context, string, string) {}
4849
4950// newTestCoordinator creates a minimal coordinator for unit testing runSubAgent.
5051func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator {
internal/backend/agent.go+42
@@ -3,12 +3,15 @@ package backend
33import (
44 "context"
55 "errors"
6+ "os"
67
78 "github.com/charmbracelet/crush/internal/agent"
89 "github.com/charmbracelet/crush/internal/agent/notify"
910 "github.com/charmbracelet/crush/internal/config"
11+ "github.com/charmbracelet/crush/internal/message"
1012 "github.com/charmbracelet/crush/internal/proto"
1113 "github.com/charmbracelet/crush/internal/pubsub"
14+ "github.com/charmbracelet/crush/internal/shell"
1215)
1316
1417// SendMessage validates and accepts a prompt for the workspace's agent,
@@ -238,3 +241,42 @@ func (b *Backend) GetDefaultSmallModel(workspaceID, providerID string) (config.S
238241
239242 return ws.GetDefaultSmallModel(providerID), nil
240243}
244+
245+// RunShellCommand runs a shell command in the workspace directory and
246+// persists the command + output as a user message in the session.
247+func (b *Backend) RunShellCommand(ctx context.Context, workspaceID string, req proto.ShellCommandRequest) (proto.ShellCommandResponse, error) {
248+ ws, err := b.GetWorkspace(workspaceID)
249+ if err != nil {
250+ return proto.ShellCommandResponse{}, err
251+ }
252+
253+ var persist shell.PersistFunc
254+ if req.SessionID != "" {
255+ persist = func(cmd, output string, exitCode int) error {
256+ _, err := ws.Messages.Create(ctx, req.SessionID, message.CreateMessageParams{
257+ Role: message.User,
258+ Parts: []message.ContentPart{message.ShellCommand{
259+ Command: cmd,
260+ Output: output,
261+ ExitCode: exitCode,
262+ }},
263+ })
264+ return err
265+ }
266+ }
267+
268+ result, err := shell.RunAndPersist(ctx, shell.RunOptions{
269+ Command: req.Command,
270+ Cwd: ws.Path,
271+ Env: append(os.Environ(), ws.Env...),
272+ TermWidth: req.TermWidth,
273+ }, persist)
274+ if err != nil {
275+ return proto.ShellCommandResponse{}, err
276+ }
277+
278+ return proto.ShellCommandResponse{
279+ Output: result.Output,
280+ ExitCode: result.ExitCode,
281+ }, nil
282+}
internal/backend/agent_runcomplete_test.go+1
@@ -47,6 +47,7 @@ func (c *errorCoordinator) ClearQueue(string) {}
4747func (c *errorCoordinator) Summarize(context.Context, string) error { return nil }
4848func (c *errorCoordinator) Model() agent.Model { return agent.Model{} }
4949func (c *errorCoordinator) UpdateModels(context.Context) error { return nil }
50+func (c *errorCoordinator) GenerateTitle(context.Context, string, string) {}
5051
5152// insertRunCompleteWorkspace installs a workspace backed by a real
5253// app.App (so the runCompletions broker exists) with the given
internal/backend/agent_test.go+1
@@ -57,6 +57,7 @@ func (c *blockingCoordinator) ClearQueue(string)
5757func (c *blockingCoordinator) Summarize(context.Context, string) error { return nil }
5858func (c *blockingCoordinator) Model() agent.Model { return agent.Model{} }
5959func (c *blockingCoordinator) UpdateModels(context.Context) error { return nil }
60+func (c *blockingCoordinator) GenerateTitle(context.Context, string, string) {}
6061
6162// insertAgentWorkspace installs a synthetic workspace with the given
6263// coordinator (or none) and a workspace run context, mirroring the
internal/client/proto.go+20
@@ -445,6 +445,26 @@ func decodeErrorMessage(body io.Reader) string {
445445 return e.Message
446446}
447447
448+// RunShellCommand runs a shell command in the workspace without triggering the agent.
449+func (c *Client) RunShellCommand(ctx context.Context, id, sessionID, command string, termWidth int) (proto.ShellCommandResponse, error) {
450+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/shell", id, sessionID), nil, jsonBody(proto.ShellCommandRequest{
451+ Command: command,
452+ TermWidth: termWidth,
453+ }), http.Header{"Content-Type": []string{"application/json"}})
454+ if err != nil {
455+ return proto.ShellCommandResponse{}, fmt.Errorf("failed to run shell command: %w", err)
456+ }
457+ defer rsp.Body.Close()
458+ if rsp.StatusCode != http.StatusOK {
459+ return proto.ShellCommandResponse{}, fmt.Errorf("failed to run shell command: status code %d", rsp.StatusCode)
460+ }
461+ var resp proto.ShellCommandResponse
462+ if err := json.NewDecoder(rsp.Body).Decode(&resp); err != nil {
463+ return proto.ShellCommandResponse{}, fmt.Errorf("failed to decode shell command response: %w", err)
464+ }
465+ return resp, nil
466+}
467+
448468// GetAgentSessionInfo retrieves the agent session info for a workspace.
449469func (c *Client) GetAgentSessionInfo(ctx context.Context, id string, sessionID string) (*proto.AgentSession, error) {
450470 rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s", id, sessionID), nil, nil)
internal/message/content.go+40
@@ -129,6 +129,37 @@ type Finish struct {
129129
130130func (Finish) isPart() {}
131131
132+// ShellCommand stores a bang-mode shell command and its output as a
133+// distinct content part so it can be reconstructed on session restore.
134+type ShellCommand struct {
135+ Command string `json:"command"`
136+ Output string `json:"output"`
137+ ExitCode int `json:"exit_code"`
138+}
139+
140+func (ShellCommand) isPart() {}
141+
142+// HasShellCommand reports whether the message contains any ShellCommand parts.
143+func (m *Message) HasShellCommand() bool {
144+ for _, part := range m.Parts {
145+ if _, ok := part.(ShellCommand); ok {
146+ return true
147+ }
148+ }
149+ return false
150+}
151+
152+// ShellCommands returns all ShellCommand parts from the message.
153+func (m *Message) ShellCommands() []ShellCommand {
154+ var cmds []ShellCommand
155+ for _, part := range m.Parts {
156+ if sc, ok := part.(ShellCommand); ok {
157+ cmds = append(cmds, sc)
158+ }
159+ }
160+ return cmds
161+}
162+
132163type Message struct {
133164 ID string
134165 Role MessageRole
@@ -482,6 +513,15 @@ func (m *Message) ToAIMessage() []fantasy.Message {
482513 })
483514 }
484515 text = PromptWithTextAttachments(text, textAttachments)
516+ // Include bang-mode shell commands as context for the agent.
517+ for _, sc := range m.ShellCommands() {
518+ shellText := fmt.Sprintf("$ %s\n%s\n(exit code %d)", sc.Command, sc.Output, sc.ExitCode)
519+ if text != "" {
520+ text += "\n\n" + shellText
521+ } else {
522+ text = shellText
523+ }
524+ }
485525 if text != "" {
486526 parts = append(parts, fantasy.TextPart{Text: text})
487527 }
internal/message/message.go+16−7
@@ -519,13 +519,14 @@ func (s *service) fromDBItem(item db.Message) (Message, error) {
519519type partType string
520520
521521const (
522− reasoningType partType = "reasoning"
523− textType partType = "text"
524− imageURLType partType = "image_url"
525− binaryType partType = "binary"
526− toolCallType partType = "tool_call"
527− toolResultType partType = "tool_result"
528− finishType partType = "finish"
522+ reasoningType partType = "reasoning"
523+ textType partType = "text"
524+ imageURLType partType = "image_url"
525+ binaryType partType = "binary"
526+ toolCallType partType = "tool_call"
527+ toolResultType partType = "tool_result"
528+ finishType partType = "finish"
529+ shellCommandType partType = "shell_command"
529530)
530531
531532type partWrapper struct {
@@ -554,6 +555,8 @@ func marshalParts(parts []ContentPart) ([]byte, error) {
554555 typ = toolResultType
555556 case Finish:
556557 typ = finishType
558+ case ShellCommand:
559+ typ = shellCommandType
557560 default:
558561 return nil, fmt.Errorf("unknown part type: %T", part)
559562 }
@@ -628,6 +631,12 @@ func unmarshalParts(data []byte) ([]ContentPart, error) {
628631 return nil, err
629632 }
630633 parts = append(parts, part)
634+ case shellCommandType:
635+ part := ShellCommand{}
636+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
637+ return nil, err
638+ }
639+ parts = append(parts, part)
631640 default:
632641 return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
633642 }
internal/proto/message.go+25−7
@@ -172,6 +172,15 @@ type Finish struct {
172172
173173func (Finish) isPart() {}
174174
175+// ShellCommand stores a bang-mode shell command and its output.
176+type ShellCommand struct {
177+ Command string `json:"command"`
178+ Output string `json:"output"`
179+ ExitCode int `json:"exit_code"`
180+}
181+
182+func (ShellCommand) isPart() {}
183+
175184// MarshalJSON implements the [json.Marshaler] interface.
176185func (m Message) MarshalJSON() ([]byte, error) {
177186 parts, err := MarshalParts(m.Parts)
@@ -495,13 +504,14 @@ func (m *Message) AddBinary(mimeType string, data []byte) {
495504type partType string
496505
497506const (
498− reasoningType partType = "reasoning"
499− textType partType = "text"
500− imageURLType partType = "image_url"
501− binaryType partType = "binary"
502− toolCallType partType = "tool_call"
503− toolResultType partType = "tool_result"
504− finishType partType = "finish"
507+ reasoningType partType = "reasoning"
508+ textType partType = "text"
509+ imageURLType partType = "image_url"
510+ binaryType partType = "binary"
511+ toolCallType partType = "tool_call"
512+ toolResultType partType = "tool_result"
513+ finishType partType = "finish"
514+ shellCommandType partType = "shell_command"
505515)
506516
507517type partWrapper struct {
@@ -531,6 +541,8 @@ func MarshalParts(parts []ContentPart) ([]byte, error) {
531541 typ = toolResultType
532542 case Finish:
533543 typ = finishType
544+ case ShellCommand:
545+ typ = shellCommandType
534546 default:
535547 return nil, fmt.Errorf("unknown part type: %T", part)
536548 }
@@ -606,6 +618,12 @@ func UnmarshalParts(data []byte) ([]ContentPart, error) {
606618 return nil, err
607619 }
608620 parts = append(parts, part)
621+ case shellCommandType:
622+ part := ShellCommand{}
623+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
624+ return nil, err
625+ }
626+ parts = append(parts, part)
609627 default:
610628 return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
611629 }
internal/proto/proto.go+13
@@ -135,6 +135,19 @@ type AgentMessage struct {
135135 Attachments []Attachment `json:"attachments,omitempty"`
136136}
137137
138+// ShellCommandRequest represents a request to run a shell command directly.
139+type ShellCommandRequest struct {
140+ SessionID string `json:"session_id"`
141+ Command string `json:"command"`
142+ TermWidth int `json:"term_width,omitempty"`
143+}
144+
145+// ShellCommandResponse represents the result of a direct shell command.
146+type ShellCommandResponse struct {
147+ Output string `json:"output"`
148+ ExitCode int `json:"exit_code"`
149+}
150+
138151// AgentSession represents a session with its busy status.
139152type AgentSession struct {
140153 Session
internal/server/agent_cancel_test.go+3−2
@@ -79,8 +79,9 @@ func (s *runCoordinator) ClearQueue(string) {}
7979func (s *runCoordinator) Summarize(context.Context, string) error {
8080 return nil
8181}
82−func (s *runCoordinator) Model() agent.Model { return agent.Model{} }
83−func (s *runCoordinator) UpdateModels(context.Context) error { return nil }
82+func (s *runCoordinator) Model() agent.Model { return agent.Model{} }
83+func (s *runCoordinator) UpdateModels(context.Context) error { return nil }
84+func (s *runCoordinator) GenerateTitle(context.Context, string, string) {}
8485
8586func (s *runCoordinator) capturedCtx() context.Context {
8687 s.mu.Lock()
internal/server/e2e_agent_test.go+9−8
@@ -212,14 +212,15 @@ func (c *scriptedCoordinator) CancelAll() {
212212 }
213213}
214214
215−func (c *scriptedCoordinator) IsBusy() bool { return false }
216−func (c *scriptedCoordinator) IsSessionBusy(string) bool { return false }
217−func (c *scriptedCoordinator) QueuedPrompts(string) int { return 0 }
218−func (c *scriptedCoordinator) QueuedPromptsList(string) []string { return nil }
219−func (c *scriptedCoordinator) ClearQueue(string) {}
220−func (c *scriptedCoordinator) Summarize(context.Context, string) error { return nil }
221−func (c *scriptedCoordinator) Model() agent.Model { return agent.Model{} }
222−func (c *scriptedCoordinator) UpdateModels(context.Context) error { return nil }
215+func (c *scriptedCoordinator) IsBusy() bool { return false }
216+func (c *scriptedCoordinator) IsSessionBusy(string) bool { return false }
217+func (c *scriptedCoordinator) QueuedPrompts(string) int { return 0 }
218+func (c *scriptedCoordinator) QueuedPromptsList(string) []string { return nil }
219+func (c *scriptedCoordinator) ClearQueue(string) {}
220+func (c *scriptedCoordinator) Summarize(context.Context, string) error { return nil }
221+func (c *scriptedCoordinator) Model() agent.Model { return agent.Model{} }
222+func (c *scriptedCoordinator) UpdateModels(context.Context) error { return nil }
223+func (c *scriptedCoordinator) GenerateTitle(context.Context, string, string) {}
223224
224225// agentE2EHarness extends the SSE harness with a scripted coordinator
225226// wired into the workspace's embedded app.App, so POST /agent drives a
internal/server/events.go+6
@@ -269,6 +269,12 @@ func messageToProto(m message.Message) proto.Message {
269269 msg.Parts = append(msg.Parts, proto.ImageURLContent{URL: v.URL, Detail: v.Detail})
270270 case message.BinaryContent:
271271 msg.Parts = append(msg.Parts, proto.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
272+ case message.ShellCommand:
273+ msg.Parts = append(msg.Parts, proto.ShellCommand{
274+ Command: v.Command,
275+ Output: v.Output,
276+ ExitCode: v.ExitCode,
277+ })
272278 }
273279 }
274280
internal/server/proto.go+34
@@ -915,6 +915,40 @@ func (c *controllerV1) handlePostWorkspaceAgentSessionSummarize(w http.ResponseW
915915 w.WriteHeader(http.StatusOK)
916916}
917917
918+// handlePostWorkspaceAgentSessionShell runs a shell command in the workspace.
919+//
920+// @Summary Run shell command
921+// @Tags agent
922+// @Accept json
923+// @Produce json
924+// @Param id path string true "Workspace ID"
925+// @Param sid path string true "Session ID"
926+// @Param request body proto.ShellCommandRequest true "Shell command"
927+// @Success 200 {object} proto.ShellCommandResponse
928+// @Failure 400 {object} proto.Error
929+// @Failure 404 {object} proto.Error
930+// @Failure 500 {object} proto.Error
931+// @Router /workspaces/{id}/agent/sessions/{sid}/shell [post]
932+func (c *controllerV1) handlePostWorkspaceAgentSessionShell(w http.ResponseWriter, r *http.Request) {
933+ id := r.PathValue("id")
934+ sid := r.PathValue("sid")
935+
936+ var req proto.ShellCommandRequest
937+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
938+ c.server.logError(r, "Failed to decode request", "error", err)
939+ jsonError(w, http.StatusBadRequest, "failed to decode request")
940+ return
941+ }
942+ req.SessionID = sid
943+
944+ resp, err := c.backend.RunShellCommand(r.Context(), id, req)
945+ if err != nil {
946+ c.handleError(w, r, err)
947+ return
948+ }
949+ jsonEncode(w, resp)
950+}
951+
918952// handleGetWorkspaceAgentSessionPromptList returns the list of queued prompts.
919953//
920954// @Summary List queued prompts
internal/server/server.go+1
@@ -189,6 +189,7 @@ func (s *Server) installHandler() {
189189 mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/list", c.handleGetWorkspaceAgentSessionPromptList)
190190 mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/prompts/clear", c.handlePostWorkspaceAgentSessionPromptClear)
191191 mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/summarize", c.handlePostWorkspaceAgentSessionSummarize)
192+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/shell", c.handlePostWorkspaceAgentSessionShell)
192193 mux.HandleFunc("GET /v1/workspaces/{id}/agent/default-small-model", c.handleGetWorkspaceAgentDefaultSmallModel)
193194 mux.HandleFunc("POST /v1/workspaces/{id}/config/set", c.handlePostWorkspaceConfigSet)
194195 mux.HandleFunc("POST /v1/workspaces/{id}/config/remove", c.handlePostWorkspaceConfigRemove)
internal/server/sessions_isbusy_test.go+3−2
@@ -50,8 +50,9 @@ func (s *stubCoordinator) ClearQueue(string) {}
5050func (s *stubCoordinator) Summarize(context.Context, string) error {
5151 return nil
5252}
53−func (s *stubCoordinator) Model() agent.Model { return agent.Model{} }
54−func (s *stubCoordinator) UpdateModels(context.Context) error { return nil }
53+func (s *stubCoordinator) Model() agent.Model { return agent.Model{} }
54+func (s *stubCoordinator) UpdateModels(context.Context) error { return nil }
55+func (s *stubCoordinator) GenerateTitle(context.Context, string, string) {}
5556
5657// stubSessions is a minimal session.Service that returns a fixed list
5758// (and supports Get by ID). All other methods return zero values; the
internal/session/session.go+23−4
@@ -223,22 +223,30 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) {
223223// UpdateTitleAndUsage updates only the title and usage fields atomically.
224224// This is safer than fetching, modifying, and saving the entire session.
225225func (s *service) UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error {
226− return s.q.UpdateSessionTitleAndUsage(ctx, db.UpdateSessionTitleAndUsageParams{
226+ if err := s.q.UpdateSessionTitleAndUsage(ctx, db.UpdateSessionTitleAndUsageParams{
227227 ID: sessionID,
228228 Title: title,
229229 PromptTokens: promptTokens,
230230 CompletionTokens: completionTokens,
231231 Cost: cost,
232− })
232+ }); err != nil {
233+ return err
234+ }
235+ s.publishSessionUpdate(ctx, sessionID)
236+ return nil
233237}
234238
235239// Rename updates only the title of a session without touching updated_at or
236240// usage fields.
237241func (s *service) Rename(ctx context.Context, id string, title string) error {
238− return s.q.RenameSession(ctx, db.RenameSessionParams{
242+ if err := s.q.RenameSession(ctx, db.RenameSessionParams{
239243 ID: id,
240244 Title: title,
241− })
245+ }); err != nil {
246+ return err
247+ }
248+ s.publishSessionUpdate(ctx, id)
249+ return nil
242250}
243251
244252func (s *service) List(ctx context.Context) ([]Session, error) {
@@ -254,6 +262,17 @@ func (s *service) List(ctx context.Context) ([]Session, error) {
254262 return sessions, nil
255263}
256264
265+// publishSessionUpdate re-fetches a session and publishes an UpdatedEvent so
266+// that UI subscribers reflect title or usage changes.
267+func (s *service) publishSessionUpdate(ctx context.Context, sessionID string) {
268+ session, err := s.Get(ctx, sessionID)
269+ if err != nil {
270+ slog.Error("Failed to re-fetch session for event publish", "error", err, "sessionID", sessionID)
271+ return
272+ }
273+ s.Publish(pubsub.UpdatedEvent, session)
274+}
275+
257276func (s *service) applyEstimatedUsageState(session *Session) {
258277 s.estimatedUsageMu.RLock()
259278 session.EstimatedUsage = s.estimatedUsage[session.ID]
internal/shell/run.go+94
@@ -1,9 +1,12 @@
11package shell
22
33import (
4+ "bytes"
45 "context"
56 "fmt"
67 "io"
8+ "log/slog"
9+ "os"
710 "slices"
811 "strings"
912
@@ -41,6 +44,9 @@ type RunOptions struct {
4144 // BlockFuncs is an optional list of deny-list matchers applied before
4245 // each command reaches the exec layer. nil disables blocking entirely.
4346 BlockFuncs []BlockFunc
47+ // TermWidth is the terminal width in columns for PTY execution.
48+ // Zero uses a default of 200.
49+ TermWidth int
4450}
4551
4652// Run parses and executes a shell command using the same mvdan.cc/sh
@@ -86,6 +92,94 @@ func Run(ctx context.Context, opts RunOptions) (err error) {
8692 return runner.Run(ctx, line)
8793}
8894
95+// CaptureResult holds the combined output and exit code from a
96+// captured shell execution.
97+type CaptureResult struct {
98+ Output string
99+ ExitCode int
100+}
101+
102+// PersistFunc is a callback that persists a shell command result.
103+// Used by RunAndPersist to decouple execution from storage.
104+type PersistFunc func(command, output string, exitCode int) error
105+
106+// RunAndPersist executes a shell command via PTY and optionally
107+// persists the result through the provided callback. This unifies
108+// the run-and-save pattern used by both AppWorkspace and Backend.
109+func RunAndPersist(ctx context.Context, opts RunOptions, persist PersistFunc) (CaptureResult, error) {
110+ result, err := RunAndCapturePTY(ctx, opts)
111+ if err != nil {
112+ return CaptureResult{}, err
113+ }
114+
115+ if persist != nil {
116+ if persistErr := persist(opts.Command, result.Output, result.ExitCode); persistErr != nil {
117+ slog.Error("Failed to persist shell command output", "error", persistErr, "command", opts.Command)
118+ }
119+ }
120+
121+ return result, nil
122+}
123+
124+// RunAndCapture executes a shell command and returns its combined
125+// stdout/stderr output along with the exit code. It inherits the
126+// current process environment when opts.Env is nil.
127+func RunAndCapture(ctx context.Context, opts RunOptions) (CaptureResult, error) {
128+ if opts.Env == nil {
129+ opts.Env = os.Environ()
130+ }
131+
132+ var stdout, stderr bytes.Buffer
133+ opts.Stdout = &stdout
134+ opts.Stderr = &stderr
135+
136+ runErr := Run(ctx, opts)
137+
138+ exitCode := 0
139+ if runErr != nil {
140+ exitCode = ExitCode(runErr)
141+ }
142+
143+ output := stdout.String()
144+ if stderr.Len() > 0 {
145+ if output != "" {
146+ output += "\n"
147+ }
148+ output += stderr.String()
149+ }
150+
151+ return CaptureResult{
152+ Output: output,
153+ ExitCode: exitCode,
154+ }, nil
155+}
156+
157+// ptyColorEnvVars force color output for programs running inside a
158+// PTY. These are only applied in RunAndCapturePTY, not in the plain
159+// Run/RunAndCapture paths where ANSI codes would be noise.
160+var ptyColorEnvVars = []string{
161+ "COLORTERM=truecolor",
162+ "CLICOLOR_FORCE=1",
163+ "FORCE_COLOR=1",
164+}
165+
166+// RunAndCapturePTY executes a shell command through the mvdan.cc/sh
167+// interpreter with color-forcing environment variables set. Programs
168+// that respect FORCE_COLOR or CLICOLOR_FORCE (git, cargo, npm, eza,
169+// bat, ripgrep, etc.) will emit ANSI color sequences even without a
170+// real PTY. This approach is fully cross-platform — no /bin/sh or
171+// unix PTY required.
172+//
173+// The name is preserved for API compatibility; the PTY path has been
174+// replaced by the portable interpreter + env-var approach.
175+func RunAndCapturePTY(ctx context.Context, opts RunOptions) (CaptureResult, error) {
176+ if opts.Env == nil {
177+ opts.Env = os.Environ()
178+ }
179+ opts.Env = append(opts.Env, ptyColorEnvVars...)
180+ return RunAndCapture(ctx, opts)
181+}
182+
89183// newRunner constructs an [interp.Runner] configured with the standard
90184// Crush handler stack. Shared by the stateless [Run] entrypoint and the
91185// stateful [Shell] so the two surfaces cannot drift.
internal/ui/chat/messages.go+10
@@ -367,6 +367,16 @@ func cappedMessageWidth(availableWidth int) int {
367367func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
368368 switch msg.Role {
369369 case message.User:
370+ // Reconstruct shell command items from ShellCommand parts.
371+ var items []MessageItem
372+ for _, part := range msg.Parts {
373+ if sc, ok := part.(message.ShellCommand); ok {
374+ items = append(items, NewShellItem(sty, sc.Command, sc.Output, sc.ExitCode))
375+ }
376+ }
377+ if len(items) > 0 {
378+ return items
379+ }
370380 r := attachments.NewRenderer(
371381 sty.Attachments.Normal,
372382 sty.Attachments.Deleting,
internal/ui/chat/shell.gonew file+198
@@ -0,0 +1,198 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "sync/atomic"
7+
8+ tea "charm.land/bubbletea/v2"
9+ "charm.land/lipgloss/v2"
10+
11+ "github.com/charmbracelet/crush/internal/ui/common"
12+ "github.com/charmbracelet/crush/internal/ui/list"
13+ "github.com/charmbracelet/crush/internal/ui/styles"
14+ "github.com/charmbracelet/x/ansi"
15+)
16+
17+// shellSeq provides unique IDs for ShellItems even when the same
18+// command is run multiple times.
19+var shellSeq atomic.Int64
20+
21+const (
22+ shellMaxCollapsedLines = 10
23+ shellHScrollStep = 5
24+)
25+
26+// ShellItem renders a bang-mode shell command result in the chat with a
27+// vertical bar on the left and plain-text output.
28+type ShellItem struct {
29+ *list.Versioned
30+ *highlightableMessageItem
31+ *cachedMessageItem
32+ *focusableMessageItem
33+
34+ id string
35+ command string
36+ output string
37+ exitCode int
38+ expandedContent bool
39+ xOffset int
40+ maxLineWidth int // computed during render, used to clamp xOffset
41+ sty *styles.Styles
42+}
43+
44+var (
45+ _ Expandable = (*ShellItem)(nil)
46+ _ list.Highlightable = (*ShellItem)(nil)
47+ _ KeyEventHandler = (*ShellItem)(nil)
48+)
49+
50+// NewShellItem creates a new ShellItem for displaying bang-mode results.
51+func NewShellItem(sty *styles.Styles, command, output string, exitCode int) MessageItem {
52+ v := list.NewVersioned()
53+ return &ShellItem{
54+ Versioned: v,
55+ highlightableMessageItem: defaultHighlighter(sty, v),
56+ cachedMessageItem: &cachedMessageItem{},
57+ focusableMessageItem: newFocusableMessageItem(v),
58+ id: fmt.Sprintf("shell-%d-%s", shellSeq.Add(1), command),
59+ command: command,
60+ output: output,
61+ exitCode: exitCode,
62+ sty: sty,
63+ }
64+}
65+
66+func (s *ShellItem) ID() string { return s.id }
67+func (s *ShellItem) FilterValue() string { return s.command }
68+func (s *ShellItem) Finished() bool { return true }
69+
70+func (s *ShellItem) Render(width int) string {
71+ innerWidth := max(0, width-MessageLeftPaddingTotal)
72+ content := s.RawRender(innerWidth)
73+
74+ var prefix string
75+ if s.focused {
76+ prefix = s.sty.Messages.ShellBarFocused.Render()
77+ } else {
78+ prefix = s.sty.Messages.ShellBarBlurred.Render()
79+ }
80+ lines := strings.Split(content, "\n")
81+ for i, ln := range lines {
82+ lines[i] = prefix + ln
83+ }
84+ out := strings.Join(lines, "\n")
85+
86+ return s.renderHighlighted(out, width, lipgloss.Height(out))
87+}
88+
89+// HandleMouseClick implements MouseClickable so clicks select this item.
90+func (s *ShellItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
91+ return btn == ansi.MouseLeft
92+}
93+
94+// HandleKeyEvent implements KeyEventHandler for horizontal scrolling.
95+func (s *ShellItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) {
96+ switch key.String() {
97+ case "shift+left", "H":
98+ if s.xOffset > 0 {
99+ s.xOffset = max(0, s.xOffset-shellHScrollStep)
100+ s.Bump()
101+ return true, nil
102+ }
103+ case "shift+right", "L":
104+ s.xOffset = min(s.xOffset+shellHScrollStep, max(s.maxLineWidth, s.xOffset))
105+ s.Bump()
106+ return true, nil
107+ }
108+ return false, nil
109+}
110+
111+// ScrollHorizontal adjusts the horizontal scroll offset by delta columns.
112+func (s *ShellItem) ScrollHorizontal(delta int) {
113+ s.xOffset = max(0, s.xOffset+delta)
114+ if s.maxLineWidth > 0 {
115+ s.xOffset = min(s.xOffset, s.maxLineWidth)
116+ }
117+ s.Bump()
118+}
119+
120+// ToggleExpanded toggles the expanded state and invalidates the cache.
121+func (s *ShellItem) ToggleExpanded() bool {
122+ s.expandedContent = !s.expandedContent
123+ s.Bump()
124+ return s.expandedContent
125+}
126+
127+func (s *ShellItem) RawRender(width int) string {
128+ cappedWidth := cappedMessageWidth(width)
129+
130+ cmd := strings.ReplaceAll(s.command, "\n", " ")
131+ cmd = strings.ReplaceAll(cmd, "\t", " ")
132+
133+ var prompt string
134+ if s.focused {
135+ prompt = s.sty.Messages.ShellPrompt.Render("$")
136+ } else {
137+ prompt = s.sty.Messages.ShellPromptBlurred.Render("$")
138+ }
139+
140+ highlighted, err := common.SyntaxHighlight(s.sty, cmd, "cmd.sh", s.sty.Background)
141+ if err != nil || highlighted == "" {
142+ highlighted = s.sty.Messages.ShellCommand.Render(cmd)
143+ }
144+ header := prompt + " " + highlighted
145+
146+ if s.exitCode != 0 {
147+ header += " " + s.sty.Messages.ShellExitCode.Render(fmt.Sprintf("(exit %d)", s.exitCode))
148+ }
149+
150+ if s.output == "" {
151+ return header
152+ }
153+
154+ output := strings.TrimRight(s.output, "\n")
155+ lines := strings.Split(output, "\n")
156+
157+ maxLines := shellMaxCollapsedLines
158+ if s.expandedContent {
159+ maxLines = len(lines)
160+ }
161+
162+ displayLines := lines
163+ if len(lines) > maxLines {
164+ displayLines = lines[:maxLines]
165+ }
166+
167+ // Compute max line width for scroll clamping.
168+ maxW := 0
169+ for _, ln := range displayLines {
170+ w := ansi.StringWidth(ln)
171+ if w > maxW {
172+ maxW = w
173+ }
174+ }
175+ s.maxLineWidth = max(0, maxW-cappedWidth)
176+
177+ var body strings.Builder
178+ for _, ln := range displayLines {
179+ scrolled := ansi.GraphemeWidth.Cut(ln, s.xOffset, len(ln))
180+ truncated := ansi.Truncate(scrolled, cappedWidth, "…")
181+ if s.xOffset > 0 && strings.TrimSpace(truncated) != "" {
182+ truncated = "…" + truncated
183+ }
184+ body.WriteString(s.sty.Messages.ShellOutput.Render(truncated))
185+ body.WriteString("\n")
186+ }
187+
188+ if len(lines) > maxLines && !s.expandedContent {
189+ body.WriteString(s.sty.Messages.ShellTruncation.Render(
190+ fmt.Sprintf("… %d more lines", len(lines)-maxLines),
191+ ))
192+ } else {
193+ result := body.String()
194+ return header + "\n" + strings.TrimRight(result, "\n")
195+ }
196+
197+ return header + "\n" + strings.TrimRight(body.String(), "\n")
198+}
internal/ui/model/chat.go+15
@@ -614,6 +614,21 @@ func (m *Chat) ToggleExpandedSelectedItem() {
614614 }
615615}
616616
617+// IsSelectedShellItem returns true if the currently selected item is a
618+// ShellItem (bang-mode result).
619+func (m *Chat) IsSelectedShellItem() bool {
620+ _, ok := m.list.SelectedItem().(*chat.ShellItem)
621+ return ok
622+}
623+
624+// ScrollSelectedShellHorizontal scrolls the selected ShellItem horizontally
625+// by delta columns. No-op if the selected item is not a ShellItem.
626+func (m *Chat) ScrollSelectedShellHorizontal(delta int) {
627+ if shell, ok := m.list.SelectedItem().(*chat.ShellItem); ok {
628+ shell.ScrollHorizontal(delta)
629+ }
630+}
631+
617632// HandleKeyMsg handles key events for the chat component.
618633func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
619634 if m.list.Focused() {
internal/ui/model/header.go+1−1
@@ -157,7 +157,7 @@ func renderHeaderDetails(
157157 }
158158
159159 if com.IsHyper() && hyperCredits != nil {
160− hc := t.Header.Hypercredit.Render(styles.HypercreditIcon) + " " + t.Header.Percentage.Render(common.FormatCredits(*hyperCredits))
160+ hc := t.Header.HypercreditIcon.Render(styles.HypercreditIcon) + " " + t.Header.Percentage.Render(common.FormatCredits(*hyperCredits))
161161 parts = append(parts, hc)
162162 }
163163
internal/ui/model/keys.go+10
@@ -47,6 +47,8 @@ type KeyMap struct {
4747 Copy key.Binding
4848 ClearHighlight key.Binding
4949 Expand key.Binding
50+ ScrollLeft key.Binding
51+ ScrollRight key.Binding
5052 }
5153
5254 Initialize struct {
@@ -250,6 +252,14 @@ func DefaultKeyMap() KeyMap {
250252 key.WithKeys("space"),
251253 key.WithHelp("space", "expand/collapse"),
252254 )
255+ km.Chat.ScrollLeft = key.NewBinding(
256+ key.WithKeys("shift+left", "H"),
257+ key.WithHelp("shift+←/H", "scroll left"),
258+ )
259+ km.Chat.ScrollRight = key.NewBinding(
260+ key.WithKeys("shift+right", "L"),
261+ key.WithHelp("shift+→/L", "scroll right"),
262+ )
253263 km.Initialize.Yes = key.NewBinding(
254264 key.WithKeys("y", "Y"),
255265 key.WithHelp("y", "yes"),
internal/ui/model/ui.go+128−3
@@ -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.
internal/ui/styles/quickstyle.go+21−3
@@ -578,7 +578,7 @@ func quickStyle(o quickStyleOpts) Styles {
578578 s.Header.Charm = base.Foreground(o.secondary)
579579 s.Header.Diagonals = base.Foreground(o.primary)
580580 s.Header.Percentage = muted
581− s.Header.Hypercredit = base.Foreground(charmtone.Dolly)
581+ s.Header.HypercreditIcon = base.Foreground(o.secondary)
582582 s.Header.Keystroke = muted
583583 s.Header.KeystrokeTip = subtle
584584 s.Header.WorkingDir = muted
@@ -695,10 +695,14 @@ func quickStyle(o quickStyleOpts) Styles {
695695 // Editor
696696 s.Editor.PromptNormalFocused = lipgloss.NewStyle().Foreground(o.successMostSubtle).SetString("::: ")
697697 s.Editor.PromptNormalBlurred = s.Editor.PromptNormalFocused.Foreground(o.fgMoreSubtle)
698− s.Editor.PromptYoloIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(o.fgMostSubtle).Background(o.busy).Bold(true).SetString(" ! ")
698+ s.Editor.PromptYoloIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(o.fgMostSubtle).Background(o.busy).Bold(true).SetString(" Y ")
699699 s.Editor.PromptYoloIconBlurred = s.Editor.PromptYoloIconFocused.Foreground(o.bgBase).Background(o.fgMoreSubtle)
700700 s.Editor.PromptYoloDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(o.warningSubtle).SetString(":::")
701701 s.Editor.PromptYoloDotsBlurred = s.Editor.PromptYoloDotsFocused.Foreground(o.fgMoreSubtle)
702+ s.Editor.PromptBangIconFocused = lipgloss.NewStyle().MarginRight(1).Foreground(o.onPrimary).Background(o.primary).Bold(true).SetString(" ! ")
703+ s.Editor.PromptBangIconBlurred = s.Editor.PromptBangIconFocused.Foreground(o.bgBase).Background(o.fgMoreSubtle)
704+ s.Editor.PromptBangDotsFocused = lipgloss.NewStyle().MarginRight(1).Foreground(o.primary).SetString(":::")
705+ s.Editor.PromptBangDotsBlurred = s.Editor.PromptBangDotsFocused.Foreground(o.fgMoreSubtle)
702706
703707 s.Radio.On = lipgloss.NewStyle().Foreground(o.fgSubtle).SetString(RadioOn)
704708 s.Radio.Off = lipgloss.NewStyle().Foreground(o.fgSubtle).SetString(RadioOff)
@@ -769,7 +773,7 @@ func quickStyle(o quickStyleOpts) Styles {
769773 s.ModelInfo.TokenPercentage = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
770774 s.ModelInfo.EstimatedUsagePrefix = s.ModelInfo.TokenPercentage
771775 s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
772− s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(charmtone.Dolly)
776+ s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(o.secondary)
773777 s.ModelInfo.HypercreditText = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
774778
775779 // ResourceGroup
@@ -803,6 +807,20 @@ func quickStyle(o quickStyleOpts) Styles {
803807 s.Messages.ToolCallBlurred = muted.PaddingLeft(2)
804808 // No padding or border for compact tool calls within messages
805809 s.Messages.ToolCallCompact = muted
810+
811+ // Shell (bang mode) item styles.
812+ s.Messages.ShellBarFocused = lipgloss.NewStyle().PaddingLeft(1).
813+ BorderStyle(messageFocussedBorder).BorderLeft(true).
814+ BorderForeground(o.primary)
815+ s.Messages.ShellBarBlurred = lipgloss.NewStyle().PaddingLeft(1).BorderLeft(true).
816+ BorderForeground(o.bgMostVisible).BorderStyle(lipgloss.NormalBorder())
817+ s.Messages.ShellPrompt = base.Foreground(o.primary).Bold(true)
818+ s.Messages.ShellPromptBlurred = base.Foreground(o.fgMoreSubtle)
819+ s.Messages.ShellCommand = base.Foreground(o.fgBase)
820+ s.Messages.ShellOutput = subtle
821+ s.Messages.ShellExitCode = lipgloss.NewStyle().Foreground(o.destructive)
822+ s.Messages.ShellTruncation = muted
823+
806824 s.Messages.SectionHeader = base.PaddingLeft(2)
807825 s.Messages.AssistantInfoIcon = subtle
808826 s.Messages.AssistantInfoModel = muted
internal/ui/styles/styles.go+18−2
@@ -65,7 +65,7 @@ type Styles struct {
6565 Charm lipgloss.Style // Style for "Charm™" label
6666 Diagonals lipgloss.Style // Style for diagonal separators (╱)
6767 Percentage lipgloss.Style // Style for context percentage
68− Hypercredit lipgloss.Style // Style for Hypercredit count (◆ N)
68+ HypercreditIcon lipgloss.Style // Style for Hypercredit count (◆ N)
6969 Keystroke lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d")
7070 KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close")
7171 WorkingDir lipgloss.Style // Style for current working directory
@@ -123,6 +123,12 @@ type Styles struct {
123123 PromptYoloIconBlurred lipgloss.Style
124124 PromptYoloDotsFocused lipgloss.Style
125125 PromptYoloDotsBlurred lipgloss.Style
126+
127+ // Bang mode prompt (" ! " icon + ":::" dots, Turtle color).
128+ PromptBangIconFocused lipgloss.Style
129+ PromptBangIconBlurred lipgloss.Style
130+ PromptBangDotsFocused lipgloss.Style
131+ PromptBangDotsBlurred lipgloss.Style
126132 }
127133
128134 // Radio
@@ -241,7 +247,17 @@ type Styles struct {
241247 ToolCallFocused lipgloss.Style
242248 ToolCallCompact lipgloss.Style
243249 ToolCallBlurred lipgloss.Style
244− SectionHeader lipgloss.Style
250+
251+ // Shell (bang mode) item styles.
252+ ShellBarFocused lipgloss.Style // Left vertical bar when focused.
253+ ShellBarBlurred lipgloss.Style // Left vertical bar when blurred.
254+ ShellPrompt lipgloss.Style // "$" prompt symbol (focused).
255+ ShellPromptBlurred lipgloss.Style // "$" prompt symbol (blurred).
256+ ShellCommand lipgloss.Style // Command text (syntax-highlighted).
257+ ShellOutput lipgloss.Style // Plain output text.
258+ ShellExitCode lipgloss.Style // Non-zero exit code indicator.
259+ ShellTruncation lipgloss.Style // "N more lines" hint.
260+ SectionHeader lipgloss.Style
245261
246262 // Thinking section styles
247263 ThinkingBox lipgloss.Style // Background for thinking content
internal/ui/styles/themes.go+26−34
@@ -1,6 +1,8 @@
11package styles
22
3−import "github.com/charmbracelet/x/exp/charmtone"
3+import (
4+ "github.com/charmbracelet/x/exp/charmtone"
5+)
46
57// ThemeForProvider returns the Styles associated with the given provider
68// ID. Unknown or empty provider IDs yield the default Charmtone Pantera
@@ -17,7 +19,7 @@ func ThemeForProvider(providerID string) Styles {
1719// CharmtonePantera returns the Charmtone dark theme. It's the default style
1820// for the UI.
1921func CharmtonePantera() Styles {
20− return quickStyle(quickStyleOpts{
22+ s := quickStyle(quickStyleOpts{
2123 primary: charmtone.Charple,
2224 secondary: charmtone.Dolly,
2325 accent: charmtone.Bok,
@@ -50,40 +52,30 @@ func CharmtonePantera() Styles {
5052 successMoreSubtle: charmtone.Bok,
5153 successMostSubtle: charmtone.Guac,
5254 })
53−}
5455
55−// HypercrushObsidiana returns the Hypercrush dark theme.
56−func HypercrushObsidiana() Styles {
57− return quickStyle(quickStyleOpts{
58− primary: charmtone.Charple,
59− secondary: charmtone.Dolly,
60− accent: charmtone.Bok,
56+ // Bang ! prompt overrides - use Salt/Hazy/Larple colors.
57+ s.Editor.PromptBangIconFocused = s.Editor.PromptBangIconFocused.
58+ Foreground(charmtone.Salt).
59+ Background(charmtone.Hazy)
60+ s.Editor.PromptBangDotsFocused = s.Editor.PromptBangDotsFocused.
61+ Foreground(charmtone.Hazy)
62+ s.Editor.PromptBangDotsBlurred = s.Editor.PromptBangDotsBlurred.
63+ Foreground(charmtone.Larple)
6164
62− fgBase: charmtone.Sash,
63− fgMoreSubtle: charmtone.Squid,
64− fgSubtle: charmtone.Smoke,
65− fgMostSubtle: charmtone.Oyster,
66−
67− onPrimary: charmtone.Butter,
68−
69− bgBase: charmtone.Pepper,
70− bgLeastVisible: charmtone.BBQ,
71− bgLessVisible: charmtone.Char,
72− bgMostVisible: charmtone.Iron,
65+ // Shell bar/prompt overrides - use Charple/Iron/Hazy colors.
66+ s.Messages.ShellBarFocused = s.Messages.ShellBarFocused.
67+ BorderForeground(charmtone.Charple)
68+ s.Messages.ShellBarBlurred = s.Messages.ShellBarBlurred.
69+ BorderForeground(charmtone.Iron)
70+ s.Messages.ShellPrompt = s.Messages.ShellPrompt.
71+ Foreground(charmtone.Hazy)
72+ s.Messages.ShellPromptBlurred = s.Messages.ShellPromptBlurred.
73+ Foreground(charmtone.Hazy)
7374
74− separator: charmtone.Char,
75+ return s
76+}
7577
76− destructive: charmtone.Coral,
77− error: charmtone.Sriracha,
78− warningSubtle: charmtone.Zest,
79− warning: charmtone.Mustard,
80− denied: charmtone.Tang,
81− busy: charmtone.Citron,
82− info: charmtone.Malibu,
83− infoMoreSubtle: charmtone.Sardine,
84− infoMostSubtle: charmtone.Damson,
85− success: charmtone.Julep,
86− successMoreSubtle: charmtone.Bok,
87− successMostSubtle: charmtone.Guac,
88− })
78+// HypercrushObsidiana returns the Hypercrush dark theme.
79+func HypercrushObsidiana() Styles {
80+ return CharmtonePantera()
8981}
internal/workspace/app_workspace.go+33
@@ -17,7 +17,9 @@ import (
1717 "github.com/charmbracelet/crush/internal/message"
1818 "github.com/charmbracelet/crush/internal/oauth"
1919 "github.com/charmbracelet/crush/internal/permission"
20+ "github.com/charmbracelet/crush/internal/proto"
2021 "github.com/charmbracelet/crush/internal/session"
22+ "github.com/charmbracelet/crush/internal/shell"
2123 "github.com/charmbracelet/crush/internal/skills"
2224)
2325
@@ -105,6 +107,37 @@ func (w *AppWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, a
105107 return err
106108}
107109
110+func (w *AppWorkspace) AgentRunShellCommand(ctx context.Context, sessionID, command string, termWidth int) (proto.ShellCommandResponse, error) {
111+ var persist shell.PersistFunc
112+ if sessionID != "" {
113+ persist = func(cmd, output string, exitCode int) error {
114+ _, err := w.app.Messages.Create(ctx, sessionID, message.CreateMessageParams{
115+ Role: message.User,
116+ Parts: []message.ContentPart{message.ShellCommand{
117+ Command: cmd,
118+ Output: output,
119+ ExitCode: exitCode,
120+ }},
121+ })
122+ return err
123+ }
124+ }
125+
126+ result, err := shell.RunAndPersist(ctx, shell.RunOptions{
127+ Command: command,
128+ Cwd: w.store.WorkingDir(),
129+ TermWidth: termWidth,
130+ }, persist)
131+ if err != nil {
132+ return proto.ShellCommandResponse{}, err
133+ }
134+
135+ return proto.ShellCommandResponse{
136+ Output: result.Output,
137+ ExitCode: result.ExitCode,
138+ }, nil
139+}
140+
108141func (w *AppWorkspace) AgentCancel(sessionID string) {
109142 if w.app.AgentCoordinator != nil {
110143 w.app.AgentCoordinator.Cancel(sessionID)
internal/workspace/client_workspace.go+10
@@ -186,6 +186,10 @@ 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) (proto.ShellCommandResponse, error) {
190+ return w.client.RunShellCommand(ctx, w.workspaceID(), sessionID, command, termWidth)
191+}
192+
189193func (w *ClientWorkspace) AgentCancel(sessionID string) {
190194 _ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
191195}
@@ -865,6 +869,12 @@ func protoToMessage(m proto.Message) message.Message {
865869 msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
866870 case proto.BinaryContent:
867871 msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
872+ case proto.ShellCommand:
873+ msg.Parts = append(msg.Parts, message.ShellCommand{
874+ Command: v.Command,
875+ Output: v.Output,
876+ ExitCode: v.ExitCode,
877+ })
868878 }
869879 }
870880
internal/workspace/workspace.go+2
@@ -17,6 +17,7 @@ import (
1717 "github.com/charmbracelet/crush/internal/message"
1818 "github.com/charmbracelet/crush/internal/oauth"
1919 "github.com/charmbracelet/crush/internal/permission"
20+ "github.com/charmbracelet/crush/internal/proto"
2021 "github.com/charmbracelet/crush/internal/session"
2122 "github.com/charmbracelet/crush/internal/skills"
2223)
@@ -82,6 +83,7 @@ type Workspace interface {
8283
8384 // Agent
8485 AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error
86+ AgentRunShellCommand(ctx context.Context, sessionID, command string, termWidth int) (proto.ShellCommandResponse, error)
8587 AgentCancel(sessionID string)
8688 AgentIsBusy() bool
8789 AgentIsSessionBusy(sessionID string) bool