Detailed changes
@@ -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"]}
+{"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"}
@@ -32,7 +32,6 @@ require (
github.com/pressly/goose/v3 v3.24.2
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sahilm/fuzzy v0.1.1
- github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
@@ -105,6 +104,7 @@ require (
github.com/rivo/uniseg v0.4.7
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
@@ -163,4 +163,5 @@ func (app *App) Shutdown() {
}
cancel()
}
+ app.CoderAgent.CancelAll()
}
@@ -50,6 +50,7 @@ type Service interface {
Model() models.Model
Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
Cancel(sessionID string)
+ CancelAll()
IsSessionBusy(sessionID string) bool
IsBusy() bool
Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error)
@@ -698,6 +699,13 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
return nil
}
+func (a *agent) CancelAll() {
+ a.activeRequests.Range(func(key, value any) bool {
+ a.Cancel(key.(string)) // key is sessionID
+ return true
+ })
+}
+
func createAgentProvider(agentName config.AgentName) (provider.Provider, error) {
cfg := config.Get()
agentConfig, ok := cfg.Agents[agentName]
@@ -30,10 +30,6 @@ You are operating as and within the Crush CLI, a terminal-based agentic coding a
You can:
- Receive user prompts, project context, and files.
- Stream responses and emit function calls (e.g., shell commands, code edits).
-- Apply patches, run commands, and manage user approvals based on policy.
-- Work inside a sandboxed, git-backed workspace with rollback support.
-- Log telemetry so sessions can be replayed or inspected later.
-- More details on your functionality are available at "crush --help"
You 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:
- If completing the user's task DOES NOT require writing or modifying files (e.g., the user asks a question about the code base):
- Respond in a friendly tune as a remote teammate, who is knowledgeable, capable and eager to help with coding.
- When your task involves writing or modifying files:
- - 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.
+ - 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.
- Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
- 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.
- If you send a path not including the working dir, the working dir will be prepended to it.
- Remember the user does not see the full output of tools
+- NEVER use emojis in your responses
`
const 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.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
+
# Memory
If the current working directory contains a file called CRUSH.md, it will be automatically added to your context. This file serves multiple purposes:
1. 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
<example>
user: write tests for new feature
-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]
+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]
</example>
# Proactiveness
@@ -165,6 +163,8 @@ NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTAN
- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in parallel.
- 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.
+VERY IMPORTANT NEVER use emojis in your responses.
+
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.`
func getEnvironmentInfo() string {
@@ -272,7 +272,7 @@ func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, me
assistantIndex = i
}
} else if tc, ok := item.(messages.ToolCallCmp); ok {
- if tc.ParentMessageId() == messageID {
+ if tc.ParentMessageID() == messageID {
toolCalls[i] = tc
}
}
@@ -295,9 +295,17 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
assistantIndex,
messages.NewMessageCmp(
msg,
- messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
),
)
+
+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+ m.listCmp.AppendItem(
+ messages.NewAssistantSection(
+ msg,
+ time.Unix(m.lastUserMessageTime, 0),
+ ),
+ )
+ }
} else if hasToolCallsOnly {
m.listCmp.DeleteItem(assistantIndex)
}
@@ -347,7 +355,6 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
cmd := m.listCmp.AppendItem(
messages.NewMessageCmp(
msg,
- messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
),
)
cmds = append(cmds, cmd)
@@ -412,6 +419,9 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
case message.Assistant:
uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+ uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
+ }
}
}
@@ -428,7 +438,6 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
uiMessages,
messages.NewMessageCmp(
msg,
- messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
),
)
}
@@ -8,13 +8,14 @@ import (
"github.com/charmbracelet/bubbles/v2/spinner"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/llm/models"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/llm/models"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/tui/components/anim"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
)
@@ -37,32 +38,17 @@ type messageCmp struct {
focused bool // Focus state for border styling
// Core message data and state
- message message.Message // The underlying message content
- spinning bool // Whether to show loading animation
- anim util.Model // Animation component for loading states
- lastUserMessageTime time.Time // Used for calculating response duration
-}
-
-// MessageOption provides functional options for configuring message components
-type MessageOption func(*messageCmp)
-
-// WithLastUserMessageTime sets the timestamp of the last user message
-// for calculating assistant response duration
-func WithLastUserMessageTime(t time.Time) MessageOption {
- return func(m *messageCmp) {
- m.lastUserMessageTime = t
- }
+ message message.Message // The underlying message content
+ spinning bool // Whether to show loading animation
+ anim util.Model // Animation component for loading states
}
// NewMessageCmp creates a new message component with the given message and options
-func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
+func NewMessageCmp(msg message.Message) MessageCmp {
m := &messageCmp{
message: msg,
anim: anim.New(15, ""),
}
- for _, opt := range opts {
- opt(m)
- }
return m
}
@@ -145,32 +131,10 @@ func (msg *messageCmp) style() lipgloss.Style {
// renderAssistantMessage renders assistant messages with optional footer information.
// Shows model name, response time, and finish reason when the message is complete.
func (m *messageCmp) renderAssistantMessage() string {
- t := styles.CurrentTheme()
parts := []string{
m.markdownContent(),
}
- finished := m.message.IsFinished()
- finishData := m.message.FinishPart()
- // Only show the footer if the message is not a tool call
- if finished && finishData.Reason != message.FinishReasonToolUse {
- infoMsg := ""
- switch finishData.Reason {
- case message.FinishReasonEndTurn:
- finishTime := time.Unix(finishData.Time, 0)
- duration := finishTime.Sub(m.lastUserMessageTime)
- infoMsg = duration.String()
- case message.FinishReasonCanceled:
- infoMsg = "canceled"
- case message.FinishReasonError:
- infoMsg = "error"
- case message.FinishReasonPermissionDenied:
- infoMsg = "permission denied"
- }
- assistant := t.S().Muted.Render(fmt.Sprintf("%s %s (%s)", styles.ModelIcon, models.SupportedModels[m.message.Model].Name, infoMsg))
- parts = append(parts, core.Section(assistant, m.textWidth()))
- }
-
joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
return m.style().Render(joined)
}
@@ -200,7 +164,7 @@ func (m *messageCmp) renderUserMessage() string {
parts = append(parts, "", strings.Join(attachments, ""))
}
joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
- return m.style().MarginBottom(1).Render(joined)
+ return m.style().Render(joined)
}
// toMarkdown converts text content to rendered markdown using the configured renderer
@@ -225,7 +189,7 @@ func (m *messageCmp) markdownContent() string {
} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
// Sometimes the LLMs respond with no content when they think the previous tool result
// provides the requested question
- content = "*Finished without output*"
+ content = ""
} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
content = "*Canceled*"
}
@@ -287,3 +251,59 @@ func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
func (m *messageCmp) Spinning() bool {
return m.spinning
}
+
+type AssistantSection interface {
+ util.Model
+ layout.Sizeable
+ list.SectionHeader
+}
+type assistantSectionModel struct {
+ width int
+ message message.Message
+ lastUserMessageTime time.Time
+}
+
+func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
+ return &assistantSectionModel{
+ width: 0,
+ message: message,
+ lastUserMessageTime: lastUserMessageTime,
+ }
+}
+
+func (m *assistantSectionModel) Init() tea.Cmd {
+ return nil
+}
+
+func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+ return m, nil
+}
+
+func (m *assistantSectionModel) View() tea.View {
+ t := styles.CurrentTheme()
+ finishData := m.message.FinishPart()
+ finishTime := time.Unix(finishData.Time, 0)
+ duration := finishTime.Sub(m.lastUserMessageTime)
+ infoMsg := t.S().Subtle.Render(duration.String())
+ icon := t.S().Subtle.Render(styles.ModelIcon)
+ model := t.S().Muted.Render(models.SupportedModels[m.message.Model].Name)
+ assistant := fmt.Sprintf("%s %s %s", icon, model, infoMsg)
+ return tea.NewView(
+ t.S().Base.PaddingLeft(2).Render(
+ core.Section(assistant, m.width-2),
+ ),
+ )
+}
+
+func (m *assistantSectionModel) GetSize() (int, int) {
+ return m.width, 1
+}
+
+func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
+ m.width = width
+ return nil
+}
+
+func (m *assistantSectionModel) IsSectionHeader() bool {
+ return true
+}
@@ -111,8 +111,18 @@ func (br baseRenderer) unmarshalParams(input string, target any) error {
return json.Unmarshal([]byte(input), target)
}
+// makeHeader builds the tool call header with status icon and parameters for a nested tool call.
+func (br baseRenderer) makeNestedHeader(v *toolCallCmp, tool string, width int, params ...string) string {
+ t := styles.CurrentTheme()
+ tool = t.S().Base.Foreground(t.FgHalfMuted).Render(tool) + " "
+ return tool + renderParamList(true, width-lipgloss.Width(tool), params...)
+}
+
// makeHeader builds "<Tool>: param (key=value)" and truncates as needed.
func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params ...string) string {
+ if v.isNested {
+ return br.makeNestedHeader(v, tool, width, params...)
+ }
t := styles.CurrentTheme()
icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
if v.result.ToolCallID != "" {
@@ -125,8 +135,8 @@ func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params
icon = t.S().Muted.Render(styles.ToolPending)
}
tool = t.S().Base.Foreground(t.Blue).Render(tool)
- prefix := fmt.Sprintf("%s %s: ", icon, tool)
- return prefix + renderParamList(width-lipgloss.Width(prefix), params...)
+ prefix := fmt.Sprintf("%s %s ", icon, tool)
+ return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...)
}
// renderError provides consistent error rendering
@@ -260,8 +270,10 @@ func (er editRenderer) Render(v *toolCallCmp) string {
formatter := core.DiffFormatter().
Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
After(fsext.PrettyPath(params.FilePath), meta.NewContent).
- Split().
Width(v.textWidth() - 2) // -2 for padding
+ if v.textWidth() > 120 {
+ formatter = formatter.Split()
+ }
return formatter.String()
})
}
@@ -475,25 +487,45 @@ type agentRenderer struct {
baseRenderer
}
+func RoundedEnumerator(children tree.Children, index int) string {
+ if children.Length()-1 == index {
+ return " β°ββ"
+ }
+ return " βββ"
+}
+
// Render displays agent task parameters and result content
func (tr agentRenderer) Render(v *toolCallCmp) string {
+ t := styles.CurrentTheme()
var params agent.AgentParams
if err := tr.unmarshalParams(v.call.Input, ¶ms); err != nil {
return tr.renderError(v, "Invalid task parameters")
}
prompt := params.Prompt
prompt = strings.ReplaceAll(prompt, "\n", " ")
- args := newParamBuilder().addMain(prompt).build()
- header := tr.makeHeader(v, "Task", v.textWidth(), args...)
- t := tree.Root(header)
+ header := tr.makeHeader(v, "Agent", v.textWidth())
+ taskTag := t.S().Base.Padding(0, 1).MarginLeft(1).Background(t.BlueLight).Foreground(t.White).Render("Task")
+ remainingWidth := v.textWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2 // -2 for padding
+ prompt = t.S().Muted.Width(remainingWidth).Render(prompt)
+ header = lipgloss.JoinVertical(
+ lipgloss.Left,
+ header,
+ "",
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ taskTag,
+ " ",
+ prompt,
+ ),
+ )
+ childTools := tree.Root(header)
for _, call := range v.nestedToolCalls {
- t.Child(call.View())
+ childTools.Child(call.View())
}
-
parts := []string{
- t.Enumerator(tree.RoundedEnumerator).String(),
+ childTools.Enumerator(RoundedEnumerator).String(),
}
if v.result.ToolCallID == "" {
v.spinning = true
@@ -516,7 +548,8 @@ func (tr agentRenderer) Render(v *toolCallCmp) string {
}
// renderParamList renders params, params[0] (params[1]=params[2] ....)
-func renderParamList(paramsWidth int, params ...string) string {
+func renderParamList(nested bool, paramsWidth int, params ...string) string {
+ t := styles.CurrentTheme()
if len(params) == 0 {
return ""
}
@@ -526,7 +559,10 @@ func renderParamList(paramsWidth int, params ...string) string {
}
if len(params) == 1 {
- return mainParam
+ if nested {
+ return t.S().Muted.Render(mainParam)
+ }
+ return t.S().Subtle.Render(mainParam)
}
otherParams := params[1:]
// create pairs of key/value
@@ -547,15 +583,21 @@ func renderParamList(paramsWidth int, params ...string) string {
partsRendered := strings.Join(parts, ", ")
remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
if remainingWidth < 30 {
+ if nested {
+ return t.S().Muted.Render(mainParam)
+ }
// No space for the params, just show the main
- return mainParam
+ return t.S().Subtle.Render(mainParam)
}
if len(parts) > 0 {
mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
}
- return ansi.Truncate(mainParam, paramsWidth, "...")
+ if nested {
+ return t.S().Muted.Render(ansi.Truncate(mainParam, paramsWidth, "..."))
+ }
+ return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "..."))
}
// earlyState returns immediatelyβrendered error/cancelled/ongoing states.
@@ -580,7 +622,7 @@ func earlyState(header string, v *toolCallCmp) (string, bool) {
func joinHeaderBody(header, body string) string {
t := styles.CurrentTheme()
body = t.S().Base.PaddingLeft(2).Render(body)
- return lipgloss.JoinVertical(lipgloss.Left, header, body, "")
+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body, "")
}
func renderPlainContent(v *toolCallCmp, content string) string {
@@ -600,46 +642,56 @@ func renderPlainContent(v *toolCallCmp, content string) string {
}
out = append(out, t.S().Muted.
Width(width).
- Background(t.BgSubtle).
+ Background(t.BgBaseLighter).
Render(ln))
}
if len(lines) > responseContextHeight {
out = append(out, t.S().Muted.
- Background(t.BgSubtle).
+ Background(t.BgBaseLighter).
Width(width).
Render(fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight)))
}
return strings.Join(out, "\n")
}
+func pad(v any, width int) string {
+ s := fmt.Sprintf("%v", v)
+ w := ansi.StringWidth(s)
+ if w >= width {
+ return s
+ }
+ return strings.Repeat(" ", width-w) + s
+}
+
func renderCodeContent(v *toolCallCmp, path, content string, offset int) string {
t := styles.CurrentTheme()
truncated := truncateHeight(content, responseContextHeight)
- highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgSubtle)
+ highlighted, _ := highlight.SyntaxHighlight(truncated, path, t.BgBase)
lines := strings.Split(highlighted, "\n")
if len(strings.Split(content, "\n")) > responseContextHeight {
lines = append(lines, t.S().Muted.
- Background(t.BgSubtle).
- Width(v.textWidth()-2).
- Render(fmt.Sprintf("... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
+ Background(t.BgBase).
+ Render(fmt.Sprintf(" β¦(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight)))
}
+ maxLineNumber := len(lines) + offset
+ padding := lipgloss.Width(fmt.Sprintf("%d", maxLineNumber))
for i, ln := range lines {
- num := t.S().Muted.
- Background(t.BgSubtle).
- PaddingLeft(4).
- PaddingRight(2).
- Render(fmt.Sprintf("%d", i+1+offset))
- w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding
+ num := t.S().Base.
+ Foreground(t.FgMuted).
+ Background(t.BgBase).
+ PaddingRight(1).
+ PaddingLeft(1).
+ Render(pad(i+1+offset, padding))
+ w := v.textWidth() - 10 - lipgloss.Width(num) // -4 for left padding
lines[i] = lipgloss.JoinHorizontal(lipgloss.Left,
num,
t.S().Base.
- Width(w).
- Background(t.BgSubtle).
- Render(v.fit(ln, w)))
+ PaddingLeft(1).
+ Render(v.fit(ln, w-1)))
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
@@ -648,7 +700,7 @@ func (v *toolCallCmp) renderToolError() string {
t := styles.CurrentTheme()
err := strings.ReplaceAll(v.result.Content, "\n", " ")
err = fmt.Sprintf("Error: %s", err)
- return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()))
+ return t.S().Base.Foreground(t.Error).Render(v.fit(err, v.textWidth()-2))
}
func truncateHeight(s string, h int) string {
@@ -662,7 +714,7 @@ func truncateHeight(s string, h int) string {
func prettifyToolName(name string) string {
switch name {
case agent.AgentToolName:
- return "Task"
+ return "Agent"
case tools.BashToolName:
return "Bash"
case tools.EditToolName:
@@ -25,7 +25,7 @@ type ToolCallCmp interface {
SetToolResult(message.ToolResult) // Update tool result
SetToolCall(message.ToolCall) // Update tool call
SetCancelled() // Mark as cancelled
- ParentMessageId() string // Get parent message ID
+ ParentMessageID() string // Get parent message ID
Spinning() bool // Animation state for pending tools
GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
@@ -83,10 +83,10 @@ func WithToolCallNestedCalls(calls []ToolCallCmp) ToolCallOption {
// NewToolCallCmp creates a new tool call component with the given parent message ID,
// tool call, and optional configuration
-func NewToolCallCmp(parentMessageId string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
+func NewToolCallCmp(parentMessageID string, tc message.ToolCall, opts ...ToolCallOption) ToolCallCmp {
m := &toolCallCmp{
call: tc,
- parentMessageID: parentMessageId,
+ parentMessageID: parentMessageID,
}
for _, opt := range opts {
opt(m)
@@ -137,9 +137,6 @@ func (m *toolCallCmp) View() tea.View {
box := m.style()
if !m.call.Finished && !m.cancelled {
- if m.isNested {
- return tea.NewView(box.Render(m.renderPending()))
- }
return tea.NewView(box.Render(m.renderPending()))
}
@@ -166,8 +163,8 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
}
}
-// ParentMessageId returns the ID of the message that initiated this tool call
-func (m *toolCallCmp) ParentMessageId() string {
+// ParentMessageID returns the ID of the message that initiated this tool call
+func (m *toolCallCmp) ParentMessageID() string {
return m.parentMessageID
}
@@ -210,9 +207,13 @@ func (m *toolCallCmp) SetIsNested(isNested bool) {
// renderPending displays the tool name with a loading animation for pending tool calls
func (m *toolCallCmp) renderPending() string {
t := styles.CurrentTheme()
+ if m.isNested {
+ tool := t.S().Base.Foreground(t.FgHalfMuted).Render(prettifyToolName(m.call.Name))
+ return fmt.Sprintf("%s %s", tool, m.anim.View())
+ }
icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
- return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
+ return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
}
// style returns the lipgloss style for the tool call component.
@@ -229,14 +230,17 @@ func (m *toolCallCmp) style() lipgloss.Style {
// textWidth calculates the available width for text content,
// accounting for borders and padding
func (m *toolCallCmp) textWidth() int {
+ if m.isNested {
+ return m.width - 6
+ }
return m.width - 5 // take into account the border and PaddingLeft
}
// fit truncates content to fit within the specified width with ellipsis
func (m *toolCallCmp) fit(content string, width int) string {
t := styles.CurrentTheme()
- lineStyle := t.S().Muted.Background(t.BgSubtle)
- dots := lineStyle.Render("...")
+ lineStyle := t.S().Muted
+ dots := lineStyle.Render("β¦")
return ansi.Truncate(content, width, dots)
}
@@ -288,9 +288,9 @@ func (m *sidebarCmp) filesBlock() string {
})
for _, file := range files {
- // Extract just the filename from the path
-
- // Create status indicators for additions/deletions
+ if file.Additions == 0 && file.Deletions == 0 {
+ continue // skip files with no changes
+ }
var statusParts []string
if file.Additions > 0 {
statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
@@ -148,8 +148,9 @@ func SelectableButtons(buttons []ButtonOpts, spacing string) string {
}
func DiffFormatter() *diffview.DiffView {
+ t := styles.CurrentTheme()
formatDiff := diffview.New()
style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
- diff := formatDiff.ChromaStyle(style)
+ diff := formatDiff.ChromaStyle(style).Style(t.S().Diff)
return diff
}
@@ -32,10 +32,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("k"),
),
UpOneItem: key.NewBinding(
- key.WithKeys("shift+up", "shift+k"),
+ key.WithKeys("shift+up", "K"),
),
DownOneItem: key.NewBinding(
- key.WithKeys("shift+down", "shift+j"),
+ key.WithKeys("shift+down", "J"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d"),
@@ -47,7 +47,7 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("g", "home"),
),
End: key.NewBinding(
- key.WithKeys("shift+g", "end"),
+ key.WithKeys("G", "end"),
),
}
}
@@ -749,8 +749,8 @@ func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
func (m *model) goToBottom() tea.Cmd {
cmds := []tea.Cmd{m.blurSelected()}
m.viewState.reverse = true
+ m.selectionState.selectedIndex = m.findLastSelectableItem()
if m.isFocused {
- m.selectionState.selectedIndex = m.findLastSelectableItem()
cmds = append(cmds, m.focusSelected())
}
m.ResetView()
@@ -764,7 +764,9 @@ func (m *model) goToTop() tea.Cmd {
cmds := []tea.Cmd{m.blurSelected()}
m.viewState.reverse = false
m.selectionState.selectedIndex = m.findFirstSelectableItem()
- cmds = append(cmds, m.focusSelected())
+ if m.isFocused {
+ cmds = append(cmds, m.focusSelected())
+ }
m.ResetView()
return tea.Batch(cmds...)
}
@@ -1,6 +1,7 @@
package status
import (
+ "strings"
"time"
"github.com/charmbracelet/bubbles/v2/help"
@@ -10,6 +11,8 @@ import (
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
)
type StatusCmp interface {
@@ -85,6 +88,7 @@ func (m *statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
TTL: msg.Payload.PersistTime,
}
}
+ return m, m.clearMessageCmd(m.info.TTL)
}
}
return m, nil
@@ -94,18 +98,32 @@ func (m *statusCmp) View() tea.View {
t := styles.CurrentTheme()
status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(m.keyMap))
if m.info.Msg != "" {
- switch m.info.Type {
- case util.InfoTypeError:
- status = t.S().Base.Background(t.Error).Padding(0, 1).Width(m.width).Render(m.info.Msg)
- case util.InfoTypeWarn:
- status = t.S().Base.Background(t.Warning).Padding(0, 1).Width(m.width).Render(m.info.Msg)
- default:
- status = t.S().Base.Background(t.Info).Padding(0, 1).Width(m.width).Render(m.info.Msg)
- }
+ status = m.infoMsg()
}
return tea.NewView(status)
}
+func (m *statusCmp) infoMsg() string {
+ t := styles.CurrentTheme()
+ message := ""
+ infoType := ""
+ switch m.info.Type {
+ case util.InfoTypeError:
+ infoType = t.S().Base.Background(t.Red).Padding(0, 1).Render("ERROR")
+ width := m.width - lipgloss.Width(infoType)
+ message = t.S().Base.Background(t.Error).Foreground(t.White).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "β¦"))
+ case util.InfoTypeWarn:
+ infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Yellow).Padding(0, 1).Render("WARNING")
+ width := m.width - lipgloss.Width(infoType)
+ message = t.S().Base.Foreground(t.BgOverlay).Background(t.Warning).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "β¦"))
+ default:
+ infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
+ width := m.width - lipgloss.Width(infoType)
+ message = t.S().Base.Background(t.Success).Foreground(t.White).Padding(0, 1).Width(width).Render(ansi.Truncate(m.info.Msg, width, "β¦"))
+ }
+ return strings.Join([]string{infoType, message}, "")
+}
+
func (m *statusCmp) ToggleFullHelp() {
m.help.ShowAll = !m.help.ShowAll
}
@@ -119,7 +137,7 @@ func NewStatusCmp(keyMap help.KeyMap) StatusCmp {
help := help.New()
help.Styles = t.S().Help
return &statusCmp{
- messageTTL: 10 * time.Second,
+ messageTTL: 5 * time.Second,
help: help,
keyMap: keyMap,
}
@@ -211,15 +211,6 @@ func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
return cursor
}
-func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
- t := styles.CurrentTheme()
- return t.S().Base.
- Width(c.width).
- Padding(1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus)
-}
-
func (c *commandArgumentsDialogCmp) Position() (int, int) {
row := c.wHeight / 2
row -= c.wHeight / 2
@@ -193,15 +193,15 @@ func (m *modelDialogCmp) listHeight() int {
func GetSelectedModel(cfg *config.Config) models.Model {
agentCfg := cfg.Agents[config.AgentCoder]
- selectedModelId := agentCfg.Model
- return models.SupportedModels[selectedModelId]
+ selectedModelID := agentCfg.Model
+ return models.SupportedModels[selectedModelID]
}
func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
var providers []models.ModelProvider
- for providerId, provider := range cfg.Providers {
+ for providerID, provider := range cfg.Providers {
if !provider.Disabled {
- providers = append(providers, providerId)
+ providers = append(providers, providerID)
}
}
@@ -61,8 +61,8 @@ func (k KeyMap) FullHelp() [][]key.Binding {
}
}
- for i := 0; i < len(cleaned); i += 2 {
- end := min(i+2, len(cleaned))
+ for i := 0; i < len(cleaned); i += 3 {
+ end := min(i+3, len(cleaned))
m = append(m, cleaned[i:end])
}
return m
@@ -3,6 +3,7 @@ package chat
import (
"context"
"strings"
+ "time"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,6 +34,7 @@ type (
ChatFocusedMsg struct {
Focused bool // True if the chat input is focused, false otherwise
}
+ CancelTimerExpiredMsg struct{}
)
type ChatPage interface {
@@ -57,6 +59,8 @@ type chatPage struct {
showDetails bool // Show details in the header
header header.Header
compactSidebar layout.Container
+
+ cancelPending bool // True if ESC was pressed once and waiting for second press
}
func (p *chatPage) Init() tea.Cmd {
@@ -67,9 +71,19 @@ func (p *chatPage) Init() tea.Cmd {
)
}
+// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
+func (p *chatPage) cancelTimerCmd() tea.Cmd {
+ return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
+ return CancelTimerExpiredMsg{}
+ })
+}
+
func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
+ case CancelTimerExpiredMsg:
+ p.cancelPending = false
+ return p, nil
case tea.WindowSizeMsg:
h, cmd := p.header.Update(msg)
cmds = append(cmds, cmd)
@@ -181,10 +195,16 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, tea.Batch(cmds...)
case key.Matches(msg, p.keyMap.Cancel):
if p.session.ID != "" {
- // Cancel the current session's generation process
- // This allows users to interrupt long-running operations
- p.app.CoderAgent.Cancel(p.session.ID)
- return p, nil
+ if p.cancelPending {
+ // Second ESC press - actually cancel the session
+ p.cancelPending = false
+ p.app.CoderAgent.Cancel(p.session.ID)
+ return p, nil
+ } else {
+ // First ESC press - start the timer
+ p.cancelPending = true
+ return p, p.cancelTimerCmd()
+ }
}
case key.Matches(msg, p.keyMap.Details):
if p.session.ID == "" || !p.compactMode {
@@ -336,7 +356,14 @@ func (p *chatPage) Bindings() []key.Binding {
p.keyMap.AddAttachment,
}
if p.app.CoderAgent.IsBusy() {
- bindings = append([]key.Binding{p.keyMap.Cancel}, bindings...)
+ cancelBinding := p.keyMap.Cancel
+ if p.cancelPending {
+ cancelBinding = key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "press again to cancel"),
+ )
+ }
+ bindings = append([]key.Binding{cancelBinding}, bindings...)
}
if p.chatFocused {
@@ -14,9 +14,10 @@ func NewCrushTheme() *Theme {
Tertiary: charmtone.Bok,
Accent: charmtone.Zest,
// Backgrounds
- BgBase: charmtone.Pepper,
- BgSubtle: charmtone.Charcoal,
- BgOverlay: charmtone.Iron,
+ BgBase: charmtone.Pepper,
+ BgBaseLighter: Lighten(charmtone.Pepper, 2),
+ BgSubtle: charmtone.Charcoal,
+ BgOverlay: charmtone.Iron,
// Foregrounds
FgBase: charmtone.Ash,
@@ -38,7 +39,10 @@ func NewCrushTheme() *Theme {
// Colors
White: charmtone.Butter,
- Blue: charmtone.Malibu,
+ BlueLight: charmtone.Sardine,
+ Blue: charmtone.Malibu,
+
+ Yellow: charmtone.Mustard,
Green: charmtone.Julep,
GreenDark: charmtone.Guac,
@@ -10,6 +10,7 @@ import (
"github.com/charmbracelet/bubbles/v2/textarea"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/exp/diffview"
"github.com/charmbracelet/glamour/v2/ansi"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lucasb-eyer/go-colorful"
@@ -31,9 +32,10 @@ type Theme struct {
Tertiary color.Color
Accent color.Color
- BgBase color.Color
- BgSubtle color.Color
- BgOverlay color.Color
+ BgBase color.Color
+ BgBaseLighter color.Color
+ BgSubtle color.Color
+ BgOverlay color.Color
FgBase color.Color
FgMuted color.Color
@@ -52,8 +54,13 @@ type Theme struct {
// Colors
// White
White color.Color
+
// Blues
- Blue color.Color
+ BlueLight color.Color
+ Blue color.Color
+
+ // Yellows
+ Yellow color.Color
// Greens
Green color.Color
@@ -65,26 +72,9 @@ type Theme struct {
RedDark color.Color
RedLight color.Color
- // TODO: add any others needed
-
styles *Styles
}
-type Diff struct {
- Added color.Color
- Removed color.Color
- Context color.Color
- HunkHeader color.Color
- HighlightAdded color.Color
- HighlightRemoved color.Color
- AddedBg color.Color
- RemovedBg color.Color
- ContextBg color.Color
- LineNumber color.Color
- AddedLineNumberBg color.Color
- RemovedLineNumberBg color.Color
-}
-
type Styles struct {
Base lipgloss.Style
SelectedBase lipgloss.Style
@@ -112,7 +102,7 @@ type Styles struct {
Help help.Styles
// Diff
- Diff Diff
+ Diff diffview.Style
// FilePicker
FilePicker filepicker.Styles
@@ -421,22 +411,50 @@ func (t *Theme) buildStyles() *Styles {
FullSeparator: base.Foreground(t.Border),
},
- // TODO: Fix this this is bad
- Diff: Diff{
- Added: t.Green,
- Removed: t.Red,
- Context: t.FgSubtle,
- HunkHeader: t.FgSubtle,
- HighlightAdded: t.GreenLight,
- HighlightRemoved: t.RedLight,
- AddedBg: t.GreenDark,
- RemovedBg: t.RedDark,
- ContextBg: t.BgSubtle,
- LineNumber: t.FgMuted,
- AddedLineNumberBg: t.GreenDark,
- RemovedLineNumberBg: t.RedDark,
+ Diff: diffview.Style{
+ DividerLine: diffview.LineStyle{
+ LineNumber: lipgloss.NewStyle().
+ Foreground(t.FgHalfMuted).
+ Background(t.BgBaseLighter),
+ Code: lipgloss.NewStyle().
+ Foreground(t.FgHalfMuted).
+ Background(t.BgBaseLighter),
+ },
+ MissingLine: diffview.LineStyle{
+ LineNumber: lipgloss.NewStyle().
+ Background(t.BgBaseLighter),
+ Code: lipgloss.NewStyle().
+ Background(t.BgBaseLighter),
+ },
+ EqualLine: diffview.LineStyle{
+ LineNumber: lipgloss.NewStyle().
+ Foreground(t.FgMuted).
+ Background(t.BgBase),
+ Code: lipgloss.NewStyle().
+ Foreground(t.FgMuted).
+ Background(t.BgBase),
+ },
+ InsertLine: diffview.LineStyle{
+ LineNumber: lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#629657")).
+ Background(lipgloss.Color("#2b322a")),
+ Symbol: lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#629657")).
+ Background(lipgloss.Color("#323931")),
+ Code: lipgloss.NewStyle().
+ Background(lipgloss.Color("#323931")),
+ },
+ DeleteLine: diffview.LineStyle{
+ LineNumber: lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#a45c59")).
+ Background(lipgloss.Color("#312929")),
+ Symbol: lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#a45c59")).
+ Background(lipgloss.Color("#383030")),
+ Code: lipgloss.NewStyle().
+ Background(lipgloss.Color("#383030")),
+ },
},
-
FilePicker: filepicker.Styles{
DisabledCursor: base.Foreground(t.FgMuted),
Cursor: base.Foreground(t.FgBase),
@@ -94,12 +94,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyboardEnhancementsMsg:
- logging.Info(
- "Keyboard enhancements detected",
- "Disambiguation", msg.SupportsKeyDisambiguation(),
- "ReleaseKeys", msg.SupportsKeyReleases(),
- "UniformKeys", msg.SupportsUniformKeyLayout(),
- )
return a, nil
case tea.WindowSizeMsg:
return a, a.handleWindowResize(msg.Width, msg.Height)
@@ -260,7 +254,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
var cmds []tea.Cmd
a.wWidth, a.wHeight = width, height
if a.showingFullHelp {
- height -= 3
+ height -= 4
} else {
height -= 2
}
@@ -20,7 +20,16 @@
- [ ] Parallel tool calls and permissions
- [ ] Run the tools in parallel and add results in parallel
- [ ] Show multiple permissions dialogs
+- [ ] Add another space around buttons
+- [ ] Completions
+ - [ ] Should change the help to show the completions stuff
+ - [ ] Should make it wider
+ - [ ] Tab and ctrl+y should accept
+ - [ ] Words should line up
+ - [ ] If there are no completions and cick tab/ctrl+y/enter it should close it
- [ ] Investigate messages issues
+ - [ ] Make the agent separator look like the
+ - [ ] Cleanup tool calls (watch all states)
- [ ] Weird behavior sometimes the message does not update
- [ ] Message length (I saw the message go beyond the correct length when there are errors)
- [ ] Address UX issues