on main help sign in
Merge pull request #41 from charmbracelet/messages-adjustments
324e207Kujtim Hoxha
cspell.json+1−1
@@ -1 +1 @@
1−{"version":"0.2","language":"en","flagWords":[],"words":["crush","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph","filepicker","imageorient","rasterx","oksvg","termenv","trashhalo","lucasb","nfnt","srwiley","Lanczos","fsext"]}
1+{"flagWords":[],"words":["crush","charmbracelet","lipgloss","bubbletea","textinput","Focusable","lsps","Sourcegraph","filepicker","imageorient","rasterx","oksvg","termenv","trashhalo","lucasb","nfnt","srwiley","Lanczos","fsext","GROQ","alecthomas","Preproc","Emph","charmtone","Charple","Guac","diffview","Strikethrough","Unticked","uniseg","rivo"],"version":"0.2","language":"en"}
go.mod+1−1
@@ -32,7 +32,6 @@ require (
32 github.com/pressly/goose/v3 v3.24.2
32 github.com/pressly/goose/v3 v3.24.2
33 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
33 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
34 github.com/sahilm/fuzzy v0.1.1
34 github.com/sahilm/fuzzy v0.1.1
35− github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
36 github.com/spf13/cobra v1.9.1
35 github.com/spf13/cobra v1.9.1
37 github.com/spf13/viper v1.20.0
36 github.com/spf13/viper v1.20.0
38 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
37 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
@@ -105,6 +104,7 @@ require (
105 github.com/rivo/uniseg v0.4.7
104 github.com/rivo/uniseg v0.4.7
106 github.com/rogpeppe/go-internal v1.14.1 // indirect
105 github.com/rogpeppe/go-internal v1.14.1 // indirect
107 github.com/sagikazarmark/locafero v0.7.0 // indirect
106 github.com/sagikazarmark/locafero v0.7.0 // indirect
107+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
108 github.com/sethvargo/go-retry v0.3.0 // indirect
108 github.com/sethvargo/go-retry v0.3.0 // indirect
109 github.com/sourcegraph/conc v0.3.0 // indirect
109 github.com/sourcegraph/conc v0.3.0 // indirect
110 github.com/spf13/afero v1.12.0 // indirect
110 github.com/spf13/afero v1.12.0 // indirect
internal/app/app.go+1
@@ -163,4 +163,5 @@ func (app *App) Shutdown() {
163 }
163 }
164 cancel()
164 cancel()
165 }
165 }
166+ app.CoderAgent.CancelAll()
166}
167}
internal/llm/agent/agent.go+8
@@ -50,6 +50,7 @@ type Service interface {
50 Model() models.Model
50 Model() models.Model
51 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
51 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
52 Cancel(sessionID string)
52 Cancel(sessionID string)
53+ CancelAll()
53 IsSessionBusy(sessionID string) bool
54 IsSessionBusy(sessionID string) bool
54 IsBusy() bool
55 IsBusy() bool
55 Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
56 Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
@@ -698,6 +699,13 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
698 return nil
699 return nil
699}
700}
700
701
702+func (a *agent) CancelAll() {
703+ a.activeRequests.Range(func(key, value any) bool {
704+ a.Cancel(key.(string)) // key is sessionID
705+ return true
706+ })
707+}
708+
701func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
709func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
702 cfg := config.Get()
710 cfg := config.Get()
703 agentConfig, ok := cfg.Agents[agentName]
711 agentConfig, ok := cfg.Agents[agentName]
internal/llm/prompt/coder.go+6−6
@@ -30,10 +30,6 @@ You are operating as and within the Crush CLI, a terminal-based agentic coding a
30You can:
30You can:
31- Receive user prompts, project context, and files.
31- Receive user prompts, project context, and files.
32- Stream responses and emit function calls (e.g., shell commands, code edits).
32- Stream responses and emit function calls (e.g., shell commands, code edits).
33−- Apply patches, run commands, and manage user approvals based on policy.
34−- Work inside a sandboxed, git-backed workspace with rollback support.
35−- Log telemetry so sessions can be replayed or inspected later.
36−- More details on your functionality are available at "crush --help"
37
33
38
34
39You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
35You are an agent - please keep going until the user's query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
@@ -64,17 +60,19 @@ You MUST adhere to the following criteria when executing the task:
64- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
60- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
65 - Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
61 - Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
66- When your task involves writing or modifying files:
62- When your task involves writing or modifying files:
67− - Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using "apply_patch". Instead, reference the file as already saved.
63+ - Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using "edit/write". Instead, reference the file as already saved.
68 - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
64 - Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
69- When doing things with paths, always use use the full path, if the working directory is /abc/xyz and you want to edit the file abc.go in the working dir refer to it as /abc/xyz/abc.go.
65- When doing things with paths, always use use the full path, if the working directory is /abc/xyz and you want to edit the file abc.go in the working dir refer to it as /abc/xyz/abc.go.
70- If you send a path not including the working dir, the working dir will be prepended to it.
66- If you send a path not including the working dir, the working dir will be prepended to it.
71- Remember the user does not see the full output of tools
67- Remember the user does not see the full output of tools
68+- NEVER use emojis in your responses
72`
69`
73
70
74const baseAnthropicCoderPrompt = `You are Crush, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
71const baseAnthropicCoderPrompt = `You are Crush, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
75
72
76IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
73IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
77
74
75+
78# Memory
76# Memory
79If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
77If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
801. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
781. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
@@ -131,7 +129,7 @@ assistant: src/foo.c
131
129
132<example>
130<example>
133user: write tests for new feature
131user: write tests for new feature
134−assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit/patch file tool to write new tests]
132+assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
135</example>
133</example>
136
134
137# Proactiveness
135# Proactiveness
@@ -165,6 +163,8 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
165- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
163- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
166- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
164- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
167
165
166+VERY IMPORTANT NEVER use emojis in your responses.
167+
168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
168You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
169
169
170func getEnvironmentInfo() string {
170func getEnvironmentInfo() string {
internal/tui/components/chat/chat.go+13−4
@@ -272,7 +272,7 @@ func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, me
272 assistantIndex = i
272 assistantIndex = i
273 }
273 }
274 } else if tc, ok := item.(messages.ToolCallCmp); ok {
274 } else if tc, ok := item.(messages.ToolCallCmp); ok {
275− if tc.ParentMessageId() == messageID {
275+ if tc.ParentMessageID() == messageID {
276 toolCalls[i] = tc
276 toolCalls[i] = tc
277 }
277 }
278 }
278 }
@@ -295,9 +295,17 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
295 assistantIndex,
295 assistantIndex,
296 messages.NewMessageCmp(
296 messages.NewMessageCmp(
297 msg,
297 msg,
298− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
299 ),
298 ),
300 )
299 )
300+
301+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
302+ m.listCmp.AppendItem(
303+ messages.NewAssistantSection(
304+ msg,
305+ time.Unix(m.lastUserMessageTime, 0),
306+ ),
307+ )
308+ }
301 } else if hasToolCallsOnly {
309 } else if hasToolCallsOnly {
302 m.listCmp.DeleteItem(assistantIndex)
310 m.listCmp.DeleteItem(assistantIndex)
303 }
311 }
@@ -347,7 +355,6 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
347 cmd := m.listCmp.AppendItem(
355 cmd := m.listCmp.AppendItem(
348 messages.NewMessageCmp(
356 messages.NewMessageCmp(
349 msg,
357 msg,
350− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
351 ),
358 ),
352 )
359 )
353 cmds = append(cmds, cmd)
360 cmds = append(cmds, cmd)
@@ -412,6 +419,9 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
412 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
419 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
413 case message.Assistant:
420 case message.Assistant:
414 uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
421 uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
422+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
423+ uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
424+ }
415 }
425 }
416 }
426 }
417
427
@@ -428,7 +438,6 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
428 uiMessages,
438 uiMessages,
429 messages.NewMessageCmp(
439 messages.NewMessageCmp(
430 msg,
440 msg,
431− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
432 ),
441 ),
433 )
442 )
434 }
443 }
internal/tui/components/chat/messages/messages.go+64−44
@@ -8,13 +8,14 @@ import (
8
8
9 "github.com/charmbracelet/bubbles/v2/spinner"
9 "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11− "github.com/charmbracelet/crush/internal/llm/models"
12 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
13
12
13+ "github.com/charmbracelet/crush/internal/llm/models"
14 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/tui/components/anim"
15 "github.com/charmbracelet/crush/internal/tui/components/anim"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
18 "github.com/charmbracelet/crush/internal/tui/styles"
19 "github.com/charmbracelet/crush/internal/tui/styles"
19 "github.com/charmbracelet/crush/internal/tui/util"
20 "github.com/charmbracelet/crush/internal/tui/util"
20)
21)
@@ -37,32 +38,17 @@ type messageCmp struct {
37 focused bool // Focus state for border styling
38 focused bool // Focus state for border styling
38
39
39 // Core message data and state
40 // Core message data and state
40− message message.Message // The underlying message content
41+ message message.Message // The underlying message content
41− spinning bool // Whether to show loading animation
42+ spinning bool // Whether to show loading animation
42− anim util.Model // Animation component for loading states
43+ anim util.Model // Animation component for loading states
43− lastUserMessageTime time.Time // Used for calculating response duration
44−}
45−
46−// MessageOption provides functional options for configuring message components
47−type MessageOption func(*messageCmp)
48−
49−// WithLastUserMessageTime sets the timestamp of the last user message
50−// for calculating assistant response duration
51−func WithLastUserMessageTime(t time.Time) MessageOption {
52− return func(m *messageCmp) {
53− m.lastUserMessageTime = t
54− }
55}
44}
56
45
57// NewMessageCmp creates a new message component with the given message and options
46// NewMessageCmp creates a new message component with the given message and options
58−func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
47+func NewMessageCmp(msg message.Message) MessageCmp {
59 m := &messageCmp{
48 m := &messageCmp{
60 message: msg,
49 message: msg,
61 anim: anim.New(15, ""),
50 anim: anim.New(15, ""),
62 }
51 }
63− for _, opt := range opts {
64− opt(m)
65− }
66 return m
52 return m
67}
53}
68
54
@@ -145,32 +131,10 @@ func (msg *messageCmp) style() lipgloss.Style {
145// renderAssistantMessage renders assistant messages with optional footer information.
131// renderAssistantMessage renders assistant messages with optional footer information.
146// Shows model name, response time, and finish reason when the message is complete.
132// Shows model name, response time, and finish reason when the message is complete.
147func (m *messageCmp) renderAssistantMessage() string {
133func (m *messageCmp) renderAssistantMessage() string {
148− t := styles.CurrentTheme()
149 parts := []string{
134 parts := []string{
150 m.markdownContent(),
135 m.markdownContent(),
151 }
136 }
152
137
153− finished := m.message.IsFinished()
154− finishData := m.message.FinishPart()
155− // Only show the footer if the message is not a tool call
156− if finished && finishData.Reason != message.FinishReasonToolUse {
157− infoMsg := ""
158− switch finishData.Reason {
159− case message.FinishReasonEndTurn:
160− finishTime := time.Unix(finishData.Time, 0)
161− duration := finishTime.Sub(m.lastUserMessageTime)
162− infoMsg = duration.String()
163− case message.FinishReasonCanceled:
164− infoMsg = "canceled"
165− case message.FinishReasonError:
166− infoMsg = "error"
167− case message.FinishReasonPermissionDenied:
168− infoMsg = "permission denied"
169− }
170− assistant := t.S().Muted.Render(fmt.Sprintf("%s %s (%s)", styles.ModelIcon, models.SupportedModels[m.message.Model].Name, infoMsg))
171− parts = append(parts, core.Section(assistant, m.textWidth()))
172− }
173−
174 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
138 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
175 return m.style().Render(joined)
139 return m.style().Render(joined)
176}
140}
@@ -200,7 +164,7 @@ func (m *messageCmp) renderUserMessage() string {
200 parts = append(parts, "", strings.Join(attachments, ""))
164 parts = append(parts, "", strings.Join(attachments, ""))
201 }
165 }
202 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
166 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
203− return m.style().MarginBottom(1).Render(joined)
167+ return m.style().Render(joined)
204}
168}
205
169
206// toMarkdown converts text content to rendered markdown using the configured renderer
170// toMarkdown converts text content to rendered markdown using the configured renderer
@@ -225,7 +189,7 @@ func (m *messageCmp) markdownContent() string {
225 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
189 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
226 // Sometimes the LLMs respond with no content when they think the previous tool result
190 // Sometimes the LLMs respond with no content when they think the previous tool result
227 // provides the requested question
191 // provides the requested question
228− content = "*Finished without output*"
192+ content = ""
229 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
193 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
230 content = "*Canceled*"
194 content = "*Canceled*"
231 }
195 }
@@ -287,3 +251,59 @@ func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
287func (m *messageCmp) Spinning() bool {
251func (m *messageCmp) Spinning() bool {
288 return m.spinning
252 return m.spinning
289}
253}
254+
255+type AssistantSection interface {
256+ util.Model
257+ layout.Sizeable
258+ list.SectionHeader
259+}
260+type assistantSectionModel struct {
261+ width int
262+ message message.Message
263+ lastUserMessageTime time.Time
264+}
265+
266+func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
267+ return &assistantSectionModel{
268+ width: 0,
269+ message: message,
270+ lastUserMessageTime: lastUserMessageTime,
271+ }
272+}
273+
274+func (m *assistantSectionModel) Init() tea.Cmd {
275+ return nil
276+}
277+
278+func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
279+ return m, nil
280+}
281+
282+func (m *assistantSectionModel) View() tea.View {
283+ t := styles.CurrentTheme()
284+ finishData := m.message.FinishPart()
285+ finishTime := time.Unix(finishData.Time, 0)
286+ duration := finishTime.Sub(m.lastUserMessageTime)
287+ infoMsg := t.S().Subtle.Render(duration.String())
288+ icon := t.S().Subtle.Render(styles.ModelIcon)
289+ model := t.S().Muted.Render(models.SupportedModels[m.message.Model].Name)
290+ assistant := fmt.Sprintf("%s %s %s", icon, model, infoMsg)
291+ return tea.NewView(
292+ t.S().Base.PaddingLeft(2).Render(
293+ core.Section(assistant, m.width-2),
294+ ),
295+ )
296+}
297+
298+func (m *assistantSectionModel) GetSize() (int, int) {
299+ return m.width, 1
300+}
301+
302+func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
303+ m.width = width
304+ return nil
305+}
306+
307+func (m *assistantSectionModel) IsSectionHeader() bool {
308+ return true
309+}
internal/tui/components/chat/messages/renderer.go+83−31
@@ -111,8 +111,18 @@ func (br baseRenderer) unmarshalParams(input string, target any) error {
111 return json.Unmarshal([]byte(input), target)
111 return json.Unmarshal([]byte(input), target)
112}
112}
113
113
114+// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
115+func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
116+ t := styles.CurrentTheme()
117+ tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) + " "
118+ return tool + renderParamList(true, width-lipgloss.Width(tool), params...)
119+}
120+
114// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
121// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
115func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
122func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
123+ if v.isNested {
124+ return br.makeNestedHeader(v, tool, width, params...)
125+ }
116 t := styles.CurrentTheme()
126 t := styles.CurrentTheme()
117 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
127 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
118 if v.result.ToolCallID != "" {
128 if v.result.ToolCallID != "" {
@@ -125,8 +135,8 @@ func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params
125 icon = t.S().Muted.Render(styles.ToolPending)
135 icon = t.S().Muted.Render(styles.ToolPending)
126 }
136 }
127 tool = t.S().Base.Foreground(t.Blue).Render(tool)
137 tool = t.S().Base.Foreground(t.Blue).Render(tool)
128− prefix := fmt.Sprintf("%s %s: ", icon, tool)
138+ prefix := fmt.Sprintf("%s %s ", icon, tool)
129− return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
139+ return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
130}
140}
131
141
132// renderError provides consistent error rendering
142// renderError provides consistent error rendering
@@ -260,8 +270,10 @@ func (er editRenderer) Render(v *toolCallCmp) string {
260 formatter := core.DiffFormatter().
270 formatter := core.DiffFormatter().
261 Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
271 Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
262 After(fsext.PrettyPath(params.FilePath), meta.NewContent).
272 After(fsext.PrettyPath(params.FilePath), meta.NewContent).
263− Split().
264 Width(v.textWidth() - 2) // -2 for padding
273 Width(v.textWidth() - 2) // -2 for padding
274+ if v.textWidth() > 120 {
275+ formatter = formatter.Split()
276+ }
265 return formatter.String()
277 return formatter.String()
266 })
278 })
267}
279}
@@ -475,25 +487,45 @@ type agentRenderer struct {
475 baseRenderer
487 baseRenderer
476}
488}
477
489
490+func RoundedEnumerator(children tree.Children, index int) string {
491+ if children.Length()-1 == index {
492+ return " ╰──"
493+ }
494+ return " ├──"
495+}
496+
478// Render displays agent task parameters and result content
497// Render displays agent task parameters and result content
479func (tr agentRenderer) Render(v *toolCallCmp) string {
498func (tr agentRenderer) Render(v *toolCallCmp) string {
499+ t := styles.CurrentTheme()
480 var params agent.AgentParams
500 var params agent.AgentParams
481 if err := tr.unmarshalParams(v.call.Input, &params); err != nil {
501 if err := tr.unmarshalParams(v.call.Input, &params); err != nil {
482 return tr.renderError(v, "Invalid task parameters")
502 return tr.renderError(v, "Invalid task parameters")
483 }
503 }
484 prompt := params.Prompt
504 prompt := params.Prompt
485 prompt = strings.ReplaceAll(prompt, "\n", " ")
505 prompt = strings.ReplaceAll(prompt, "\n", " ")
486− args := newParamBuilder().addMain(prompt).build()
487
506
488− header := tr.makeHeader(v, "Task", v.textWidth(), args...)
507+ header := tr.makeHeader(v, "Agent", v.textWidth())
508+ taskTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.BlueLight).Foreground(t.White).Render("Task")
509+ remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2 // -2 for padding
510+ prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
511+ header = lipgloss.JoinVertical(
512+ lipgloss.Left,
513+ header,
514+ "",
515+ lipgloss.JoinHorizontal(
516+ lipgloss.Left,
517+ taskTag,
518+ " ",
519+ prompt,
520+ ),
521+ )
489− t := tree.Root(header)
522+ childTools := tree.Root(header)
490
523
491 for _, call := range v.nestedToolCalls {
524 for _, call := range v.nestedToolCalls {
492− t.Child(call.View())
525+ childTools.Child(call.View())
493 }
526 }
494−
495 parts := []string{
527 parts := []string{
496− t.Enumerator(tree.RoundedEnumerator).String(),
528+ childTools.Enumerator(RoundedEnumerator).String(),
497 }
529 }
498 if v.result.ToolCallID == "" {
530 if v.result.ToolCallID == "" {
499 v.spinning = true
531 v.spinning = true
@@ -516,7 +548,8 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
516}
548}
517
549
518// renderParamList renders params, params[0] (params[1]=params[2] ....)
550// renderParamList renders params, params[0] (params[1]=params[2] ....)
519−func renderParamList(paramsWidth int, params ...string) string {
551+func renderParamList(nested bool, paramsWidth int, params ...string) string {
552+ t := styles.CurrentTheme()
520 if len(params) == 0 {
553 if len(params) == 0 {
521 return ""
554 return ""
522 }
555 }
@@ -526,7 +559,10 @@ func renderParamList(paramsWidth int, params ...string) string {
526 }
559 }
527
560
528 if len(params) == 1 {
561 if len(params) == 1 {
562+ if nested {
529− return mainParam
563+ return t.S().Muted.Render(mainParam)
564+ }
565+ return t.S().Subtle.Render(mainParam)
530 }
566 }
531 otherParams := params[1:]
567 otherParams := params[1:]
532 // create pairs of key/value
568 // create pairs of key/value
@@ -547,15 +583,21 @@ func renderParamList(paramsWidth int, params ...string) string {
547 partsRendered := strings.Join(parts, ", ")
583 partsRendered := strings.Join(parts, ", ")
548 remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
584 remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
549 if remainingWidth < 30 {
585 if remainingWidth < 30 {
586+ if nested {
587+ return t.S().Muted.Render(mainParam)
588+ }
550 // No space for the params, just show the main
589 // No space for the params, just show the main
551− return mainParam
590+ return t.S().Subtle.Render(mainParam)
552 }
591 }
553
592
554 if len(parts) > 0 {
593 if len(parts) > 0 {
555 mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
594 mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
556 }
595 }
557
596
597+ if nested {
598+ return t.S().Muted.Render(ansi.Truncate(mainParam, paramsWidth, "..."))
599+ }
558− return ansi.Truncate(mainParam, paramsWidth, "...")
600+ return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "..."))
559}
601}
560
602
561// earlyState returns immediately‑rendered error/cancelled/ongoing states.
603// earlyState returns immediately‑rendered error/cancelled/ongoing states.
@@ -580,7 +622,7 @@ func earlyState(header string, v *toolCallCmp) (string, bool) {
580func joinHeaderBody(header, body string) string {
622func joinHeaderBody(header, body string) string {
581 t := styles.CurrentTheme()
623 t := styles.CurrentTheme()
582 body = t.S().Base.PaddingLeft(2).Render(body)
624 body = t.S().Base.PaddingLeft(2).Render(body)
583− return lipgloss.JoinVertical(lipgloss.Left, header, body, "")
625+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
584}
626}
585
627
586func renderPlainContent(v *toolCallCmp, content string) string {
628func renderPlainContent(v *toolCallCmp, content string) string {
@@ -600,46 +642,56 @@ func renderPlainContent(v *toolCallCmp, content string) string {
600 }
642 }
601 out = append(out, t.S().Muted.
643 out = append(out, t.S().Muted.
602 Width(width).
644 Width(width).
603− Background(t.BgSubtle).
645+ Background(t.BgBaseLighter).
604 Render(ln))
646 Render(ln))
605 }
647 }
606
648
607 if len(lines) > responseContextHeight {
649 if len(lines) > responseContextHeight {
608 out = append(out, t.S().Muted.
650 out = append(out, t.S().Muted.
609− Background(t.BgSubtle).
651+ Background(t.BgBaseLighter).
610 Width(width).
652 Width(width).
611 Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
653 Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
612 }
654 }
613 return strings.Join(out, "\n")
655 return strings.Join(out, "\n")
614}
656}
615
657
658+func pad(v any, width int) string {
659+ s := fmt.Sprintf("%v", v)
660+ w := ansi.StringWidth(s)
661+ if w >= width {
662+ return s
663+ }
664+ return strings.Repeat(" ", width-w) + s
665+}
666+
616func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
667func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
617 t := styles.CurrentTheme()
668 t := styles.CurrentTheme()
618 truncated := truncateHeight(content, responseContextHeight)
669 truncated := truncateHeight(content, responseContextHeight)
619
670
620− highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgSubtle)
671+ highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgBase)
621 lines := strings.Split(highlighted, "\n")
672 lines := strings.Split(highlighted, "\n")
622
673
623 if len(strings.Split(content, "\n")) > responseContextHeight {
674 if len(strings.Split(content, "\n")) > responseContextHeight {
624 lines = append(lines, t.S().Muted.
675 lines = append(lines, t.S().Muted.
625− Background(t.BgSubtle).
676+ Background(t.BgBase).
626− Width(v.textWidth()-2).
627− Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
677+ Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
628 }
678 }
629
679
680+ maxLineNumber := len(lines) + offset
681+ padding := lipgloss.Width(fmt.Sprintf("%d", maxLineNumber))
630 for i, ln := range lines {
682 for i, ln := range lines {
631− num := t.S().Muted.
683+ num := t.S().Base.
684+ Foreground(t.FgMuted).
632− Background(t.BgSubtle).
685+ Background(t.BgBase).
633− PaddingLeft(4).
634− PaddingRight(2).
686+ PaddingRight(1).
687+ PaddingLeft(1).
635− Render(fmt.Sprintf("%d", i+1+offset))
688+ Render(pad(i+1+offset, padding))
636− w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding
689+ w := v.textWidth() - 10 - lipgloss.Width(num) // -4 for left padding
637 lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
690 lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
638 num,
691 num,
639 t.S().Base.
692 t.S().Base.
640− Width(w).
693+ PaddingLeft(1).
641− Background(t.BgSubtle).
642− Render(v.fit(ln, w)))
694+ Render(v.fit(ln, w-1)))
643 }
695 }
644 return lipgloss.JoinVertical(lipgloss.Left, lines...)
696 return lipgloss.JoinVertical(lipgloss.Left, lines...)
645}
697}
@@ -648,7 +700,7 @@ func (v *toolCallCmp) renderToolError() string {
648 t := styles.CurrentTheme()
700 t := styles.CurrentTheme()
649 err := strings.ReplaceAll(v.result.Content, "\n", " ")
701 err := strings.ReplaceAll(v.result.Content, "\n", " ")
650 err = fmt.Sprintf("Error: %s", err)
702 err = fmt.Sprintf("Error: %s", err)
651− return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
703+ return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()-2))
652}
704}
653
705
654func truncateHeight(s string, h int) string {
706func truncateHeight(s string, h int) string {
@@ -662,7 +714,7 @@ func truncateHeight(s string, h int) string {
662func prettifyToolName(name string) string {
714func prettifyToolName(name string) string {
663 switch name {
715 switch name {
664 case agent.AgentToolName:
716 case agent.AgentToolName:
665− return "Task"
717+ return "Agent"
666 case tools.BashToolName:
718 case tools.BashToolName:
667 return "Bash"
719 return "Bash"
668 case tools.EditToolName:
720 case tools.EditToolName:
internal/tui/components/chat/messages/tool.go+15−11
@@ -25,7 +25,7 @@ type ToolCallCmp interface {
25 SetToolResult(message.ToolResult) // Update tool result
25 SetToolResult(message.ToolResult) // Update tool result
26 SetToolCall(message.ToolCall) // Update tool call
26 SetToolCall(message.ToolCall) // Update tool call
27 SetCancelled() // Mark as cancelled
27 SetCancelled() // Mark as cancelled
28− ParentMessageId() string // Get parent message ID
28+ ParentMessageID() string // Get parent message ID
29 Spinning() bool // Animation state for pending tools
29 Spinning() bool // Animation state for pending tools
30 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
30 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
31 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
31 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
@@ -83,10 +83,10 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
83
83
84// NewToolCallCmp creates a new tool call component with the given parent message ID,
84// NewToolCallCmp creates a new tool call component with the given parent message ID,
85// tool call, and optional configuration
85// tool call, and optional configuration
86−func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
86+func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
87 m := &toolCallCmp{
87 m := &toolCallCmp{
88 call: tc,
88 call: tc,
89− parentMessageID: parentMessageId,
89+ parentMessageID: parentMessageID,
90 }
90 }
91 for _, opt := range opts {
91 for _, opt := range opts {
92 opt(m)
92 opt(m)
@@ -137,9 +137,6 @@ func (m *toolCallCmp) View() tea.View {
137 box := m.style()
137 box := m.style()
138
138
139 if !m.call.Finished && !m.cancelled {
139 if !m.call.Finished && !m.cancelled {
140− if m.isNested {
141− return tea.NewView(box.Render(m.renderPending()))
142− }
143 return tea.NewView(box.Render(m.renderPending()))
140 return tea.NewView(box.Render(m.renderPending()))
144 }
141 }
145
142
@@ -166,8 +163,8 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
166 }
163 }
167}
164}
168
165
169−// ParentMessageId returns the ID of the message that initiated this tool call
166+// ParentMessageID returns the ID of the message that initiated this tool call
170−func (m *toolCallCmp) ParentMessageId() string {
167+func (m *toolCallCmp) ParentMessageID() string {
171 return m.parentMessageID
168 return m.parentMessageID
172}
169}
173
170
@@ -210,9 +207,13 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
210// renderPending displays the tool name with a loading animation for pending tool calls
207// renderPending displays the tool name with a loading animation for pending tool calls
211func (m *toolCallCmp) renderPending() string {
208func (m *toolCallCmp) renderPending() string {
212 t := styles.CurrentTheme()
209 t := styles.CurrentTheme()
210+ if m.isNested {
211+ tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
212+ return fmt.Sprintf("%s %s", tool, m.anim.View())
213+ }
213 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
214 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
214 tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
215 tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
215− return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
216+ return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
216}
217}
217
218
218// style returns the lipgloss style for the tool call component.
219// style returns the lipgloss style for the tool call component.
@@ -229,14 +230,17 @@ func (m *toolCallCmp) style() lipgloss.Style {
229// textWidth calculates the available width for text content,
230// textWidth calculates the available width for text content,
230// accounting for borders and padding
231// accounting for borders and padding
231func (m *toolCallCmp) textWidth() int {
232func (m *toolCallCmp) textWidth() int {
233+ if m.isNested {
234+ return m.width - 6
235+ }
232 return m.width - 5 // take into account the border and PaddingLeft
236 return m.width - 5 // take into account the border and PaddingLeft
233}
237}
234
238
235// fit truncates content to fit within the specified width with ellipsis
239// fit truncates content to fit within the specified width with ellipsis
236func (m *toolCallCmp) fit(content string, width int) string {
240func (m *toolCallCmp) fit(content string, width int) string {
237 t := styles.CurrentTheme()
241 t := styles.CurrentTheme()
238− lineStyle := t.S().Muted.Background(t.BgSubtle)
242+ lineStyle := t.S().Muted
239− dots := lineStyle.Render("...")
243+ dots := lineStyle.Render("…")
240 return ansi.Truncate(content, width, dots)
244 return ansi.Truncate(content, width, dots)
241}
245}
242
246
internal/tui/components/chat/sidebar/sidebar.go+3−3
@@ -288,9 +288,9 @@ func (m *sidebarCmp) filesBlock() string {
288 })
288 })
289
289
290 for _, file := range files {
290 for _, file := range files {
291− // Extract just the filename from the path
291+ if file.Additions == 0 && file.Deletions == 0 {
292−
292+ continue // skip files with no changes
293− // Create status indicators for additions/deletions
293+ }
294 var statusParts []string
294 var statusParts []string
295 if file.Additions > 0 {
295 if file.Additions > 0 {
296 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
296 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
internal/tui/components/core/helpers.go+2−1
@@ -148,8 +148,9 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string {
148}
148}
149
149
150func DiffFormatter() *diffview.DiffView {
150func DiffFormatter() *diffview.DiffView {
151+ t := styles.CurrentTheme()
151 formatDiff := diffview.New()
152 formatDiff := diffview.New()
152 style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
153 style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
153− diff := formatDiff.ChromaStyle(style)
154+ diff := formatDiff.ChromaStyle(style).Style(t.S().Diff)
154 return diff
155 return diff
155}
156}
internal/tui/components/core/list/keys.go+3−3
@@ -32,10 +32,10 @@ func DefaultKeyMap() KeyMap {
32 key.WithKeys("k"),
32 key.WithKeys("k"),
33 ),
33 ),
34 UpOneItem: key.NewBinding(
34 UpOneItem: key.NewBinding(
35− key.WithKeys("shift+up", "shift+k"),
35+ key.WithKeys("shift+up", "K"),
36 ),
36 ),
37 DownOneItem: key.NewBinding(
37 DownOneItem: key.NewBinding(
38− key.WithKeys("shift+down", "shift+j"),
38+ key.WithKeys("shift+down", "J"),
39 ),
39 ),
40 HalfPageDown: key.NewBinding(
40 HalfPageDown: key.NewBinding(
41 key.WithKeys("d"),
41 key.WithKeys("d"),
@@ -47,7 +47,7 @@ func DefaultKeyMap() KeyMap {
47 key.WithKeys("g", "home"),
47 key.WithKeys("g", "home"),
48 ),
48 ),
49 End: key.NewBinding(
49 End: key.NewBinding(
50− key.WithKeys("shift+g", "end"),
50+ key.WithKeys("G", "end"),
51 ),
51 ),
52 }
52 }
53}
53}
internal/tui/components/core/list/list.go+4−2
@@ -749,8 +749,8 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
749func (m *model) goToBottom() tea.Cmd {
749func (m *model) goToBottom() tea.Cmd {
750 cmds := []tea.Cmd{m.blurSelected()}
750 cmds := []tea.Cmd{m.blurSelected()}
751 m.viewState.reverse = true
751 m.viewState.reverse = true
752+ m.selectionState.selectedIndex = m.findLastSelectableItem()
752 if m.isFocused {
753 if m.isFocused {
753− m.selectionState.selectedIndex = m.findLastSelectableItem()
754 cmds = append(cmds, m.focusSelected())
754 cmds = append(cmds, m.focusSelected())
755 }
755 }
756 m.ResetView()
756 m.ResetView()
@@ -764,7 +764,9 @@ func (m *model) goToTop() tea.Cmd {
764 cmds := []tea.Cmd{m.blurSelected()}
764 cmds := []tea.Cmd{m.blurSelected()}
765 m.viewState.reverse = false
765 m.viewState.reverse = false
766 m.selectionState.selectedIndex = m.findFirstSelectableItem()
766 m.selectionState.selectedIndex = m.findFirstSelectableItem()
767+ if m.isFocused {
767− cmds = append(cmds, m.focusSelected())
768+ cmds = append(cmds, m.focusSelected())
769+ }
768 m.ResetView()
770 m.ResetView()
769 return tea.Batch(cmds...)
771 return tea.Batch(cmds...)
770}
772}
internal/tui/components/core/status/status.go+27−9
@@ -1,6 +1,7 @@
1package status
1package status
2
2
3import (
3import (
4+ "strings"
4 "time"
5 "time"
5
6
6 "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/help"
@@ -10,6 +11,8 @@ import (
10 "github.com/charmbracelet/crush/internal/session"
11 "github.com/charmbracelet/crush/internal/session"
11 "github.com/charmbracelet/crush/internal/tui/styles"
12 "github.com/charmbracelet/crush/internal/tui/styles"
12 "github.com/charmbracelet/crush/internal/tui/util"
13 "github.com/charmbracelet/crush/internal/tui/util"
14+ "github.com/charmbracelet/lipgloss/v2"
15+ "github.com/charmbracelet/x/ansi"
13)
16)
14
17
15type StatusCmp interface {
18type StatusCmp interface {
@@ -85,6 +88,7 @@ func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85 TTL: msg.Payload.PersistTime,
88 TTL: msg.Payload.PersistTime,
86 }
89 }
87 }
90 }
91+ return m, m.clearMessageCmd(m.info.TTL)
88 }
92 }
89 }
93 }
90 return m, nil
94 return m, nil
@@ -94,18 +98,32 @@ func (m *statusCmp) View() tea.View {
94 t := styles.CurrentTheme()
98 t := styles.CurrentTheme()
95 status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
99 status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
96 if m.info.Msg != "" {
100 if m.info.Msg != "" {
97− switch m.info.Type {
101+ status = m.infoMsg()
98− case util.InfoTypeError:
99− status = t.S().Base.Background(t.Error).Padding(0, 1).Width(m.width).Render(m.info.Msg)
100− case util.InfoTypeWarn:
101− status = t.S().Base.Background(t.Warning).Padding(0, 1).Width(m.width).Render(m.info.Msg)
102− default:
103− status = t.S().Base.Background(t.Info).Padding(0, 1).Width(m.width).Render(m.info.Msg)
104− }
105 }
102 }
106 return tea.NewView(status)
103 return tea.NewView(status)
107}
104}
108
105
106+func (m *statusCmp) infoMsg() string {
107+ t := styles.CurrentTheme()
108+ message := ""
109+ infoType := ""
110+ switch m.info.Type {
111+ case util.InfoTypeError:
112+ infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
113+ width := m.width - lipgloss.Width(infoType)
114+ message = t.S().Base.Background(t.Error).Foreground(t.White).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
115+ case util.InfoTypeWarn:
116+ infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
117+ width := m.width - lipgloss.Width(infoType)
118+ message = t.S().Base.Foreground(t.BgOverlay).Background(t.Warning).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
119+ default:
120+ infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
121+ width := m.width - lipgloss.Width(infoType)
122+ message = t.S().Base.Background(t.Success).Foreground(t.White).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "…"))
123+ }
124+ return strings.Join([]string{infoType, message}, "")
125+}
126+
109func (m *statusCmp) ToggleFullHelp() {
127func (m *statusCmp) ToggleFullHelp() {
110 m.help.ShowAll = !m.help.ShowAll
128 m.help.ShowAll = !m.help.ShowAll
111}
129}
@@ -119,7 +137,7 @@ func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
119 help := help.New()
137 help := help.New()
120 help.Styles = t.S().Help
138 help.Styles = t.S().Help
121 return &statusCmp{
139 return &statusCmp{
122− messageTTL: 10 * time.Second,
140+ messageTTL: 5 * time.Second,
123 help: help,
141 help: help,
124 keyMap: keyMap,
142 keyMap: keyMap,
125 }
143 }
internal/tui/components/dialogs/commands/arguments.go−9
@@ -211,15 +211,6 @@ func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
211 return cursor
211 return cursor
212}
212}
213
213
214−func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
215− t := styles.CurrentTheme()
216− return t.S().Base.
217− Width(c.width).
218− Padding(1).
219− Border(lipgloss.RoundedBorder()).
220− BorderForeground(t.BorderFocus)
221−}
222−
223func (c *commandArgumentsDialogCmp) Position() (int, int) {
214func (c *commandArgumentsDialogCmp) Position() (int, int) {
224 row := c.wHeight / 2
215 row := c.wHeight / 2
225 row -= c.wHeight / 2
216 row -= c.wHeight / 2
internal/tui/components/dialogs/models/models.go+4−4
@@ -193,15 +193,15 @@ func (m *modelDialogCmp) listHeight() int {
193
193
194func GetSelectedModel(cfg *config.Config) models.Model {
194func GetSelectedModel(cfg *config.Config) models.Model {
195 agentCfg := cfg.Agents[config.AgentCoder]
195 agentCfg := cfg.Agents[config.AgentCoder]
196− selectedModelId := agentCfg.Model
196+ selectedModelID := agentCfg.Model
197− return models.SupportedModels[selectedModelId]
197+ return models.SupportedModels[selectedModelID]
198}
198}
199
199
200func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
200func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
201 var providers []models.ModelProvider
201 var providers []models.ModelProvider
202− for providerId, provider := range cfg.Providers {
202+ for providerID, provider := range cfg.Providers {
203 if !provider.Disabled {
203 if !provider.Disabled {
204− providers = append(providers, providerId)
204+ providers = append(providers, providerID)
205 }
205 }
206 }
206 }
207
207
internal/tui/keys.go+2−2
@@ -61,8 +61,8 @@ func (k KeyMap) FullHelp() [][]key.Binding {
61 }
61 }
62 }
62 }
63
63
64− for i := 0; i < len(cleaned); i += 2 {
64+ for i := 0; i < len(cleaned); i += 3 {
65− end := min(i+2, len(cleaned))
65+ end := min(i+3, len(cleaned))
66 m = append(m, cleaned[i:end])
66 m = append(m, cleaned[i:end])
67 }
67 }
68 return m
68 return m
internal/tui/page/chat/chat.go+32−5
@@ -3,6 +3,7 @@ package chat
3import (
3import (
4 "context"
4 "context"
5 "strings"
5 "strings"
6+ "time"
6
7
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,6 +34,7 @@ type (
33 ChatFocusedMsg struct {
34 ChatFocusedMsg struct {
34 Focused bool // True if the chat input is focused, false otherwise
35 Focused bool // True if the chat input is focused, false otherwise
35 }
36 }
37+ CancelTimerExpiredMsg struct{}
36)
38)
37
39
38type ChatPage interface {
40type ChatPage interface {
@@ -57,6 +59,8 @@ type chatPage struct {
57 showDetails bool // Show details in the header
59 showDetails bool // Show details in the header
58 header header.Header
60 header header.Header
59 compactSidebar layout.Container
61 compactSidebar layout.Container
62+
63+ cancelPending bool // True if ESC was pressed once and waiting for second press
60}
64}
61
65
62func (p *chatPage) Init() tea.Cmd {
66func (p *chatPage) Init() tea.Cmd {
@@ -67,9 +71,19 @@ func (p *chatPage) Init() tea.Cmd {
67 )
71 )
68}
72}
69
73
74+// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
75+func (p *chatPage) cancelTimerCmd() tea.Cmd {
76+ return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
77+ return CancelTimerExpiredMsg{}
78+ })
79+}
80+
70func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
81func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71 var cmds []tea.Cmd
82 var cmds []tea.Cmd
72 switch msg := msg.(type) {
83 switch msg := msg.(type) {
84+ case CancelTimerExpiredMsg:
85+ p.cancelPending = false
86+ return p, nil
73 case tea.WindowSizeMsg:
87 case tea.WindowSizeMsg:
74 h, cmd := p.header.Update(msg)
88 h, cmd := p.header.Update(msg)
75 cmds = append(cmds, cmd)
89 cmds = append(cmds, cmd)
@@ -181,10 +195,16 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
181 return p, tea.Batch(cmds...)
195 return p, tea.Batch(cmds...)
182 case key.Matches(msg, p.keyMap.Cancel):
196 case key.Matches(msg, p.keyMap.Cancel):
183 if p.session.ID != "" {
197 if p.session.ID != "" {
184− // Cancel the current session's generation process
198+ if p.cancelPending {
185− // This allows users to interrupt long-running operations
199+ // Second ESC press - actually cancel the session
200+ p.cancelPending = false
186− p.app.CoderAgent.Cancel(p.session.ID)
201+ p.app.CoderAgent.Cancel(p.session.ID)
187− return p, nil
202+ return p, nil
203+ } else {
204+ // First ESC press - start the timer
205+ p.cancelPending = true
206+ return p, p.cancelTimerCmd()
207+ }
188 }
208 }
189 case key.Matches(msg, p.keyMap.Details):
209 case key.Matches(msg, p.keyMap.Details):
190 if p.session.ID == "" || !p.compactMode {
210 if p.session.ID == "" || !p.compactMode {
@@ -336,7 +356,14 @@ func (p *chatPage) Bindings() []key.Binding {
336 p.keyMap.AddAttachment,
356 p.keyMap.AddAttachment,
337 }
357 }
338 if p.app.CoderAgent.IsBusy() {
358 if p.app.CoderAgent.IsBusy() {
359+ cancelBinding := p.keyMap.Cancel
360+ if p.cancelPending {
361+ cancelBinding = key.NewBinding(
362+ key.WithKeys("esc"),
363+ key.WithHelp("esc", "press again to cancel"),
364+ )
365+ }
339− bindings = append([]key.Binding{p.keyMap.Cancel}, bindings...)
366+ bindings = append([]key.Binding{cancelBinding}, bindings...)
340 }
367 }
341
368
342 if p.chatFocused {
369 if p.chatFocused {
internal/tui/styles/crush.go+8−4
@@ -14,9 +14,10 @@ func NewCrushTheme() *Theme {
14 Tertiary: charmtone.Bok,
14 Tertiary: charmtone.Bok,
15 Accent: charmtone.Zest,
15 Accent: charmtone.Zest,
16 // Backgrounds
16 // Backgrounds
17− BgBase: charmtone.Pepper,
17+ BgBase: charmtone.Pepper,
18+ BgBaseLighter: Lighten(charmtone.Pepper, 2),
18− BgSubtle: charmtone.Charcoal,
19+ BgSubtle: charmtone.Charcoal,
19− BgOverlay: charmtone.Iron,
20+ BgOverlay: charmtone.Iron,
20
21
21 // Foregrounds
22 // Foregrounds
22 FgBase: charmtone.Ash,
23 FgBase: charmtone.Ash,
@@ -38,7 +39,10 @@ func NewCrushTheme() *Theme {
38 // Colors
39 // Colors
39 White: charmtone.Butter,
40 White: charmtone.Butter,
40
41
42+ BlueLight: charmtone.Sardine,
41− Blue: charmtone.Malibu,
43+ Blue: charmtone.Malibu,
44+
45+ Yellow: charmtone.Mustard,
42
46
43 Green: charmtone.Julep,
47 Green: charmtone.Julep,
44 GreenDark: charmtone.Guac,
48 GreenDark: charmtone.Guac,
internal/tui/styles/theme.go+55−37
@@ -10,6 +10,7 @@ import (
10 "github.com/charmbracelet/bubbles/v2/textarea"
10 "github.com/charmbracelet/bubbles/v2/textarea"
11 "github.com/charmbracelet/bubbles/v2/textinput"
11 "github.com/charmbracelet/bubbles/v2/textinput"
12 tea "github.com/charmbracelet/bubbletea/v2"
12 tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/crush/internal/exp/diffview"
13 "github.com/charmbracelet/glamour/v2/ansi"
14 "github.com/charmbracelet/glamour/v2/ansi"
14 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/lucasb-eyer/go-colorful"
16 "github.com/lucasb-eyer/go-colorful"
@@ -31,9 +32,10 @@ type Theme struct {
31 Tertiary color.Color
32 Tertiary color.Color
32 Accent color.Color
33 Accent color.Color
33
34
35+ BgBase color.Color
34− BgBase color.Color
36+ BgBaseLighter color.Color
35− BgSubtle color.Color
37+ BgSubtle color.Color
36− BgOverlay color.Color
38+ BgOverlay color.Color
37
39
38 FgBase color.Color
40 FgBase color.Color
39 FgMuted color.Color
41 FgMuted color.Color
@@ -52,8 +54,13 @@ type Theme struct {
52 // Colors
54 // Colors
53 // White
55 // White
54 White color.Color
56 White color.Color
57+
55 // Blues
58 // Blues
59+ BlueLight color.Color
60+ Blue color.Color
61+
62+ // Yellows
56− Blue color.Color
63+ Yellow color.Color
57
64
58 // Greens
65 // Greens
59 Green color.Color
66 Green color.Color
@@ -65,26 +72,9 @@ type Theme struct {
65 RedDark color.Color
72 RedDark color.Color
66 RedLight color.Color
73 RedLight color.Color
67
74
68− // TODO: add any others needed
69−
70 styles *Styles
75 styles *Styles
71}
76}
72
77
73−type Diff struct {
74− Added color.Color
75− Removed color.Color
76− Context color.Color
77− HunkHeader color.Color
78− HighlightAdded color.Color
79− HighlightRemoved color.Color
80− AddedBg color.Color
81− RemovedBg color.Color
82− ContextBg color.Color
83− LineNumber color.Color
84− AddedLineNumberBg color.Color
85− RemovedLineNumberBg color.Color
86−}
87−
88type Styles struct {
78type Styles struct {
89 Base lipgloss.Style
79 Base lipgloss.Style
90 SelectedBase lipgloss.Style
80 SelectedBase lipgloss.Style
@@ -112,7 +102,7 @@ type Styles struct {
112 Help help.Styles
102 Help help.Styles
113
103
114 // Diff
104 // Diff
115− Diff Diff
105+ Diff diffview.Style
116
106
117 // FilePicker
107 // FilePicker
118 FilePicker filepicker.Styles
108 FilePicker filepicker.Styles
@@ -421,22 +411,50 @@ func (t *Theme) buildStyles() *Styles {
421 FullSeparator: base.Foreground(t.Border),
411 FullSeparator: base.Foreground(t.Border),
422 },
412 },
423
413
424− // TODO: Fix this this is bad
414+ Diff: diffview.Style{
425− Diff: Diff{
415+ DividerLine: diffview.LineStyle{
426− Added: t.Green,
416+ LineNumber: lipgloss.NewStyle().
427− Removed: t.Red,
417+ Foreground(t.FgHalfMuted).
428− Context: t.FgSubtle,
418+ Background(t.BgBaseLighter),
429− HunkHeader: t.FgSubtle,
419+ Code: lipgloss.NewStyle().
430− HighlightAdded: t.GreenLight,
420+ Foreground(t.FgHalfMuted).
431− HighlightRemoved: t.RedLight,
421+ Background(t.BgBaseLighter),
432− AddedBg: t.GreenDark,
422+ },
433− RemovedBg: t.RedDark,
423+ MissingLine: diffview.LineStyle{
434− ContextBg: t.BgSubtle,
424+ LineNumber: lipgloss.NewStyle().
425+ Background(t.BgBaseLighter),
426+ Code: lipgloss.NewStyle().
427+ Background(t.BgBaseLighter),
428+ },
429+ EqualLine: diffview.LineStyle{
430+ LineNumber: lipgloss.NewStyle().
431+ Foreground(t.FgMuted).
432+ Background(t.BgBase),
433+ Code: lipgloss.NewStyle().
434+ Foreground(t.FgMuted).
435+ Background(t.BgBase),
436+ },
437+ InsertLine: diffview.LineStyle{
438+ LineNumber: lipgloss.NewStyle().
439+ Foreground(lipgloss.Color("#629657")).
440+ Background(lipgloss.Color("#2b322a")),
441+ Symbol: lipgloss.NewStyle().
442+ Foreground(lipgloss.Color("#629657")).
443+ Background(lipgloss.Color("#323931")),
444+ Code: lipgloss.NewStyle().
445+ Background(lipgloss.Color("#323931")),
446+ },
447+ DeleteLine: diffview.LineStyle{
435− LineNumber: t.FgMuted,
448+ LineNumber: lipgloss.NewStyle().
436− AddedLineNumberBg: t.GreenDark,
449+ Foreground(lipgloss.Color("#a45c59")).
437− RemovedLineNumberBg: t.RedDark,
450+ Background(lipgloss.Color("#312929")),
451+ Symbol: lipgloss.NewStyle().
452+ Foreground(lipgloss.Color("#a45c59")).
453+ Background(lipgloss.Color("#383030")),
454+ Code: lipgloss.NewStyle().
455+ Background(lipgloss.Color("#383030")),
456+ },
438 },
457 },
439−
440 FilePicker: filepicker.Styles{
458 FilePicker: filepicker.Styles{
441 DisabledCursor: base.Foreground(t.FgMuted),
459 DisabledCursor: base.Foreground(t.FgMuted),
442 Cursor: base.Foreground(t.FgBase),
460 Cursor: base.Foreground(t.FgBase),
internal/tui/tui.go+1−7
@@ -94,12 +94,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
94
94
95 switch msg := msg.(type) {
95 switch msg := msg.(type) {
96 case tea.KeyboardEnhancementsMsg:
96 case tea.KeyboardEnhancementsMsg:
97− logging.Info(
98− "Keyboard enhancements detected",
99− "Disambiguation", msg.SupportsKeyDisambiguation(),
100− "ReleaseKeys", msg.SupportsKeyReleases(),
101− "UniformKeys", msg.SupportsUniformKeyLayout(),
102− )
103 return a, nil
97 return a, nil
104 case tea.WindowSizeMsg:
98 case tea.WindowSizeMsg:
105 return a, a.handleWindowResize(msg.Width, msg.Height)
99 return a, a.handleWindowResize(msg.Width, msg.Height)
@@ -260,7 +254,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
260 var cmds []tea.Cmd
254 var cmds []tea.Cmd
261 a.wWidth, a.wHeight = width, height
255 a.wWidth, a.wHeight = width, height
262 if a.showingFullHelp {
256 if a.showingFullHelp {
263− height -= 3
257+ height -= 4
264 } else {
258 } else {
265 height -= 2
259 height -= 2
266 }
260 }
todos.md+9
@@ -20,7 +20,16 @@
20- [ ] Parallel tool calls and permissions
20- [ ] Parallel tool calls and permissions
21 - [ ] Run the tools in parallel and add results in parallel
21 - [ ] Run the tools in parallel and add results in parallel
22 - [ ] Show multiple permissions dialogs
22 - [ ] Show multiple permissions dialogs
23+- [ ] Add another space around buttons
24+- [ ] Completions
25+ - [ ] Should change the help to show the completions stuff
26+ - [ ] Should make it wider
27+ - [ ] Tab and ctrl+y should accept
28+ - [ ] Words should line up
29+ - [ ] If there are no completions and cick tab/ctrl+y/enter it should close it
23- [ ] Investigate messages issues
30- [ ] Investigate messages issues
31+ - [ ] Make the agent separator look like the
32+ - [ ] Cleanup tool calls (watch all states)
24 - [ ] Weird behavior sometimes the message does not update
33 - [ ] Weird behavior sometimes the message does not update
25 - [ ] Message length (I saw the message go beyond the correct length when there are errors)
34 - [ ] Message length (I saw the message go beyond the correct length when there are errors)
26 - [ ] Address UX issues
35 - [ ] Address UX issues