on main help sign in
Merge pull request #1676 from charmbracelet/chat-bash-tools
0af0ebeAyman Bagabas
internal/ui/AGENTS.md+52−10
@@ -1,17 +1,59 @@
1# UI Development Instructions
1# UI Development Instructions
2
2
3−## General guideline
3+## General Guidelines
4−- Never use commands to send messages when you can directly mutate children or state
4+- Never use commands to send messages when you can directly mutate children or state.
5−- Keep things simple do not overcomplicated
5+- Keep things simple; do not overcomplicate.
6−- Create files if needed to separate logic do not nest models
6+- Create files if needed to separate logic; do not nest models.
7
7
8−## Big model
8+## Architecture
9−Keep most of the logic and state in the main model `internal/ui/model/ui.go`.
10
9
10+### Main Model (`model/ui.go`)
11+Keep most of the logic and state in the main model. This is where:
12+- Message routing happens
13+- Focus and UI state is managed
14+- Layout calculations are performed
15+- Dialogs are orchestrated
11
16
12−## When working on components
17+### Components Should Be Dumb
13−Whenever you work on components make them dumb they should not handle bubble tea messages they should have methods.
18+Components should not handle bubbletea messages directly. Instead:
19+- Expose methods for state changes
20+- Return `tea.Cmd` from methods when side effects are needed
21+- Handle their own rendering via `Render(width int) string`
14
22
15−## When adding logic that has to do with the chat
23+### Chat Logic (`model/chat.go`)
16−Most of the logic with the chat should be in the chat component `internal/ui/model/chat.go`, keep individual items dumb and handle logic in this component.
24+Most chat-related logic belongs here. Individual chat items in `chat/` should be simple renderers that cache their output and invalidate when data changes (see `cachedMessageItem` in `chat/messages.go`).
17
25
26+## Key Patterns
27+
28+### Composition Over Inheritance
29+Use struct embedding for shared behaviors. See `chat/messages.go` for examples of reusable embedded structs for highlighting, caching, and focus.
30+
31+### Interfaces
32+- List item interfaces are in `list/item.go`
33+- Chat message interfaces are in `chat/messages.go`
34+- Dialog interface is in `dialog/dialog.go`
35+
36+### Styling
37+- All styles are defined in `styles/styles.go`
38+- Access styles via `*common.Common` passed to components
39+- Use semantic color fields rather than hardcoded colors
40+
41+### Dialogs
42+- Implement the dialog interface in `dialog/dialog.go`
43+- Return message types from `Update()` to signal actions to the main model
44+- Use the overlay system for managing dialog lifecycle
45+
46+## File Organization
47+- `model/` - Main UI model and major components (chat, sidebar, etc.)
48+- `chat/` - Chat message item types and renderers
49+- `dialog/` - Dialog implementations
50+- `list/` - Generic list component with lazy rendering
51+- `common/` - Shared utilities and the Common struct
52+- `styles/` - All style definitions
53+- `anim/` - Animation system
54+- `logo/` - Logo rendering
55+
56+## Common Gotchas
57+- Always account for padding/borders in width calculations
58+- Use `tea.Batch()` when returning multiple commands
59+- Pass `*common.Common` to components that need styles or app access
internal/ui/chat/bash.go+181−41
@@ -1,14 +1,22 @@
1package chat
1package chat
2
2
3import (
3import (
4+ "cmp"
4 "encoding/json"
5 "encoding/json"
6+ "fmt"
5 "strings"
7 "strings"
6
8
9+ "charm.land/lipgloss/v2"
7 "github.com/charmbracelet/crush/internal/agent/tools"
10 "github.com/charmbracelet/crush/internal/agent/tools"
8 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/message"
9 "github.com/charmbracelet/crush/internal/ui/styles"
12 "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/x/ansi"
10)
14)
11
15
16+// -----------------------------------------------------------------------------
17+// Bash Tool
18+// -----------------------------------------------------------------------------
19+
12// BashToolMessageItem is a message item that represents a bash tool call.
20// BashToolMessageItem is a message item that represents a bash tool call.
13type BashToolMessageItem struct {
21type BashToolMessageItem struct {
14 *baseToolMessageItem
22 *baseToolMessageItem
@@ -23,86 +31,218 @@ func NewBashToolMessageItem(
23 result *message.ToolResult,
31 result *message.ToolResult,
24 canceled bool,
32 canceled bool,
25) ToolMessageItem {
33) ToolMessageItem {
26− return newBaseToolMessageItem(
34+ return newBaseToolMessageItem(sty, toolCall, result, &BashToolRenderContext{}, canceled)
27− sty,
28− toolCall,
29− result,
30− &BashToolRenderContext{},
31− canceled,
32− )
33}
35}
34
36
35−// BashToolRenderContext holds context for rendering bash tool messages.
37+// BashToolRenderContext renders bash tool messages.
36−//
37−// It implements the [ToolRenderer] interface.
38type BashToolRenderContext struct{}
38type BashToolRenderContext struct{}
39
39
40// RenderTool implements the [ToolRenderer] interface.
40// RenderTool implements the [ToolRenderer] interface.
41func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
41func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
42 cappedWidth := cappedMessageWidth(width)
42 cappedWidth := cappedMessageWidth(width)
43− const toolName = "Bash"
44 if !opts.ToolCall.Finished && !opts.Canceled {
43 if !opts.ToolCall.Finished && !opts.Canceled {
45− return pendingTool(sty, toolName, opts.Anim)
44+ return pendingTool(sty, "Bash", opts.Anim)
46 }
45 }
47
46
48 var params tools.BashParams
47 var params tools.BashParams
49− var cmd string
50− err := json.Unmarshal([]byte(opts.ToolCall.Input), &params)
48+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
51−
52− if err != nil {
53− cmd = "failed to parse command"
49+ params.Command = "failed to parse command"
54− } else {
55− cmd = strings.ReplaceAll(params.Command, "\n", " ")
56− cmd = strings.ReplaceAll(cmd, "\t", " ")
57 }
50 }
58
51
59− // TODO: if the tool is being run in the background use the background job renderer
52+ // Check if this is a background job.
53+ var meta tools.BashResponseMetadata
54+ if opts.Result != nil {
55+ _ = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
56+ }
60
57
61− toolParams := []string{
58+ if meta.Background {
62− cmd,
59+ description := cmp.Or(meta.Description, params.Command)
60+ content := "Command: " + params.Command + "\n" + opts.Result.Content
61+ return renderJobTool(sty, opts, cappedWidth, "Start", meta.ShellID, description, content)
63 }
62 }
64
63
64+ // Regular bash command.
65+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
66+ cmd = strings.ReplaceAll(cmd, "\t", " ")
67+ toolParams := []string{cmd}
65 if params.RunInBackground {
68 if params.RunInBackground {
66 toolParams = append(toolParams, "background", "true")
69 toolParams = append(toolParams, "background", "true")
67 }
70 }
68
71
69 header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
72 header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
70−
71 if opts.Nested {
73 if opts.Nested {
72 return header
74 return header
73 }
75 }
74
76
75− earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
77+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
76−
78+ return joinToolParts(header, earlyState)
77− // If this is OK that means that the tool is not done yet or it was canceled
78− if ok {
79− return strings.Join([]string{header, "", earlyStateContent}, "\n")
80 }
79 }
81
80
82 if opts.Result == nil {
81 if opts.Result == nil {
83− // We should not get here!
84 return header
82 return header
85 }
83 }
86
84
87− var meta tools.BashResponseMetadata
88− err = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
89−
90− var output string
91− if err != nil {
92− output = "failed to parse output"
93− }
94− output = meta.Output
85+ output := meta.Output
95 if output == "" && opts.Result.Content != tools.BashNoOutput {
86 if output == "" && opts.Result.Content != tools.BashNoOutput {
96 output = opts.Result.Content
87 output = opts.Result.Content
97 }
88 }
98−
99 if output == "" {
89 if output == "" {
100 return header
90 return header
101 }
91 }
102
92
103 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
93 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
94+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
95+ return joinToolParts(header, body)
96+}
97+
98+// -----------------------------------------------------------------------------
99+// Job Output Tool
100+// -----------------------------------------------------------------------------
101+
102+// JobOutputToolMessageItem is a message item for job_output tool calls.
103+type JobOutputToolMessageItem struct {
104+ *baseToolMessageItem
105+}
106+
107+var _ ToolMessageItem = (*JobOutputToolMessageItem)(nil)
108+
109+// NewJobOutputToolMessageItem creates a new [JobOutputToolMessageItem].
110+func NewJobOutputToolMessageItem(
111+ sty *styles.Styles,
112+ toolCall message.ToolCall,
113+ result *message.ToolResult,
114+ canceled bool,
115+) ToolMessageItem {
116+ return newBaseToolMessageItem(sty, toolCall, result, &JobOutputToolRenderContext{}, canceled)
117+}
118+
119+// JobOutputToolRenderContext renders job_output tool messages.
120+type JobOutputToolRenderContext struct{}
121+
122+// RenderTool implements the [ToolRenderer] interface.
123+func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
124+ cappedWidth := cappedMessageWidth(width)
125+ if !opts.ToolCall.Finished && !opts.Canceled {
126+ return pendingTool(sty, "Job", opts.Anim)
127+ }
128+
129+ var params tools.JobOutputParams
130+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
131+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
132+ }
133+
134+ var description string
135+ if opts.Result != nil && opts.Result.Metadata != "" {
136+ var meta tools.JobOutputResponseMetadata
137+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
138+ description = cmp.Or(meta.Description, meta.Command)
139+ }
140+ }
141+
142+ content := ""
143+ if opts.Result != nil {
144+ content = opts.Result.Content
145+ }
146+ return renderJobTool(sty, opts, cappedWidth, "Output", params.ShellID, description, content)
147+}
148+
149+// -----------------------------------------------------------------------------
150+// Job Kill Tool
151+// -----------------------------------------------------------------------------
152+
153+// JobKillToolMessageItem is a message item for job_kill tool calls.
154+type JobKillToolMessageItem struct {
155+ *baseToolMessageItem
156+}
104
157
158+var _ ToolMessageItem = (*JobKillToolMessageItem)(nil)
159+
160+// NewJobKillToolMessageItem creates a new [JobKillToolMessageItem].
161+func NewJobKillToolMessageItem(
162+ sty *styles.Styles,
163+ toolCall message.ToolCall,
164+ result *message.ToolResult,
165+ canceled bool,
166+) ToolMessageItem {
167+ return newBaseToolMessageItem(sty, toolCall, result, &JobKillToolRenderContext{}, canceled)
168+}
169+
170+// JobKillToolRenderContext renders job_kill tool messages.
171+type JobKillToolRenderContext struct{}
172+
173+// RenderTool implements the [ToolRenderer] interface.
174+func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
175+ cappedWidth := cappedMessageWidth(width)
176+ if !opts.ToolCall.Finished && !opts.Canceled {
177+ return pendingTool(sty, "Job", opts.Anim)
178+ }
179+
180+ var params tools.JobKillParams
181+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
182+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
183+ }
184+
185+ var description string
186+ if opts.Result != nil && opts.Result.Metadata != "" {
187+ var meta tools.JobKillResponseMetadata
188+ if err := json.Unmarshal([]byte(opts.Result.Metadata), &meta); err == nil {
189+ description = cmp.Or(meta.Description, meta.Command)
190+ }
191+ }
192+
193+ content := ""
194+ if opts.Result != nil {
195+ content = opts.Result.Content
196+ }
197+ return renderJobTool(sty, opts, cappedWidth, "Kill", params.ShellID, description, content)
198+}
199+
200+// renderJobTool renders a job-related tool with the common pattern:
201+// header → nested check → early state → body.
202+func renderJobTool(sty *styles.Styles, opts *ToolRenderOpts, width int, action, shellID, description, content string) string {
203+ header := jobHeader(sty, opts.Status(), action, shellID, description, width)
204+ if opts.Nested {
205+ return header
206+ }
207+
208+ if earlyState, ok := toolEarlyStateContent(sty, opts, width); ok {
209+ return joinToolParts(header, earlyState)
210+ }
211+
212+ if content == "" {
213+ return header
214+ }
215+
216+ bodyWidth := width - toolBodyLeftPaddingTotal
105− output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
217+ body := sty.Tool.Body.Render(toolOutputPlainContent(sty, content, bodyWidth, opts.Expanded))
218+ return joinToolParts(header, body)
219+}
220+
221+// jobHeader builds a header for job-related tools.
222+// Format: "● Job (Action) PID shellID description..."
223+func jobHeader(sty *styles.Styles, status ToolStatus, action, shellID, description string, width int) string {
224+ icon := toolIcon(sty, status)
225+ jobPart := sty.Tool.JobToolName.Render("Job")
226+ actionPart := sty.Tool.JobAction.Render("(" + action + ")")
227+ pidPart := sty.Tool.JobPID.Render("PID " + shellID)
228+
229+ prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
230+
231+ if description == "" {
232+ return prefix
233+ }
234+
235+ prefixWidth := lipgloss.Width(prefix)
236+ availableWidth := width - prefixWidth - 1
237+ if availableWidth < 10 {
238+ return prefix
239+ }
240+
241+ truncatedDesc := ansi.Truncate(description, availableWidth, "…")
242+ return prefix + " " + sty.Tool.JobDescription.Render(truncatedDesc)
243+}
106
244
245+// joinToolParts joins header and body with a blank line separator.
246+func joinToolParts(header, body string) string {
107− return strings.Join([]string{header, "", output}, "\n")
247+ return strings.Join([]string{header, "", body}, "\n")
108}
248}
internal/ui/chat/messages.go+3−3
@@ -161,7 +161,7 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
161 return []MessageItem{NewUserMessageItem(sty, msg)}
161 return []MessageItem{NewUserMessageItem(sty, msg)}
162 case message.Assistant:
162 case message.Assistant:
163 var items []MessageItem
163 var items []MessageItem
164− if shouldRenderAssistantMessage(msg) {
164+ if ShouldRenderAssistantMessage(msg) {
165 items = append(items, NewAssistantMessageItem(sty, msg))
165 items = append(items, NewAssistantMessageItem(sty, msg))
166 }
166 }
167 for _, tc := range msg.ToolCalls() {
167 for _, tc := range msg.ToolCalls() {
@@ -181,11 +181,11 @@ func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults m
181 return []MessageItem{}
181 return []MessageItem{}
182}
182}
183
183
184−// shouldRenderAssistantMessage determines if an assistant message should be rendered
184+// ShouldRenderAssistantMessage determines if an assistant message should be rendered
185//
185//
186// In some cases the assistant message only has tools so we do not want to render an
186// In some cases the assistant message only has tools so we do not want to render an
187// empty message.
187// empty message.
188−func shouldRenderAssistantMessage(msg *message.Message) bool {
188+func ShouldRenderAssistantMessage(msg *message.Message) bool {
189 content := strings.TrimSpace(msg.Content().Text)
189 content := strings.TrimSpace(msg.Content().Text)
190 thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
190 thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
191 isError := msg.FinishReason() == message.FinishReasonError
191 isError := msg.FinishReason() == message.FinishReasonError
internal/ui/chat/tools.go+4
@@ -158,6 +158,10 @@ func NewToolMessageItem(
158 switch toolCall.Name {
158 switch toolCall.Name {
159 case tools.BashToolName:
159 case tools.BashToolName:
160 return NewBashToolMessageItem(sty, toolCall, result, canceled)
160 return NewBashToolMessageItem(sty, toolCall, result, canceled)
161+ case tools.JobOutputToolName:
162+ return NewJobOutputToolMessageItem(sty, toolCall, result, canceled)
163+ case tools.JobKillToolName:
164+ return NewJobKillToolMessageItem(sty, toolCall, result, canceled)
161 default:
165 default:
162 // TODO: Implement other tool items
166 // TODO: Implement other tool items
163 return newBaseToolMessageItem(
167 return newBaseToolMessageItem(
internal/ui/dialog/models_list.go+3−3
@@ -47,7 +47,7 @@ func (f *ModelsList) SetGroups(groups ...ModelGroup) {
47 // Add a space separator after each provider section
47 // Add a space separator after each provider section
48 items = append(items, list.NewSpacerItem(1))
48 items = append(items, list.NewSpacerItem(1))
49 }
49 }
50− f.List.SetItems(items...)
50+ f.SetItems(items...)
51}
51}
52
52
53// SetFilter sets the filter query and updates the list items.
53// SetFilter sets the filter query and updates the list items.
@@ -66,7 +66,7 @@ func (f *ModelsList) SetSelectedItem(itemID string) {
66 for _, g := range f.groups {
66 for _, g := range f.groups {
67 for _, item := range g.Items {
67 for _, item := range g.Items {
68 if item.ID() == itemID {
68 if item.ID() == itemID {
69− f.List.SetSelected(count)
69+ f.SetSelected(count)
70 return
70 return
71 }
71 }
72 count++
72 count++
@@ -142,7 +142,7 @@ func (f *ModelsList) VisibleItems() []list.Item {
142
142
143// Render renders the filterable list.
143// Render renders the filterable list.
144func (f *ModelsList) Render() string {
144func (f *ModelsList) Render() string {
145− f.List.SetItems(f.VisibleItems()...)
145+ f.SetItems(f.VisibleItems()...)
146 return f.List.Render()
146 return f.List.Render()
147}
147}
148
148
internal/ui/list/list.go+25
@@ -304,6 +304,31 @@ func (l *List) AppendItems(items ...Item) {
304 l.items = append(l.items, items...)
304 l.items = append(l.items, items...)
305}
305}
306
306
307+// RemoveItem removes the item at the given index from the list.
308+func (l *List) RemoveItem(idx int) {
309+ if idx < 0 || idx >= len(l.items) {
310+ return
311+ }
312+
313+ // Remove the item
314+ l.items = append(l.items[:idx], l.items[idx+1:]...)
315+
316+ // Adjust selection if needed
317+ if l.selectedIdx == idx {
318+ l.selectedIdx = -1
319+ } else if l.selectedIdx > idx {
320+ l.selectedIdx--
321+ }
322+
323+ // Adjust offset if needed
324+ if l.offsetIdx > idx {
325+ l.offsetIdx--
326+ } else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
327+ l.offsetIdx = max(0, len(l.items)-1)
328+ l.offsetLine = 0
329+ }
330+}
331+
307// Focus sets the focus state of the list.
332// Focus sets the focus state of the list.
308func (l *List) Focus() {
333func (l *List) Focus() {
309 l.focused = true
334 l.focused = true
internal/ui/model/chat.go+24
@@ -245,6 +245,30 @@ func (m *Chat) ClearMessages() {
245 m.ClearMouse()
245 m.ClearMouse()
246}
246}
247
247
248+// RemoveMessage removes a message from the chat list by its ID.
249+func (m *Chat) RemoveMessage(id string) {
250+ idx, ok := m.idInxMap[id]
251+ if !ok {
252+ return
253+ }
254+
255+ // Remove from list
256+ m.list.RemoveItem(idx)
257+
258+ // Remove from index map
259+ delete(m.idInxMap, id)
260+
261+ // Rebuild index map for all items after the removed one
262+ for i := idx; i < m.list.Len(); i++ {
263+ if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
264+ m.idInxMap[item.ID()] = i
265+ }
266+ }
267+
268+ // Clean up any paused animations for this message
269+ delete(m.pausedAnimations, id)
270+}
271+
248// MessageItem returns the message item with the given ID, or nil if not found.
272// MessageItem returns the message item with the given ID, or nil if not found.
249func (m *Chat) MessageItem(id string) chat.MessageItem {
273func (m *Chat) MessageItem(id string) chat.MessageItem {
250 idx, ok := m.idInxMap[id]
274 idx, ok := m.idInxMap[id]
internal/ui/model/ui.go+8−4
@@ -431,12 +431,16 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
431func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
431func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
432 var cmds []tea.Cmd
432 var cmds []tea.Cmd
433 existingItem := m.chat.MessageItem(msg.ID)
433 existingItem := m.chat.MessageItem(msg.ID)
434+
434− if existingItem == nil || msg.Role != message.Assistant {
435+ if existingItem != nil {
435− return nil
436+ if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
437+ assistantItem.SetMessage(&msg)
438+ }
436 }
439 }
437
440
438− if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
441+ // if the message of the assistant does not have any response just tool calls we need to remove it
439− assistantItem.SetMessage(&msg)
442+ if !chat.ShouldRenderAssistantMessage(&msg) && len(msg.ToolCalls()) > 0 && existingItem != nil {
443+ m.chat.RemoveMessage(msg.ID)
440 }
444 }
441
445
442 var items []chat.MessageItem
446 var items []chat.MessageItem
internal/ui/styles/styles.go+5−2
@@ -160,6 +160,7 @@ type Styles struct {
160 White color.Color
160 White color.Color
161 BlueLight color.Color
161 BlueLight color.Color
162 Blue color.Color
162 Blue color.Color
163+ BlueDark color.Color
163 Green color.Color
164 Green color.Color
164 GreenDark color.Color
165 GreenDark color.Color
165 Red color.Color
166 Red color.Color
@@ -382,6 +383,7 @@ func DefaultStyles() Styles {
382
383
383 blueLight = charmtone.Sardine
384 blueLight = charmtone.Sardine
384 blue = charmtone.Malibu
385 blue = charmtone.Malibu
386+ blueDark = charmtone.Damson
385
387
386 // yellow = charmtone.Mustard
388 // yellow = charmtone.Mustard
387 yellow = charmtone.Mustard
389 yellow = charmtone.Mustard
@@ -424,6 +426,7 @@ func DefaultStyles() Styles {
424 s.White = white
426 s.White = white
425 s.BlueLight = blueLight
427 s.BlueLight = blueLight
426 s.Blue = blue
428 s.Blue = blue
429+ s.BlueDark = blueDark
427 s.Green = green
430 s.Green = green
428 s.GreenDark = greenDark
431 s.GreenDark = greenDark
429 s.Red = red
432 s.Red = red
@@ -992,8 +995,8 @@ func DefaultStyles() Styles {
992 s.Tool.JobIconError = base.Foreground(redDark)
995 s.Tool.JobIconError = base.Foreground(redDark)
993 s.Tool.JobIconSuccess = base.Foreground(green)
996 s.Tool.JobIconSuccess = base.Foreground(green)
994 s.Tool.JobToolName = base.Foreground(blue)
997 s.Tool.JobToolName = base.Foreground(blue)
995− s.Tool.JobAction = base.Foreground(fgHalfMuted)
998+ s.Tool.JobAction = base.Foreground(blueDark)
996− s.Tool.JobPID = s.Subtle
999+ s.Tool.JobPID = s.Muted
997 s.Tool.JobDescription = s.Subtle
1000 s.Tool.JobDescription = s.Subtle
998
1001
999 // Agent task styles
1002 // Agent task styles