Merge pull request #41 from charmbracelet/messages-adjustments

Kujtim Hoxha created

All message UI/UX adjustments

Change summary

cspell.json                                           |   2 
go.mod                                                |   2 
internal/app/app.go                                   |   1 
internal/llm/agent/agent.go                           |   8 
internal/llm/prompt/coder.go                          |  12 
internal/tui/components/chat/chat.go                  |  17 +
internal/tui/components/chat/messages/messages.go     | 108 +++++++-----
internal/tui/components/chat/messages/renderer.go     | 114 +++++++++---
internal/tui/components/chat/messages/tool.go         |  26 +-
internal/tui/components/chat/sidebar/sidebar.go       |   6 
internal/tui/components/core/helpers.go               |   3 
internal/tui/components/core/list/keys.go             |   6 
internal/tui/components/core/list/list.go             |   6 
internal/tui/components/core/status/status.go         |  36 +++-
internal/tui/components/dialogs/commands/arguments.go |   9 -
internal/tui/components/dialogs/models/models.go      |   8 
internal/tui/keys.go                                  |   4 
internal/tui/page/chat/chat.go                        |  37 +++
internal/tui/styles/crush.go                          |  12 
internal/tui/styles/theme.go                          |  92 ++++++----
internal/tui/tui.go                                   |   8 
todos.md                                              |   9 +
22 files changed, 342 insertions(+), 184 deletions(-)

Detailed changes

cspell.json πŸ”—

@@ -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"}

go.mod πŸ”—

@@ -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

internal/llm/agent/agent.go πŸ”—

@@ -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]

internal/llm/prompt/coder.go πŸ”—

@@ -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 {

internal/tui/components/chat/chat.go πŸ”—

@@ -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)),
 			),
 		)
 	}

internal/tui/components/chat/messages/messages.go πŸ”—

@@ -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
+}

internal/tui/components/chat/messages/renderer.go πŸ”—

@@ -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, &params); 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:

internal/tui/components/chat/messages/tool.go πŸ”—

@@ -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)
 }
 

internal/tui/components/chat/sidebar/sidebar.go πŸ”—

@@ -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)))

internal/tui/components/core/helpers.go πŸ”—

@@ -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
 }

internal/tui/components/core/list/keys.go πŸ”—

@@ -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"),
 		),
 	}
 }

internal/tui/components/core/list/list.go πŸ”—

@@ -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...)
 }

internal/tui/components/core/status/status.go πŸ”—

@@ -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,
 	}

internal/tui/components/dialogs/commands/arguments.go πŸ”—

@@ -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

internal/tui/components/dialogs/models/models.go πŸ”—

@@ -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)
 		}
 	}
 

internal/tui/keys.go πŸ”—

@@ -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

internal/tui/page/chat/chat.go πŸ”—

@@ -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 {

internal/tui/styles/crush.go πŸ”—

@@ -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,

internal/tui/styles/theme.go πŸ”—

@@ -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),

internal/tui/tui.go πŸ”—

@@ -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
 	}

todos.md πŸ”—

@@ -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