From 81aedaec82cdbca28ad75d5675277a63cbecd469 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 19 Jun 2025 11:06:40 +0200 Subject: [PATCH 01/13] chore: fix list focus, fix files with no changes --- internal/tui/components/chat/sidebar/sidebar.go | 6 +++--- internal/tui/components/core/list/list.go | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 54d9cb6b3ab78aa0d673a6c143b2119d73d08d7f..12beb139071da58168e9c4f07b991c9ad05f7320 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/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))) diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 14a32777117a744b708c280f2d0bc3dde70306c5..79ad6d62b0744650a5d2a0deb5f3b46582704407 100644 --- a/internal/tui/components/core/list/list.go +++ b/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...) } From 771835fdf128e49f93f6039c622ec72824ddeeb9 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 19 Jun 2025 11:41:08 +0200 Subject: [PATCH 02/13] chore: add dedicates diff styles --- .../tui/components/chat/messages/renderer.go | 35 +++++--- internal/tui/components/core/helpers.go | 3 +- internal/tui/styles/crush.go | 7 +- internal/tui/styles/theme.go | 83 +++++++++++-------- 4 files changed, 78 insertions(+), 50 deletions(-) diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 32322ddefe4ebdd42a949263f9e59b752a6a3b3c..56a42c0f93a3f558218cfac080a6b355b00297bd 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -600,45 +600,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)) + num := t.S().Base. + Foreground(t.FgMuted). + Background(t.BgBase). + PaddingRight(1). + PaddingLeft(1). + Render(pad(i+1+offset, padding)) w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, num, t.S().Base. + PaddingLeft(1). Width(w). - Background(t.BgSubtle). Render(v.fit(ln, w))) } return lipgloss.JoinVertical(lipgloss.Left, lines...) diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go index bd14febf1c94577fbd4882248d1344f364464b46..b13a23d868a80518fe6a5079f2053fe2d38463c8 100644 --- a/internal/tui/components/core/helpers.go +++ b/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 } diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go index 41acdfad103e70b19955a84722a27876575d15b4..fca7f5d36f090d2f253260116a84dace4a22bb8a 100644 --- a/internal/tui/styles/crush.go +++ b/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, diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index 8d3c014048b950a034357f10f0e7f6ce7883d2f0..74df47d8a238f4946f678b5ba1d88ab4f9d89992 100644 --- a/internal/tui/styles/theme.go +++ b/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 @@ -70,21 +72,6 @@ type Theme struct { 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 +99,7 @@ type Styles struct { Help help.Styles // Diff - Diff Diff + Diff diffview.Style // FilePicker FilePicker filepicker.Styles @@ -421,22 +408,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), From 3603a74dd4561380651f542d87178096fc465b8e Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 19 Jun 2025 12:33:39 +0200 Subject: [PATCH 03/13] chore: small ui fixes - fixed the tool call header - fixed the status error style - other small things --- go.mod | 2 +- .../tui/components/chat/messages/renderer.go | 11 ++++--- internal/tui/components/core/status/status.go | 33 ++++++++++++++----- internal/tui/keys.go | 4 +-- internal/tui/styles/crush.go | 2 ++ internal/tui/styles/theme.go | 6 ++-- internal/tui/tui.go | 8 +---- 7 files changed, 41 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index f5b9b0ef945822cf386f62767ead9079787524ea..ca66162d0d9efe6316b4d28b31ab92797c569925 100644 --- a/go.mod +++ b/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 diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 56a42c0f93a3f558218cfac080a6b355b00297bd..71cd7288f5c364b5dd0d5c5a4e80c8d4fa465a5e 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -125,7 +125,7 @@ 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) + prefix := fmt.Sprintf("%s %s ", icon, tool) return prefix + renderParamList(width-lipgloss.Width(prefix), params...) } @@ -517,6 +517,7 @@ func (tr agentRenderer) Render(v *toolCallCmp) string { // renderParamList renders params, params[0] (params[1]=params[2] ....) func renderParamList(paramsWidth int, params ...string) string { + t := styles.CurrentTheme() if len(params) == 0 { return "" } @@ -526,7 +527,7 @@ func renderParamList(paramsWidth int, params ...string) string { } if len(params) == 1 { - return mainParam + return t.S().Subtle.Render(mainParam) } otherParams := params[1:] // create pairs of key/value @@ -548,14 +549,14 @@ func renderParamList(paramsWidth int, params ...string) string { remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()" if remainingWidth < 30 { // 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, "...") + return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "...")) } // earlyState returns immediately‑rendered error/cancelled/ongoing states. @@ -580,7 +581,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 { diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go index 7b91c186f7ab9e572685de3e346204873d8cede2..a5caa538b26b83e6f2388d2419f95f0047df151d 100644 --- a/internal/tui/components/core/status/status.go +++ b/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 { @@ -94,18 +97,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 } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index dda3ad4dba02192626adf74540d2c62aad44a5a1..8af028cd10338eba2108f94156035b8f58f342e2 100644 --- a/internal/tui/keys.go +++ b/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 diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go index fca7f5d36f090d2f253260116a84dace4a22bb8a..7ee690b99037770bdd2204db7f6270c20d473514 100644 --- a/internal/tui/styles/crush.go +++ b/internal/tui/styles/crush.go @@ -41,6 +41,8 @@ func NewCrushTheme() *Theme { Blue: charmtone.Malibu, + Yellow: charmtone.Mustard, + Green: charmtone.Julep, GreenDark: charmtone.Guac, GreenLight: charmtone.Bok, diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index 74df47d8a238f4946f678b5ba1d88ab4f9d89992..4da3bd520f14da5d8e2fbfd561ed31d7a5a56fa2 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -54,9 +54,13 @@ type Theme struct { // Colors // White White color.Color + // Blues Blue color.Color + // Yellows + Yellow color.Color + // Greens Green color.Color GreenDark color.Color @@ -67,8 +71,6 @@ type Theme struct { RedDark color.Color RedLight color.Color - // TODO: add any others needed - styles *Styles } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e7f2f99da3e7bbf726ac78d654d6501018ba1351..c6dee6532993becfbda24d115b8e1e5d05e4fd60 100644 --- a/internal/tui/tui.go +++ b/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 } From 8e81c4fe146fa960f15b7c1e97dbafb321080310 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 19 Jun 2025 13:44:42 +0200 Subject: [PATCH 04/13] chore: dynamic change the diff format on size --- internal/tui/components/chat/messages/renderer.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 71cd7288f5c364b5dd0d5c5a4e80c8d4fa465a5e..1c6a96b445fe6053807309b0c4aefeab931387c6 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -260,8 +260,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() }) } From 97147f0bc45037f6ebc14e6d74567d5e0fe3f4ed Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 20 Jun 2025 10:54:17 +0200 Subject: [PATCH 05/13] chore: add assistant section --- internal/tui/components/chat/chat.go | 17 ++- .../tui/components/chat/messages/messages.go | 106 +++++++++++------- internal/tui/components/chat/messages/tool.go | 11 +- todos.md | 9 ++ 4 files changed, 89 insertions(+), 54 deletions(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 332d0ff5070290377342e75b0af6c2f4a59d70e5..95c9ad2d2831ab39da9ddd524fec2932ad9ddc73 100644 --- a/internal/tui/components/chat/chat.go +++ b/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)), ), ) } diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 51901308b6a20b65bbaa0779d8e3340675d2b1e5..b8bb27dac8eb5b9ec3824189741f3443db1f2c7a 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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) } @@ -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-1), + ), + ) +} + +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 +} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 65274b11c489e4e78ef0e70fe7a3adbe1f82806d..d8e7500391a9868f7941683137227d54e58d88b9 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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 @@ -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 } @@ -212,7 +209,7 @@ func (m *toolCallCmp) renderPending() string { t := styles.CurrentTheme() 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. diff --git a/todos.md b/todos.md index ca0ad74ef08258b6b209a3da7ff79f3922ae9e40..080bf64df8dd6e4d5a496531ba5f8f2be5fcf8a4 100644 --- a/todos.md +++ b/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 From 410a32947b40eb493b0564cffa9687f237a8ec06 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 20 Jun 2025 10:58:06 +0200 Subject: [PATCH 06/13] chore: small spelling stuff --- cspell.json | 2 +- internal/tui/components/chat/messages/tool.go | 4 ++-- internal/tui/components/dialogs/models/models.go | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cspell.json b/cspell.json index 266001569d7d8dfb6713c634286f406ae04b03b1..5e62368ec8ffecaaebb2fdc1c4ef23bc42cc0e0f 100644 --- a/cspell.json +++ b/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"]} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index d8e7500391a9868f7941683137227d54e58d88b9..89d1f407eb12e157307f95f8c81735f7cdd26260 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -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) diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index f8d23006929fa42cfb5d1a6d2841080d2541b330..02bb8514e59c94aaaf85c5739d8b8a7e92b0d1d2 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/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) } } From 2eb6d59323b162f11585820daaa2e80f1a129447 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 20 Jun 2025 11:00:55 +0200 Subject: [PATCH 07/13] chore: remove unused func --- internal/tui/components/dialogs/commands/arguments.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index 1128acf21b031ab914662f6686ffc9f57b9b7653..7e4bdcc271c0dcbdf1923c773f204c14a0fbf32b 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/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 From b1cd3f13ee4544cb54bbc636f2a7db0b601ecd80 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 20 Jun 2025 11:09:26 +0200 Subject: [PATCH 08/13] chore: change cancel logic --- internal/tui/page/chat/chat.go | 37 +++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 909ef31f451e8eb5bfabd61e3cb1750e738f838d..e79eb35e27cb5805c000e774b209c66c96b01ebd 100644 --- a/internal/tui/page/chat/chat.go +++ b/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 { From fc8ec7f200393bd5b8f870971c851dca23634fa1 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 20 Jun 2025 11:21:35 +0200 Subject: [PATCH 09/13] chore: cancel requests when user closes --- internal/app/app.go | 1 + internal/llm/agent/agent.go | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index 29c77308111e09f8174ea7f7ceddd30948db8cf1..e7472059a9f3fad360172c353f5d9a188529d177 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -163,4 +163,5 @@ func (app *App) Shutdown() { } cancel() } + app.CoderAgent.CancelAll() } diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 2659e2bcad17756986cbc69a203c05ce7a688c9f..4c8bae171118b4550c9d2a28cb5df0456099530c 100644 --- a/internal/llm/agent/agent.go +++ b/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] From 312df62923e8e434af03083c818251d07fb2a34f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 20 Jun 2025 11:28:18 +0200 Subject: [PATCH 10/13] chore: small fix --- internal/tui/components/chat/messages/messages.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index b8bb27dac8eb5b9ec3824189741f3443db1f2c7a..97e391bc6b041fb60a73a8b5abdd29ec6b576cf8 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -290,7 +290,7 @@ func (m *assistantSectionModel) View() tea.View { assistant := fmt.Sprintf("%s %s %s", icon, model, infoMsg) return tea.NewView( t.S().Base.PaddingLeft(2).Render( - core.Section(assistant, m.width-1), + core.Section(assistant, m.width-2), ), ) } From 254a5e755b6b8ae93c9e2ca204c62820d98591f1 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 20 Jun 2025 11:45:14 +0200 Subject: [PATCH 11/13] chore: small fixes --- internal/llm/prompt/coder.go | 9 ++++++--- internal/tui/components/core/status/status.go | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go index d994208dae04cd1cec63d0fc9b3fa7d606a2b0af..a5347cbb36da8e0b2ba88c2077cffbe9e389e878 100644 --- a/internal/llm/prompt/coder.go +++ b/internal/llm/prompt/coder.go @@ -30,7 +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" @@ -64,17 +63,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 +132,7 @@ assistant: src/foo.c 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] # Proactiveness @@ -165,6 +166,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 { diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go index a5caa538b26b83e6f2388d2419f95f0047df151d..bded453e78ecdfd85d6d182b4785a55d641dfd44 100644 --- a/internal/tui/components/core/status/status.go +++ b/internal/tui/components/core/status/status.go @@ -88,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 @@ -136,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, } From c992b57e1d39397d8d833f12ad49d55c4c6cad8d Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 20 Jun 2025 13:59:07 +0200 Subject: [PATCH 12/13] chore: small fix --- internal/llm/prompt/coder.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/llm/prompt/coder.go b/internal/llm/prompt/coder.go index a5347cbb36da8e0b2ba88c2077cffbe9e389e878..ea31bfa0297c1ce207e188a7f162e26831927636 100644 --- a/internal/llm/prompt/coder.go +++ b/internal/llm/prompt/coder.go @@ -30,9 +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). -- 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. From b6c792bce3ab0f847f8b4a69a9ab3715ffa12160 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 20 Jun 2025 15:20:00 +0200 Subject: [PATCH 13/13] chore: implement agent tool --- .../tui/components/chat/messages/messages.go | 2 +- .../tui/components/chat/messages/renderer.go | 66 +++++++++++++++---- internal/tui/components/chat/messages/tool.go | 11 +++- internal/tui/components/core/list/keys.go | 6 +- internal/tui/styles/crush.go | 3 +- internal/tui/styles/theme.go | 3 +- 6 files changed, 69 insertions(+), 22 deletions(-) diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 97e391bc6b041fb60a73a8b5abdd29ec6b576cf8..52ca288b9aa5a140f2abaa9ee64ae8775e78bfa6 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -164,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 diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 1c6a96b445fe6053807309b0c4aefeab931387c6..c32a7a124f160b2efe6a832ac8fea6ae8357692c 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/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 ": 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 != "" { @@ -126,7 +136,7 @@ func (br baseRenderer) makeHeader(v *toolCallCmp, tool string, width int, params } tool = t.S().Base.Foreground(t.Blue).Render(tool) prefix := fmt.Sprintf("%s %s ", icon, tool) - return prefix + renderParamList(width-lipgloss.Width(prefix), params...) + return prefix + renderParamList(false, width-lipgloss.Width(prefix), params...) } // renderError provides consistent error rendering @@ -477,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 @@ -518,7 +548,7 @@ 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 "" @@ -529,6 +559,9 @@ func renderParamList(paramsWidth int, params ...string) string { } if len(params) == 1 { + if nested { + return t.S().Muted.Render(mainParam) + } return t.S().Subtle.Render(mainParam) } otherParams := params[1:] @@ -550,6 +583,9 @@ 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 t.S().Subtle.Render(mainParam) } @@ -558,6 +594,9 @@ func renderParamList(paramsWidth int, params ...string) string { mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", ")) } + if nested { + return t.S().Muted.Render(ansi.Truncate(mainParam, paramsWidth, "...")) + } return t.S().Subtle.Render(ansi.Truncate(mainParam, paramsWidth, "...")) } @@ -635,7 +674,7 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string if len(strings.Split(content, "\n")) > responseContextHeight { lines = append(lines, t.S().Muted. Background(t.BgBase). - Render(fmt.Sprintf(" ... (%d lines)", len(strings.Split(content, "\n"))-responseContextHeight))) + Render(fmt.Sprintf(" …(%d lines)", len(strings.Split(content, "\n"))-responseContextHeight))) } maxLineNumber := len(lines) + offset @@ -647,13 +686,12 @@ func renderCodeContent(v *toolCallCmp, path, content string, offset int) string PaddingRight(1). PaddingLeft(1). Render(pad(i+1+offset, padding)) - w := v.textWidth() - 2 - lipgloss.Width(num) // -2 for left padding + w := v.textWidth() - 10 - lipgloss.Width(num) // -4 for left padding lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, num, t.S().Base. PaddingLeft(1). - Width(w). - Render(v.fit(ln, w))) + Render(v.fit(ln, w-1))) } return lipgloss.JoinVertical(lipgloss.Left, lines...) } @@ -662,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 { @@ -676,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: diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 89d1f407eb12e157307f95f8c81735f7cdd26260..458e5ed320c2ce6c33fc35afef8a076a6e594e56 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -207,6 +207,10 @@ 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()) @@ -226,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) } diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go index 4ad2a9e27807063609215f1f6c834872ceff2aac..0e33b62d1b615ea49866881b770d292486b688de 100644 --- a/internal/tui/components/core/list/keys.go +++ b/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"), ), } } diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go index 7ee690b99037770bdd2204db7f6270c20d473514..975c7f6080e654bad0a7d760543535bc6eea5827 100644 --- a/internal/tui/styles/crush.go +++ b/internal/tui/styles/crush.go @@ -39,7 +39,8 @@ func NewCrushTheme() *Theme { // Colors White: charmtone.Butter, - Blue: charmtone.Malibu, + BlueLight: charmtone.Sardine, + Blue: charmtone.Malibu, Yellow: charmtone.Mustard, diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index 4da3bd520f14da5d8e2fbfd561ed31d7a5a56fa2..b6a5b4d1e2b41b7bb1190d5a802bd48f4aeceec3 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -56,7 +56,8 @@ type Theme struct { White color.Color // Blues - Blue color.Color + BlueLight color.Color + Blue color.Color // Yellows Yellow color.Color