From b94495230b21203935ad3b043fcaa789dd49d7d7 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 26 Jan 2026 18:07:12 -0300 Subject: [PATCH 01/75] feat: add ability to drag & drop multiple file at once + support windows (#1992) Before, only dragging a single file was working. If you tried to drag & drop multiple, it would fail. Also, because Windows paste in a totally different format, it wasn't working at all before. --- internal/fsext/paste.go | 111 ++++++++++++++++++++++++++ internal/fsext/paste_test.go | 149 +++++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 49 ++++++++---- 3 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 internal/fsext/paste.go create mode 100644 internal/fsext/paste_test.go diff --git a/internal/fsext/paste.go b/internal/fsext/paste.go new file mode 100644 index 0000000000000000000000000000000000000000..7e89a6443e09a2c5831ce8a072945cf7d1c4fd95 --- /dev/null +++ b/internal/fsext/paste.go @@ -0,0 +1,111 @@ +package fsext + +import ( + "runtime" + "strings" +) + +func PasteStringToPaths(s string) []string { + switch runtime.GOOS { + case "windows": + return windowsPasteStringToPaths(s) + default: + return unixPasteStringToPaths(s) + } +} + +func windowsPasteStringToPaths(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + + var ( + paths []string + current strings.Builder + inQuotes = false + ) + for i := range len(s) { + ch := s[i] + + switch { + case ch == '"': + if inQuotes { + // End of quoted section + if current.Len() > 0 { + paths = append(paths, current.String()) + current.Reset() + } + inQuotes = false + } else { + // Start of quoted section + inQuotes = true + } + case inQuotes: + current.WriteByte(ch) + } + // Skip characters outside quotes and spaces between quoted sections + } + + // Add any remaining content if quotes were properly closed + if current.Len() > 0 && !inQuotes { + paths = append(paths, current.String()) + } + + // If quotes were not closed, return empty (malformed input) + if inQuotes { + return nil + } + + return paths +} + +func unixPasteStringToPaths(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + + var ( + paths []string + current strings.Builder + escaped = false + ) + for i := range len(s) { + ch := s[i] + + switch { + case escaped: + // After a backslash, add the character as-is (including space) + current.WriteByte(ch) + escaped = false + case ch == '\\': + // Check if this backslash is at the end of the string + if i == len(s)-1 { + // Trailing backslash, treat as literal + current.WriteByte(ch) + } else { + // Start of escape sequence + escaped = true + } + case ch == ' ': + // Space separates paths (unless escaped) + if current.Len() > 0 { + paths = append(paths, current.String()) + current.Reset() + } + default: + current.WriteByte(ch) + } + } + + // Handle trailing backslash if present + if escaped { + current.WriteByte('\\') + } + + // Add the last path if any + if current.Len() > 0 { + paths = append(paths, current.String()) + } + + return paths +} diff --git a/internal/fsext/paste_test.go b/internal/fsext/paste_test.go new file mode 100644 index 0000000000000000000000000000000000000000..09f8ad4d5bebbc993193d38a7ebbb31778aba7f6 --- /dev/null +++ b/internal/fsext/paste_test.go @@ -0,0 +1,149 @@ +package fsext + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPasteStringToPaths(t *testing.T) { + t.Run("Windows", func(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "single path", + input: `"C:\path\my-screenshot-one.png"`, + expected: []string{`C:\path\my-screenshot-one.png`}, + }, + { + name: "multiple paths no spaces", + input: `"C:\path\my-screenshot-one.png" "C:\path\my-screenshot-two.png" "C:\path\my-screenshot-three.png"`, + expected: []string{`C:\path\my-screenshot-one.png`, `C:\path\my-screenshot-two.png`, `C:\path\my-screenshot-three.png`}, + }, + { + name: "sigle with spaces", + input: `"C:\path\my screenshot one.png"`, + expected: []string{`C:\path\my screenshot one.png`}, + }, + { + name: "multiple paths with spaces", + input: `"C:\path\my screenshot one.png" "C:\path\my screenshot two.png" "C:\path\my screenshot three.png"`, + expected: []string{`C:\path\my screenshot one.png`, `C:\path\my screenshot two.png`, `C:\path\my screenshot three.png`}, + }, + { + name: "empty string", + input: "", + expected: nil, + }, + { + name: "unclosed quotes", + input: `"C:\path\file.png`, + expected: nil, + }, + { + name: "text outside quotes", + input: `"C:\path\file.png" some random text "C:\path\file2.png"`, + expected: []string{`C:\path\file.png`, `C:\path\file2.png`}, + }, + { + name: "multiple spaces between paths", + input: `"C:\path\file1.png" "C:\path\file2.png"`, + expected: []string{`C:\path\file1.png`, `C:\path\file2.png`}, + }, + { + name: "just whitespace", + input: " ", + expected: nil, + }, + { + name: "consecutive quoted sections", + input: `"C:\path1""C:\path2"`, + expected: []string{`C:\path1`, `C:\path2`}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := windowsPasteStringToPaths(tt.input) + require.Equal(t, tt.expected, result) + }) + } + }) + + t.Run("Unix", func(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "single path", + input: `/path/my-screenshot.png`, + expected: []string{"/path/my-screenshot.png"}, + }, + { + name: "multiple paths no spaces", + input: `/path/screenshot-one.png /path/screenshot-two.png /path/screenshot-three.png`, + expected: []string{"/path/screenshot-one.png", "/path/screenshot-two.png", "/path/screenshot-three.png"}, + }, + { + name: "sigle with spaces", + input: `/path/my\ screenshot\ one.png`, + expected: []string{"/path/my screenshot one.png"}, + }, + { + name: "multiple paths with spaces", + input: `/path/my\ screenshot\ one.png /path/my\ screenshot\ two.png /path/my\ screenshot\ three.png`, + expected: []string{"/path/my screenshot one.png", "/path/my screenshot two.png", "/path/my screenshot three.png"}, + }, + { + name: "empty string", + input: "", + expected: nil, + }, + { + name: "double backslash escapes", + input: `/path/my\\file.png`, + expected: []string{"/path/my\\file.png"}, + }, + { + name: "trailing backslash", + input: `/path/file\`, + expected: []string{`/path/file\`}, + }, + { + name: "multiple consecutive escaped spaces", + input: `/path/file\ \ with\ \ many\ \ spaces.png`, + expected: []string{"/path/file with many spaces.png"}, + }, + { + name: "multiple unescaped spaces", + input: `/path/file1.png /path/file2.png`, + expected: []string{"/path/file1.png", "/path/file2.png"}, + }, + { + name: "just whitespace", + input: " ", + expected: nil, + }, + { + name: "tab characters", + input: "/path/file1.png\t/path/file2.png", + expected: []string{"/path/file1.png\t/path/file2.png"}, + }, + { + name: "newlines in input", + input: "/path/file1.png\n/path/file2.png", + expected: []string{"/path/file1.png\n/path/file2.png"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := unixPasteStringToPaths(tt.input) + require.Equal(t, tt.expected, result) + }) + } + }) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cd1ad42a0dc473c31b3ff280a7a224d64d0094c2..9a0cb92aa4e96a6bf4c2aa914ba3a826420f56b0 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -29,6 +29,7 @@ import ( "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/filetracker" + "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/message" @@ -2817,29 +2818,45 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { } } - var cmd tea.Cmd - path := strings.ReplaceAll(msg.Content, "\\ ", " ") - // Try to get an image. - path, err := filepath.Abs(strings.TrimSpace(path)) - if err != nil { - m.textarea, cmd = m.textarea.Update(msg) - return cmd - } + // Attempt to parse pasted content as file paths. If possible to parse, + // all files exist and are valid, add as attachments. + // Otherwise, paste as text. + paths := fsext.PasteStringToPaths(msg.Content) + allExistsAndValid := func() bool { + for _, path := range paths { + if _, err := os.Stat(path); os.IsNotExist(err) { + return false + } - // Check if file has an allowed image extension. - isAllowedType := false - lowerPath := strings.ToLower(path) - for _, ext := range common.AllowedImageTypes { - if strings.HasSuffix(lowerPath, ext) { - isAllowedType = true - break + lowerPath := strings.ToLower(path) + isValid := false + for _, ext := range common.AllowedImageTypes { + if strings.HasSuffix(lowerPath, ext) { + isValid = true + break + } + } + if !isValid { + return false + } } + return true } - if !isAllowedType { + if !allExistsAndValid() { + var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(msg) return cmd } + var cmds []tea.Cmd + for _, path := range paths { + cmds = append(cmds, m.handleFilePathPaste(path)) + } + return tea.Batch(cmds...) +} + +// handleFilePathPaste handles a pasted file path. +func (m *UI) handleFilePathPaste(path string) tea.Cmd { return func() tea.Msg { fileInfo, err := os.Stat(path) if err != nil { From 5ddf9d1068cc01d2cea20500cecd6fabf9370fb4 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 Jan 2026 14:11:37 +0100 Subject: [PATCH 02/75] fix: token calculation (#2004) --- internal/agent/agent.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d5b0ed5f1cf4333922ef65c6e0a0fafcf92710a2..815ba2fa8f3c78db8de593849a83ed161e1ee008 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -372,20 +372,18 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy } currentAssistant.AddFinish(finishReason, "", "") sessionLock.Lock() - updatedSession, getSessionErr := a.sessions.Get(genCtx, call.SessionID) + defer sessionLock.Unlock() + + updatedSession, getSessionErr := a.sessions.Get(ctx, call.SessionID) if getSessionErr != nil { - sessionLock.Unlock() return getSessionErr } a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata)) - _, sessionErr := a.sessions.Save(genCtx, updatedSession) - if sessionErr == nil { - currentSession = updatedSession - } - sessionLock.Unlock() + _, sessionErr := a.sessions.Save(ctx, updatedSession) if sessionErr != nil { return sessionErr } + currentSession = updatedSession return a.messages.Update(genCtx, *currentAssistant) }, StopWhen: []fantasy.StopCondition{ @@ -898,7 +896,7 @@ func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, } session.CompletionTokens = usage.OutputTokens - session.PromptTokens = usage.InputTokens + usage.CacheCreationTokens + session.PromptTokens = usage.InputTokens + usage.CacheReadTokens } func (a *sessionAgent) Cancel(sessionID string) { From 5e384b2e8f7ba72395581d164c18e232152e1023 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 27 Jan 2026 09:12:25 -0500 Subject: [PATCH 04/75] fix(ui): ensure the message list does not scroll beyond the last item (#1993) * fix(ui): ensure the message list does not scroll beyond the last item Ensure that when scrolling down, the message list does not scroll beyond the last item, preventing empty space from appearing below the last message. * fix: lint --------- Co-authored-by: Kujtim Hoxha --- internal/ui/list/list.go | 114 +++++++++++++++++--------------------- internal/ui/model/chat.go | 4 ++ 2 files changed, 55 insertions(+), 63 deletions(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index a731a0a30023c451f2e1067e4e15ccb5e06ea177..0883ab2b56c5bb7ab26073301890c832e7c4e441 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -75,30 +75,24 @@ func (l *List) Gap() int { return l.gap } -// AtBottom returns whether the list is scrolled to the bottom. +// AtBottom returns whether the list is showing the last item at the bottom. func (l *List) AtBottom() bool { if len(l.items) == 0 { return true } - // Calculate total height of all items from the bottom. + // Calculate the height from offsetIdx to the end. var totalHeight int - for i := len(l.items) - 1; i >= 0; i-- { - item := l.getItem(i) - totalHeight += item.height - if l.gap > 0 && i < len(l.items)-1 { - totalHeight += l.gap - } - if totalHeight >= l.height { - // This is the expected bottom position. - expectedIdx := i - expectedLine := totalHeight - l.height - return l.offsetIdx == expectedIdx && l.offsetLine >= expectedLine + for idx := l.offsetIdx; idx < len(l.items); idx++ { + item := l.getItem(idx) + itemHeight := item.height + if l.gap > 0 && idx > l.offsetIdx { + itemHeight += l.gap } + totalHeight += itemHeight } - // All items fit in viewport - we're at bottom if at top. - return l.offsetIdx == 0 && l.offsetLine == 0 + return totalHeight-l.offsetLine <= l.height } // SetReverse shows the list in reverse order. @@ -121,6 +115,30 @@ func (l *List) Len() int { return len(l.items) } +// lastOffsetItem returns the index and line offsets of the last item that can +// be partially visible in the viewport. +func (l *List) lastOffsetItem() (int, int, int) { + var totalHeight int + var idx int + for idx = len(l.items) - 1; idx >= 0; idx-- { + item := l.getItem(idx) + itemHeight := item.height + if l.gap > 0 && idx < len(l.items)-1 { + itemHeight += l.gap + } + totalHeight += itemHeight + if totalHeight > l.height { + break + } + } + + // Calculate line offset within the item + lineOffset := max(totalHeight-l.height, 0) + idx = max(idx, 0) + + return idx, lineOffset, totalHeight +} + // getItem renders (if needed) and returns the item at the given index. func (l *List) getItem(idx int) renderedItem { if idx < 0 || idx >= len(l.items) { @@ -171,44 +189,29 @@ func (l *List) ScrollBy(lines int) { if lines > 0 { // Scroll down - // Calculate from the bottom how many lines needed to anchor the last - // item to the bottom - var totalLines int - var lastItemIdx int // the last item that can be partially visible - for i := len(l.items) - 1; i >= 0; i-- { - item := l.getItem(i) - totalLines += item.height - if l.gap > 0 && i < len(l.items)-1 { - totalLines += l.gap - } - if totalLines > l.height-1 { - lastItemIdx = i - break - } - } - - // Now scroll down by lines - var item renderedItem l.offsetLine += lines - for { - item = l.getItem(l.offsetIdx) - totalHeight := item.height + currentItem := l.getItem(l.offsetIdx) + for l.offsetLine >= currentItem.height { + l.offsetLine -= currentItem.height if l.gap > 0 { - totalHeight += l.gap - } - - if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight { - // Valid offset - break + l.offsetLine -= l.gap } // Move to next item - l.offsetLine -= totalHeight l.offsetIdx++ + if l.offsetIdx > len(l.items)-1 { + // Reached bottom + l.ScrollToBottom() + return + } + currentItem = l.getItem(l.offsetIdx) } - if l.offsetLine >= item.height { - l.offsetLine = item.height + lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem() + if l.offsetIdx > lastOffsetIdx || (l.offsetIdx == lastOffsetIdx && l.offsetLine > lastOffsetLine) { + // Clamp to bottom + l.offsetIdx = lastOffsetIdx + l.offsetLine = lastOffsetLine } } else if lines < 0 { // Scroll up @@ -408,24 +411,9 @@ func (l *List) ScrollToBottom() { return } - // Scroll to the last item - var totalHeight int - for i := len(l.items) - 1; i >= 0; i-- { - item := l.getItem(i) - totalHeight += item.height - if l.gap > 0 && i < len(l.items)-1 { - totalHeight += l.gap - } - if totalHeight >= l.height { - l.offsetIdx = i - l.offsetLine = totalHeight - l.height - break - } - } - if totalHeight < l.height { - // All items fit in the viewport - l.ScrollToTop() - } + lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem() + l.offsetIdx = lastOffsetIdx + l.offsetLine = lastOffsetLine } // ScrollToSelected scrolls the list to the selected item. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index d009a261580eaed209c1fc15966f50f4a8b3e62d..3a743edd9d1e87b643076f114b065b2eaa2b2ca5 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -66,6 +66,10 @@ func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) { // SetSize sets the size of the chat view port. func (m *Chat) SetSize(width, height int) { m.list.SetSize(width, height) + // Anchor to bottom if we were at the bottom. + if m.list.AtBottom() { + m.list.ScrollToBottom() + } } // Len returns the number of items in the chat list. From 8d3064ffe778438dae209f3520d6449e2ec60bdf Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 Jan 2026 15:28:19 +0100 Subject: [PATCH 05/75] fix: layout calculations when editor has attachments (#2012) --- internal/ui/model/ui.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 9a0cb92aa4e96a6bf4c2aa914ba3a826420f56b0..7895f323ba8c8a24de1eb5895e377cbeef761d40 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2247,7 +2247,9 @@ func (m *UI) generateLayout(w, h int) layout { if !layout.editor.Empty() { // Add editor margins 1 top and bottom - layout.editor.Min.Y += 1 + if len(m.attachments.List()) == 0 { + layout.editor.Min.Y += 1 + } layout.editor.Max.Y -= 1 } From 699ae40fd953bf671e740fbc16a1b841fa566ac3 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 Jan 2026 16:28:41 +0100 Subject: [PATCH 06/75] fix: make the check for sidebar toggle inclusive (#2013) --- internal/ui/dialog/commands.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 444492c9f71241bf812f0a96ac18d2118919e33d..6595b56fb702069b6a0f0786ee25cd4e94f13642 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -422,7 +422,7 @@ func (c *Commands) defaultCommands() []*CommandItem { } } // Only show toggle compact mode command if window width is larger than compact breakpoint (120) - if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" { + if c.windowWidth >= sidebarCompactModeBreakpoint && c.sessionID != "" { commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{})) } if c.sessionID != "" { From 115adebe89e0166c2a15f323bb901a5f5151d208 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 27 Jan 2026 11:38:57 -0500 Subject: [PATCH 07/75] fix(ui): use setState method to change UI state and focus (#1994) This change introduces a setState method in the UI model to encapsulate the logic for changing the UI state and focus. This ensures that any time the state or focus is changed, the layout and size are updated accordingly. --- internal/ui/model/onboarding.go | 3 +- internal/ui/model/ui.go | 63 +++++++++++++++++---------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index d18469ee822460e60544a304afebb37dac7fa0d9..9d7e9ea0882a2d0cf8508ed75f8e5d4b5edb2b03 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -68,8 +68,7 @@ func (m *UI) initializeProject() tea.Cmd { // skipInitializeProject skips project initialization and transitions to the landing view. func (m *UI) skipInitializeProject() tea.Cmd { // TODO: initialize the project - m.state = uiLanding - m.focus = uiFocusEditor + m.setState(uiLanding, uiFocusEditor) // mark the project as initialized return m.markProjectInitialized } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 7895f323ba8c8a24de1eb5895e377cbeef761d40..e6e54af525cb72c61a942e408dcbda9203de6cf2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -258,8 +258,6 @@ func New(com *common.Common) *UI { com: com, dialog: dialog.NewOverlay(), keyMap: keyMap, - focus: uiFocusNone, - state: uiOnboarding, textarea: ta, chat: ch, completions: comp, @@ -271,18 +269,6 @@ func New(com *common.Common) *UI { status := NewStatus(com, ui) - // set onboarding state defaults - ui.onboarding.yesInitializeSelected = true - - if !com.Config().IsConfigured() { - ui.state = uiOnboarding - } else if n, _ := config.ProjectNeedsInitialization(); n { - ui.state = uiInitialize - } else { - ui.state = uiLanding - ui.focus = uiFocusEditor - } - ui.setEditorPrompt(false) ui.randomizePlaceholders() ui.textarea.Placeholder = ui.readyPlaceholder @@ -291,6 +277,20 @@ func New(com *common.Common) *UI { // Initialize compact mode from config ui.forceCompactMode = com.Config().Options.TUI.CompactMode + // set onboarding state defaults + ui.onboarding.yesInitializeSelected = true + + desiredState := uiLanding + desiredFocus := uiFocusEditor + if !com.Config().IsConfigured() { + desiredState = uiOnboarding + } else if n, _ := config.ProjectNeedsInitialization(); n { + desiredState = uiInitialize + } + + // set initial state + ui.setState(desiredState, desiredFocus) + return ui } @@ -310,6 +310,14 @@ func (m *UI) Init() tea.Cmd { return tea.Batch(cmds...) } +// setState changes the UI state and focus. +func (m *UI) setState(state uiState, focus uiFocusState) { + m.state = state + m.focus = focus + // Changing the state may change layout, so update it. + m.updateLayoutAndSize() +} + // loadCustomCommands loads the custom commands asynchronously. func (m *UI) loadCustomCommands() tea.Cmd { return func() tea.Msg { @@ -360,10 +368,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env)) } case loadSessionMsg: - m.state = uiChat if m.forceCompactMode { m.isCompact = true } + m.setState(uiChat, m.focus) m.session = msg.session m.sessionFiles = msg.files msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID) @@ -493,7 +501,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height - m.handleCompactMode(m.width, m.height) m.updateLayoutAndSize() // XXX: We need to store cell dimensions for image rendering. m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height @@ -1212,9 +1219,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { m.dialog.CloseDialog(dialog.ModelsID) if isOnboarding { - m.state = uiLanding - m.focus = uiFocusEditor - + m.setState(uiLanding, uiFocusEditor) m.com.Config().SetupAgents() if err := m.com.App.InitCoderAgent(context.TODO()); err != nil { cmds = append(cmds, uiutil.ReportError(err)) @@ -1507,7 +1512,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.newSession() case key.Matches(msg, m.keyMap.Tab): if m.state != uiLanding { - m.focus = uiFocusMain + m.setState(m.state, uiFocusMain) m.textarea.Blur() m.chat.Focus() m.chat.SetSelected(m.chat.Len() - 1) @@ -2054,29 +2059,26 @@ func (m *UI) toggleCompactMode() tea.Cmd { return uiutil.ReportError(err) } - m.handleCompactMode(m.width, m.height) m.updateLayoutAndSize() return nil } -// handleCompactMode updates the UI state based on window size and compact mode setting. -func (m *UI) handleCompactMode(newWidth, newHeight int) { +// updateLayoutAndSize updates the layout and sizes of UI components. +func (m *UI) updateLayoutAndSize() { + // Determine if we should be in compact mode if m.state == uiChat { if m.forceCompactMode { m.isCompact = true return } - if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint { + if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint { m.isCompact = true } else { m.isCompact = false } } -} -// updateLayoutAndSize updates the layout and sizes of UI components. -func (m *UI) updateLayoutAndSize() { m.layout = m.generateLayout(m.width, m.height) m.updateSize() } @@ -2121,7 +2123,7 @@ func (m *UI) generateLayout(w, h int) layout { const landingHeaderHeight = 4 var helpKeyMap help.KeyMap = m - if m.status.ShowingAll() { + if m.status != nil && m.status.ShowingAll() { for _, row := range helpKeyMap.FullHelp() { helpHeight = max(helpHeight, len(row)) } @@ -2527,7 +2529,6 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea. if err != nil { return uiutil.ReportError(err) } - m.state = uiChat if m.forceCompactMode { m.isCompact = true } @@ -2535,6 +2536,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea. m.session = &newSession cmds = append(cmds, m.loadSession(newSession.ID)) } + m.setState(uiChat, m.focus) } // Capture session ID to avoid race with main goroutine updating m.session. @@ -2782,8 +2784,7 @@ func (m *UI) newSession() { m.session = nil m.sessionFiles = nil - m.state = uiLanding - m.focus = uiFocusEditor + m.setState(uiLanding, uiFocusEditor) m.textarea.Focus() m.chat.Blur() m.chat.ClearMessages() From c5f0e4da2baa31e865effd3da90abf7a222bb98f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 Jan 2026 17:46:51 +0100 Subject: [PATCH 08/75] Handle unknown tool calls in the TUI (#2001) --- internal/ui/chat/generic.go | 98 +++++++++++++++++++++++++++++++++++++ internal/ui/chat/tools.go | 11 +---- internal/ui/model/ui.go | 5 -- 3 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 internal/ui/chat/generic.go diff --git a/internal/ui/chat/generic.go b/internal/ui/chat/generic.go new file mode 100644 index 0000000000000000000000000000000000000000..6b0ac433028daf7a06c57f85c7799250e9652f6f --- /dev/null +++ b/internal/ui/chat/generic.go @@ -0,0 +1,98 @@ +package chat + +import ( + "encoding/json" + "strings" + + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/stringext" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// GenericToolMessageItem is a message item that represents an unknown tool call. +type GenericToolMessageItem struct { + *baseToolMessageItem +} + +var _ ToolMessageItem = (*GenericToolMessageItem)(nil) + +// NewGenericToolMessageItem creates a new [GenericToolMessageItem]. +func NewGenericToolMessageItem( + sty *styles.Styles, + toolCall message.ToolCall, + result *message.ToolResult, + canceled bool, +) ToolMessageItem { + return newBaseToolMessageItem(sty, toolCall, result, &GenericToolRenderContext{}, canceled) +} + +// GenericToolRenderContext renders unknown/generic tool messages. +type GenericToolRenderContext struct{} + +// RenderTool implements the [ToolRenderer] interface. +func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { + cappedWidth := cappedMessageWidth(width) + name := genericPrettyName(opts.ToolCall.Name) + + if opts.IsPending() { + return pendingTool(sty, name, opts.Anim) + } + + var params map[string]any + if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil { + return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth) + } + + var toolParams []string + if len(params) > 0 { + parsed, _ := json.Marshal(params) + toolParams = append(toolParams, string(parsed)) + } + + header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...) + if opts.Compact { + return header + } + + if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok { + return joinToolParts(header, earlyState) + } + + if !opts.HasResult() || opts.Result.Content == "" { + return header + } + + bodyWidth := cappedWidth - toolBodyLeftPaddingTotal + + // Handle image data. + if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") { + body := sty.Tool.Body.Render(toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType)) + return joinToolParts(header, body) + } + + // Try to parse result as JSON for pretty display. + var result json.RawMessage + var body string + if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil { + prettyResult, err := json.MarshalIndent(result, "", " ") + if err == nil { + body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent)) + } else { + body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + } + } else if looksLikeMarkdown(opts.Result.Content) { + body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent)) + } else { + body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent)) + } + + return joinToolParts(header, body) +} + +// genericPrettyName converts a snake_case or kebab-case tool name to a +// human-readable title case name. +func genericPrettyName(name string) string { + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return stringext.Capitalize(name) +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index e10d28e061e17c636dc9e1a6cfe364ca6f220d0e..8aac1c1401fe299b24bd2cda81e18113bfd6176d 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -255,14 +255,7 @@ func NewToolMessageItem( if strings.HasPrefix(toolCall.Name, "mcp_") { item = NewMCPToolMessageItem(sty, toolCall, result, canceled) } else { - // TODO: Implement other tool items - item = newBaseToolMessageItem( - sty, - toolCall, - result, - &DefaultToolRenderContext{}, - canceled, - ) + item = NewGenericToolMessageItem(sty, toolCall, result, canceled) } } item.SetMessageID(messageID) @@ -1399,6 +1392,6 @@ func prettifyToolName(name string) string { case tools.WriteToolName: return "Write" default: - return name + return genericPrettyName(name) } } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e6e54af525cb72c61a942e408dcbda9203de6cf2..121446524cb95fe8f443f551e14273e5327dd1a1 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1124,11 +1124,6 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { cmds = append(cmds, m.toggleCompactMode()) m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionToggleThinking: - if m.isAgentBusy() { - cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait...")) - break - } - cmds = append(cmds, func() tea.Msg { cfg := m.com.Config() if cfg == nil { From 033584c7d0c88834011697d13361d13810a86d46 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 27 Jan 2026 17:47:12 +0100 Subject: [PATCH 09/75] feat: implement prompt history (#2005) --- internal/db/db.go | 20 ++++ internal/db/messages.sql.go | 82 ++++++++++++++ internal/db/querier.go | 2 + internal/db/sql/messages.sql | 12 +++ internal/message/message.go | 32 ++++++ internal/ui/model/history.go | 184 ++++++++++++++++++++++++++++++++ internal/ui/model/keys.go | 10 ++ internal/ui/model/onboarding.go | 6 +- internal/ui/model/ui.go | 61 +++++++++-- 9 files changed, 400 insertions(+), 9 deletions(-) create mode 100644 internal/ui/model/history.go diff --git a/internal/db/db.go b/internal/db/db.go index 81c3179e22f6768b2ffa2c5b4af2e10c385d5835..a4e430c720f33f4cd3c0b9710633595ef5c5fa1f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -87,6 +87,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getUsageByModelStmt, err = db.PrepareContext(ctx, getUsageByModel); err != nil { return nil, fmt.Errorf("error preparing query GetUsageByModel: %w", err) } + if q.listAllUserMessagesStmt, err = db.PrepareContext(ctx, listAllUserMessages); err != nil { + return nil, fmt.Errorf("error preparing query ListAllUserMessages: %w", err) + } if q.listFilesByPathStmt, err = db.PrepareContext(ctx, listFilesByPath); err != nil { return nil, fmt.Errorf("error preparing query ListFilesByPath: %w", err) } @@ -105,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listSessionsStmt, err = db.PrepareContext(ctx, listSessions); err != nil { return nil, fmt.Errorf("error preparing query ListSessions: %w", err) } + if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil { + return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err) + } if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil { return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err) } @@ -224,6 +230,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getUsageByModelStmt: %w", cerr) } } + if q.listAllUserMessagesStmt != nil { + if cerr := q.listAllUserMessagesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listAllUserMessagesStmt: %w", cerr) + } + } if q.listFilesByPathStmt != nil { if cerr := q.listFilesByPathStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listFilesByPathStmt: %w", cerr) @@ -254,6 +265,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listSessionsStmt: %w", cerr) } } + if q.listUserMessagesBySessionStmt != nil { + if cerr := q.listUserMessagesBySessionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr) + } + } if q.updateMessageStmt != nil { if cerr := q.updateMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateMessageStmt: %w", cerr) @@ -329,12 +345,14 @@ type Queries struct { getUsageByDayOfWeekStmt *sql.Stmt getUsageByHourStmt *sql.Stmt getUsageByModelStmt *sql.Stmt + listAllUserMessagesStmt *sql.Stmt listFilesByPathStmt *sql.Stmt listFilesBySessionStmt *sql.Stmt listLatestSessionFilesStmt *sql.Stmt listMessagesBySessionStmt *sql.Stmt listNewFilesStmt *sql.Stmt listSessionsStmt *sql.Stmt + listUserMessagesBySessionStmt *sql.Stmt updateMessageStmt *sql.Stmt updateSessionStmt *sql.Stmt updateSessionTitleAndUsageStmt *sql.Stmt @@ -365,12 +383,14 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getUsageByDayOfWeekStmt: q.getUsageByDayOfWeekStmt, getUsageByHourStmt: q.getUsageByHourStmt, getUsageByModelStmt: q.getUsageByModelStmt, + listAllUserMessagesStmt: q.listAllUserMessagesStmt, listFilesByPathStmt: q.listFilesByPathStmt, listFilesBySessionStmt: q.listFilesBySessionStmt, listLatestSessionFilesStmt: q.listLatestSessionFilesStmt, listMessagesBySessionStmt: q.listMessagesBySessionStmt, listNewFilesStmt: q.listNewFilesStmt, listSessionsStmt: q.listSessionsStmt, + listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt, updateMessageStmt: q.updateMessageStmt, updateSessionStmt: q.updateSessionStmt, updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt, diff --git a/internal/db/messages.sql.go b/internal/db/messages.sql.go index f10b9d5e2c47ec90aec9dc0f206d4a157fa7f6b0..44e8bb366b3e864b6716d8ccefa301c86c915234 100644 --- a/internal/db/messages.sql.go +++ b/internal/db/messages.sql.go @@ -107,6 +107,47 @@ func (q *Queries) GetMessage(ctx context.Context, id string) (Message, error) { return i, err } +const listAllUserMessages = `-- name: ListAllUserMessages :many +SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message +FROM messages +WHERE role = 'user' +ORDER BY created_at DESC +` + +func (q *Queries) ListAllUserMessages(ctx context.Context) ([]Message, error) { + rows, err := q.query(ctx, q.listAllUserMessagesStmt, listAllUserMessages) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Message{} + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Role, + &i.Parts, + &i.Model, + &i.CreatedAt, + &i.UpdatedAt, + &i.FinishedAt, + &i.Provider, + &i.IsSummaryMessage, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listMessagesBySession = `-- name: ListMessagesBySession :many SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message FROM messages @@ -148,6 +189,47 @@ func (q *Queries) ListMessagesBySession(ctx context.Context, sessionID string) ( return items, nil } +const listUserMessagesBySession = `-- name: ListUserMessagesBySession :many +SELECT id, session_id, role, parts, model, created_at, updated_at, finished_at, provider, is_summary_message +FROM messages +WHERE session_id = ? AND role = 'user' +ORDER BY created_at DESC +` + +func (q *Queries) ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) { + rows, err := q.query(ctx, q.listUserMessagesBySessionStmt, listUserMessagesBySession, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Message{} + for rows.Next() { + var i Message + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.Role, + &i.Parts, + &i.Model, + &i.CreatedAt, + &i.UpdatedAt, + &i.FinishedAt, + &i.Provider, + &i.IsSummaryMessage, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateMessage = `-- name: UpdateMessage :exec UPDATE messages SET diff --git a/internal/db/querier.go b/internal/db/querier.go index c70386690c6c42aca53a2b6682ddca0f3a0262ba..394ba1f71aea47c93956e91fcaf07e02f65098b8 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -30,12 +30,14 @@ type Querier interface { GetUsageByDayOfWeek(ctx context.Context) ([]GetUsageByDayOfWeekRow, error) GetUsageByHour(ctx context.Context) ([]GetUsageByHourRow, error) GetUsageByModel(ctx context.Context) ([]GetUsageByModelRow, error) + ListAllUserMessages(ctx context.Context) ([]Message, error) ListFilesByPath(ctx context.Context, path string) ([]File, error) ListFilesBySession(ctx context.Context, sessionID string) ([]File, error) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]File, error) ListMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) ListNewFiles(ctx context.Context) ([]File, error) ListSessions(ctx context.Context) ([]Session, error) + ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) UpdateMessage(ctx context.Context, arg UpdateMessageParams) error UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error diff --git a/internal/db/sql/messages.sql b/internal/db/sql/messages.sql index fc66b78c08b85c8fe1f7ec79985fb2edd4a03668..91d158eb1fb1d2280698ba09193a6298c7b129da 100644 --- a/internal/db/sql/messages.sql +++ b/internal/db/sql/messages.sql @@ -41,3 +41,15 @@ WHERE id = ?; -- name: DeleteSessionMessages :exec DELETE FROM messages WHERE session_id = ?; + +-- name: ListUserMessagesBySession :many +SELECT * +FROM messages +WHERE session_id = ? AND role = 'user' +ORDER BY created_at DESC; + +-- name: ListAllUserMessages :many +SELECT * +FROM messages +WHERE role = 'user' +ORDER BY created_at DESC; diff --git a/internal/message/message.go b/internal/message/message.go index 04eb8252bbe9a68444eba81fc581c6b49231734b..6da8827b72227602dc36c39b6a2254aba18d2b0d 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -26,6 +26,8 @@ type Service interface { Update(ctx context.Context, message Message) error Get(ctx context.Context, id string) (Message, error) List(ctx context.Context, sessionID string) ([]Message, error) + ListUserMessages(ctx context.Context, sessionID string) ([]Message, error) + ListAllUserMessages(ctx context.Context) ([]Message, error) Delete(ctx context.Context, id string) error DeleteSessionMessages(ctx context.Context, sessionID string) error } @@ -157,6 +159,36 @@ func (s *service) List(ctx context.Context, sessionID string) ([]Message, error) return messages, nil } +func (s *service) ListUserMessages(ctx context.Context, sessionID string) ([]Message, error) { + dbMessages, err := s.q.ListUserMessagesBySession(ctx, sessionID) + if err != nil { + return nil, err + } + messages := make([]Message, len(dbMessages)) + for i, dbMessage := range dbMessages { + messages[i], err = s.fromDBItem(dbMessage) + if err != nil { + return nil, err + } + } + return messages, nil +} + +func (s *service) ListAllUserMessages(ctx context.Context) ([]Message, error) { + dbMessages, err := s.q.ListAllUserMessages(ctx) + if err != nil { + return nil, err + } + messages := make([]Message, len(dbMessages)) + for i, dbMessage := range dbMessages { + messages[i], err = s.fromDBItem(dbMessage) + if err != nil { + return nil, err + } + } + return messages, nil +} + func (s *service) fromDBItem(item db.Message) (Message, error) { parts, err := unmarshalParts([]byte(item.Parts)) if err != nil { diff --git a/internal/ui/model/history.go b/internal/ui/model/history.go new file mode 100644 index 0000000000000000000000000000000000000000..5acc6ef5feabdab2bcb7a81ba8a60f5f224dab11 --- /dev/null +++ b/internal/ui/model/history.go @@ -0,0 +1,184 @@ +package model + +import ( + "context" + "log/slog" + + tea "charm.land/bubbletea/v2" + + "github.com/charmbracelet/crush/internal/message" +) + +// promptHistoryLoadedMsg is sent when prompt history is loaded. +type promptHistoryLoadedMsg struct { + messages []string +} + +// loadPromptHistory loads user messages for history navigation. +func (m *UI) loadPromptHistory() tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + var messages []message.Message + var err error + + if m.session != nil { + messages, err = m.com.App.Messages.ListUserMessages(ctx, m.session.ID) + } else { + messages, err = m.com.App.Messages.ListAllUserMessages(ctx) + } + if err != nil { + slog.Error("failed to load prompt history", "error", err) + return promptHistoryLoadedMsg{messages: nil} + } + + texts := make([]string, 0, len(messages)) + for _, msg := range messages { + if text := msg.Content().Text; text != "" { + texts = append(texts, text) + } + } + return promptHistoryLoadedMsg{messages: texts} + } +} + +// handleHistoryUp handles up arrow for history navigation. +func (m *UI) handleHistoryUp(msg tea.Msg) tea.Cmd { + // Navigate to older history entry from cursor position (0,0). + if m.textarea.Length() == 0 || m.isAtEditorStart() { + if m.historyPrev() { + // we send this so that the textarea moves the view to the correct position + // without this the cursor will show up in the wrong place. + ta, cmd := m.textarea.Update(nil) + m.textarea = ta + return cmd + } + } + + // First move cursor to start before entering history. + if m.textarea.Line() == 0 { + m.textarea.CursorStart() + return nil + } + + // Let textarea handle normal cursor movement. + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + return cmd +} + +// handleHistoryDown handles down arrow for history navigation. +func (m *UI) handleHistoryDown(msg tea.Msg) tea.Cmd { + // Navigate to newer history entry from end of text. + if m.isAtEditorEnd() { + if m.historyNext() { + // we send this so that the textarea moves the view to the correct position + // without this the cursor will show up in the wrong place. + ta, cmd := m.textarea.Update(nil) + m.textarea = ta + return cmd + } + } + + // First move cursor to end before navigating history. + if m.textarea.Line() == max(m.textarea.LineCount()-1, 0) { + m.textarea.MoveToEnd() + ta, cmd := m.textarea.Update(nil) + m.textarea = ta + return cmd + } + + // Let textarea handle normal cursor movement. + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + return cmd +} + +// handleHistoryEscape handles escape for exiting history navigation. +func (m *UI) handleHistoryEscape(msg tea.Msg) tea.Cmd { + // Return to current draft when browsing history. + if m.promptHistory.index >= 0 { + m.promptHistory.index = -1 + m.textarea.Reset() + m.textarea.InsertString(m.promptHistory.draft) + ta, cmd := m.textarea.Update(nil) + m.textarea = ta + return cmd + } + + // Let textarea handle escape normally. + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + return cmd +} + +// updateHistoryDraft updates history state when text is modified. +func (m *UI) updateHistoryDraft(oldValue string) { + if m.textarea.Value() != oldValue { + m.promptHistory.draft = m.textarea.Value() + m.promptHistory.index = -1 + } +} + +// historyPrev changes the text area content to the previous message in the history +// it returns false if it could not find the previous message. +func (m *UI) historyPrev() bool { + if len(m.promptHistory.messages) == 0 { + return false + } + if m.promptHistory.index == -1 { + m.promptHistory.draft = m.textarea.Value() + } + nextIndex := m.promptHistory.index + 1 + if nextIndex >= len(m.promptHistory.messages) { + return false + } + m.promptHistory.index = nextIndex + m.textarea.Reset() + m.textarea.InsertString(m.promptHistory.messages[nextIndex]) + m.textarea.MoveToBegin() + return true +} + +// historyNext changes the text area content to the next message in the history +// it returns false if it could not find the next message. +func (m *UI) historyNext() bool { + if m.promptHistory.index < 0 { + return false + } + nextIndex := m.promptHistory.index - 1 + if nextIndex < 0 { + m.promptHistory.index = -1 + m.textarea.Reset() + m.textarea.InsertString(m.promptHistory.draft) + return true + } + m.promptHistory.index = nextIndex + m.textarea.Reset() + m.textarea.InsertString(m.promptHistory.messages[nextIndex]) + return true +} + +// historyReset resets the history, but does not clear the message +// it just sets the current draft to empty and the position in the history. +func (m *UI) historyReset() { + m.promptHistory.index = -1 + m.promptHistory.draft = "" +} + +// isAtEditorStart returns true if we are at the 0 line and 0 col in the textarea. +func (m *UI) isAtEditorStart() bool { + return m.textarea.Line() == 0 && m.textarea.LineInfo().ColumnOffset == 0 +} + +// isAtEditorEnd returns true if we are in the last line and the last column in the textarea. +func (m *UI) isAtEditorEnd() bool { + lineCount := m.textarea.LineCount() + if lineCount == 0 { + return true + } + if m.textarea.Line() != lineCount-1 { + return false + } + info := m.textarea.LineInfo() + return info.CharOffset >= info.CharWidth-1 || info.CharWidth == 0 +} diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index 6e21e4dee0dbae1dffc124066b01185c7ebc9d3a..cf2fdcaa431b2a9c43a9612ef99ec8ce696216ca 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -15,6 +15,10 @@ type KeyMap struct { AttachmentDeleteMode key.Binding Escape key.Binding DeleteAllAttachments key.Binding + + // History navigation + HistoryPrev key.Binding + HistoryNext key.Binding } Chat struct { @@ -131,6 +135,12 @@ func DefaultKeyMap() KeyMap { key.WithKeys("r"), key.WithHelp("ctrl+r+r", "delete all attachments"), ) + km.Editor.HistoryPrev = key.NewBinding( + key.WithKeys("up"), + ) + km.Editor.HistoryNext = key.NewBinding( + key.WithKeys("down"), + ) km.Chat.NewSession = key.NewBinding( key.WithKeys("ctrl+n"), diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go index 9d7e9ea0882a2d0cf8508ed75f8e5d4b5edb2b03..1cd481f2f9a3625ba0ed8f12c8450265c0aa5ef0 100644 --- a/internal/ui/model/onboarding.go +++ b/internal/ui/model/onboarding.go @@ -48,9 +48,11 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { // initializeProject starts project initialization and transitions to the landing view. func (m *UI) initializeProject() tea.Cmd { // clear the session - m.newSession() - cfg := m.com.Config() var cmds []tea.Cmd + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } + cfg := m.com.Config() initialize := func() tea.Msg { initPrompt, err := agent.InitializePrompt(*cfg) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 121446524cb95fe8f443f551e14273e5327dd1a1..58a6525672310684ff7950ab33c48bce3b00ddfe 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -211,6 +211,13 @@ type UI struct { // mouse highlighting related state lastClickTime time.Time + + // Prompt history for up/down navigation through previous messages. + promptHistory struct { + messages []string + index int + draft string + } } // New creates a new instance of the [UI] model. @@ -307,6 +314,8 @@ func (m *UI) Init() tea.Cmd { } // load the user commands async cmds = append(cmds, m.loadCustomCommands()) + // load prompt history async + cmds = append(cmds, m.loadPromptHistory()) return tea.Batch(cmds...) } @@ -390,6 +399,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.updateLayoutAndSize() } + // Reload prompt history for the new session. + m.historyReset() + cmds = append(cmds, m.loadPromptHistory()) case sendMessageMsg: cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...)) @@ -417,13 +429,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { commands.SetMCPPrompts(m.mcpPrompts) } + case promptHistoryLoadedMsg: + m.promptHistory.messages = msg.messages + m.promptHistory.index = -1 + m.promptHistory.draft = "" + case closeDialogMsg: m.dialog.CloseFrontDialog() case pubsub.Event[session.Session]: if msg.Type == pubsub.DeletedEvent { if m.session != nil && m.session.ID == msg.Payload.ID { - m.newSession() + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } } break } @@ -1095,7 +1114,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) break } - m.newSession() + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionSummarize: if m.isAgentBusy() { @@ -1494,8 +1515,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { } m.randomizePlaceholders() + m.historyReset() - return m.sendMessage(value, attachments...) + return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory()) case key.Matches(msg, m.keyMap.Chat.NewSession): if !m.hasSession() { break @@ -1504,7 +1526,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session...")) break } - m.newSession() + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } case key.Matches(msg, m.keyMap.Tab): if m.state != uiLanding { m.setState(m.state, uiFocusMain) @@ -1524,6 +1548,21 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { ta, cmd := m.textarea.Update(msg) m.textarea = ta cmds = append(cmds, cmd) + case key.Matches(msg, m.keyMap.Editor.HistoryPrev): + cmd := m.handleHistoryUp(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Editor.HistoryNext): + cmd := m.handleHistoryDown(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + case key.Matches(msg, m.keyMap.Editor.Escape): + cmd := m.handleHistoryEscape(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } default: if handleGlobalKeys(msg) { // Handle global keys first before passing to textarea. @@ -1557,6 +1596,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.textarea = ta cmds = append(cmds, cmd) + // Any text modification becomes the current draft. + m.updateHistoryDraft(curValue) + // After updating textarea, check if we need to filter completions. // Skip filtering on the initial @ keystroke since items are loading async. if m.completionsOpen && msg.String() != "@" { @@ -1596,7 +1638,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { break } m.focus = uiFocusEditor - m.newSession() + if cmd := m.newSession(); cmd != nil { + cmds = append(cmds, cmd) + } case key.Matches(msg, m.keyMap.Chat.Expand): m.chat.ToggleExpandedSelectedItem() case key.Matches(msg, m.keyMap.Chat.Up): @@ -2772,9 +2816,10 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti // newSession clears the current session state and prepares for a new session. // The actual session creation happens when the user sends their first message. -func (m *UI) newSession() { +// Returns a command to reload prompt history. +func (m *UI) newSession() tea.Cmd { if !m.hasSession() { - return + return nil } m.session = nil @@ -2786,6 +2831,8 @@ func (m *UI) newSession() { m.pillsExpanded = false m.promptQueue = 0 m.pillsView = "" + m.historyReset() + return m.loadPromptHistory() } // handlePasteMsg handles a paste message. From 632666e5f926f74423bc4c2af39ed713f2327bce Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 27 Jan 2026 15:43:31 -0300 Subject: [PATCH 10/75] chore: fix typo on `crush stats` html page --- internal/cmd/stats/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/stats/index.html b/internal/cmd/stats/index.html index 4b25831f86c76f86bf405d3c9e77ddb2b7d1821e..b2822b132c6af919874523678a42ec32d3a76475 100644 --- a/internal/cmd/stats/index.html +++ b/internal/cmd/stats/index.html @@ -28,7 +28,7 @@
- Generated by {{.Username}} for {{.ProjectName}} in {{.GeneratedAt}}. + Generated by {{.Username}} for {{.ProjectName}} on {{.GeneratedAt}}.
From df2c001c27c3e46b11dbbc57e4bdf7d5215ad7f4 Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 27 Jan 2026 12:25:00 -0700 Subject: [PATCH 11/75] fix(lsp): scope client to working directory (#1792) Files outside the working directory are now rejected by HandlesFile(), preventing external files from being opened on the LSP and triggering spurious diagnostics. Assisted-by: Claude Opus 4.5 via Crush --- internal/lsp/client.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index d2f4ab8c6f1f495ec836198d86621a9df279457b..1f0b09bf990bd50aa06198d88aea034ef3d6453c 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -34,6 +34,9 @@ type Client struct { client *powernap.Client name string + // Working directory this LSP is scoped to. + workDir string + // File types this LSP server handles (e.g., .go, .rs, .py) fileTypes []string @@ -133,6 +136,7 @@ func (c *Client) createPowernapClient() error { } rootURI := string(protocol.URIFromPath(workDir)) + c.workDir = workDir command, err := c.resolver.ResolveValue(c.config.Command) if err != nil { @@ -305,9 +309,22 @@ type OpenFileInfo struct { URI protocol.DocumentURI } -// HandlesFile checks if this LSP client handles the given file based on its extension. +// HandlesFile checks if this LSP client handles the given file based on its +// extension and whether it's within the working directory. func (c *Client) HandlesFile(path string) bool { - // If no file types are specified, handle all files (backward compatibility) + // Check if file is within working directory. + absPath, err := filepath.Abs(path) + if err != nil { + slog.Debug("cannot resolve path", "name", c.name, "file", path, "error", err) + return false + } + relPath, err := filepath.Rel(c.workDir, absPath) + if err != nil || strings.HasPrefix(relPath, "..") { + slog.Debug("file outside workspace", "name", c.name, "file", path, "workDir", c.workDir) + return false + } + + // If no file types are specified, handle all files (backward compatibility). if len(c.fileTypes) == 0 { return true } From c81b02f440a1c442fe731122f5b2150547ab8fd3 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 27 Jan 2026 16:26:00 -0300 Subject: [PATCH 12/75] feat(lsp): auto-discover LSPs (#1834) * feat(lsp): auto-discover LSPs - auto-discover LSPs defined in powernap - faster startup by walking dir only once to check root markers from all LSPs - errors on auto-found LSPs are ignored (e.g. it might match golangci-lint-server but if you don't have it installed it shouldn't show in the list) Signed-off-by: Carlos Alexandro Becker * fix: sidebar improvement Signed-off-by: Carlos Alexandro Becker * fix: if Signed-off-by: Carlos Alexandro Becker * fix: startup, disabled Signed-off-by: Carlos Alexandro Becker * fix: lint Signed-off-by: Carlos Alexandro Becker * fix: remove unneeded func Signed-off-by: Carlos Alexandro Becker * perf: skip empty Signed-off-by: Carlos Alexandro Becker * fix: server names Signed-off-by: Carlos Alexandro Becker * fix: do not show failing non configured lsps Signed-off-by: Carlos Alexandro Becker * fix: allow to disable auto lsp Signed-off-by: Carlos Alexandro Becker * chore: update powernap Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- go.mod | 2 +- go.sum | 4 +- internal/app/app.go | 2 +- internal/app/lsp.go | 89 ++++++++++-- internal/config/config.go | 1 + internal/lsp/client.go | 79 +++++++++-- internal/lsp/filtermatching_test.go | 111 +++++++++++++++ internal/lsp/language.go | 132 ------------------ internal/lsp/rootmarkers_test.go | 37 ----- .../tui/components/chat/sidebar/sidebar.go | 2 - internal/tui/components/lsp/lsp.go | 31 ++-- 11 files changed, 273 insertions(+), 217 deletions(-) create mode 100644 internal/lsp/filtermatching_test.go delete mode 100644 internal/lsp/language.go delete mode 100644 internal/lsp/rootmarkers_test.go diff --git a/go.mod b/go.mod index 30c5613bf400e4568bf0662b6c340d371b1d4268..9281f1b44966d5ce00d19264b6ad29dfc4cb4aa4 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff github.com/charmbracelet/x/exp/strings v0.1.0 - github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b + github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec diff --git a/go.sum b/go.sum index 5f1787b0a9e5372580a3a92dfbb43e2786e582bb..c0d8fdcc25091a334f0f79fcf2e5f91247496fdc 100644 --- a/go.sum +++ b/go.sum @@ -122,8 +122,8 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9 github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ= github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= -github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8= -github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= +github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 h1:h1XMgTkpBt9kEJ+9DkARNBXEgaigUQ0cI2Bot7Awnt8= +github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= diff --git a/internal/app/app.go b/internal/app/app.go index b186c1aeb4f7d0adbc3d0fd443b660952a4def52..ef6e636e44eeea9407557ca48f8ba9bd8eba72b2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -101,7 +101,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { app.setupEvents() // Initialize LSP clients in the background. - app.initLSPClients(ctx) + go app.initLSPClients(ctx) // Check for updates in the background. go app.checkForUpdates(ctx) diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 23a5447af92872223f91d3283cf6663aae0d1d07..39e03d3cb4f2f5a9dc7720f8ce1f7286d4efd6b2 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -3,41 +3,108 @@ package app import ( "context" "log/slog" + "os/exec" + "slices" "time" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/lsp" + powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" ) // initLSPClients initializes LSP clients. func (app *App) initLSPClients(ctx context.Context) { + slog.Info("LSP clients initialization started") + + manager := powernapconfig.NewManager() + manager.LoadDefaults() + + var userConfiguredLSPs []string for name, clientConfig := range app.config.LSP { if clientConfig.Disabled { slog.Info("Skipping disabled LSP client", "name", name) + manager.RemoveServer(name) continue } - go app.createAndStartLSPClient(ctx, name, clientConfig) + + // HACK: the user might have the command name in their config, instead + // of the actual name. This finds out these cases, and adjusts the name + // accordingly. + if _, ok := manager.GetServer(name); !ok { + for sname, server := range manager.GetServers() { + if server.Command == name { + name = sname + break + } + } + } + userConfiguredLSPs = append(userConfiguredLSPs, name) + manager.AddServer(name, &powernapconfig.ServerConfig{ + Command: clientConfig.Command, + Args: clientConfig.Args, + Environment: clientConfig.Env, + FileTypes: clientConfig.FileTypes, + RootMarkers: clientConfig.RootMarkers, + InitOptions: clientConfig.InitOptions, + Settings: clientConfig.Options, + }) + } + + servers := manager.GetServers() + filtered := lsp.FilterMatching(app.config.WorkingDir(), servers) + + for _, name := range userConfiguredLSPs { + if _, ok := filtered[name]; !ok { + updateLSPState(name, lsp.StateDisabled, nil, nil, 0) + } + } + for name, server := range filtered { + if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) { + slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name) + continue + } + go app.createAndStartLSPClient( + ctx, name, + toOurConfig(server), + slices.Contains(userConfiguredLSPs, name), + ) } - slog.Info("LSP clients initialization started in background") } -// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher -func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig) { - slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args) +func toOurConfig(in *powernapconfig.ServerConfig) config.LSPConfig { + return config.LSPConfig{ + Command: in.Command, + Args: in.Args, + Env: in.Environment, + FileTypes: in.FileTypes, + RootMarkers: in.RootMarkers, + InitOptions: in.InitOptions, + Options: in.Settings, + } +} - // Check if any root markers exist in the working directory (config now has defaults) - if !lsp.HasRootMarkers(app.config.WorkingDir(), config.RootMarkers) { - slog.Debug("Skipping LSP client: no root markers found", "name", name, "rootMarkers", config.RootMarkers) - updateLSPState(name, lsp.StateDisabled, nil, nil, 0) - return +// createAndStartLSPClient creates a new LSP client, initializes it, and starts its workspace watcher. +func (app *App) createAndStartLSPClient(ctx context.Context, name string, config config.LSPConfig, userConfigured bool) { + if !userConfigured { + if _, err := exec.LookPath(config.Command); err != nil { + slog.Warn("Default LSP config skipped: server not installed", "name", name, "error", err) + return + } } - // Update state to starting + slog.Debug("Creating LSP client", "name", name, "command", config.Command, "fileTypes", config.FileTypes, "args", config.Args) + + // Update state to starting. updateLSPState(name, lsp.StateStarting, nil, nil, 0) // Create LSP client. lspClient, err := lsp.New(ctx, name, config, app.config.Resolver()) if err != nil { + if !userConfigured { + slog.Warn("Default LSP config skipped due to error", "name", name, "error", err) + updateLSPState(name, lsp.StateDisabled, nil, nil, 0) + return + } slog.Error("Failed to create LSP client for", "name", name, "error", err) updateLSPState(name, lsp.StateError, err, nil, 0) return diff --git a/internal/config/config.go b/internal/config/config.go index eb8394e11972de4c91017a4b92e59ccee804ef0c..510685325fa779c7f53842435049478efeb389fb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -257,6 +257,7 @@ type Options struct { Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"` DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"` InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"` + AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers"` } type MCPs map[string]MCPConfig diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 1f0b09bf990bd50aa06198d88aea034ef3d6453c..98aa75966160ba97af8c431d98c642fb558e5dc7 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -13,10 +13,12 @@ import ( "sync/atomic" "time" + "github.com/bmatcuk/doublestar/v4" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/home" + powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" powernap "github.com/charmbracelet/x/powernap/pkg/lsp" "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" "github.com/charmbracelet/x/powernap/pkg/transport" @@ -329,14 +331,15 @@ func (c *Client) HandlesFile(path string) bool { return true } + kind := powernap.DetectLanguage(path) name := strings.ToLower(filepath.Base(path)) for _, filetype := range c.fileTypes { suffix := strings.ToLower(filetype) if !strings.HasPrefix(suffix, ".") { suffix = "." + suffix } - if strings.HasSuffix(name, suffix) { - slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype) + if strings.HasSuffix(name, suffix) || filetype == string(kind) { + slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind) return true } } @@ -363,7 +366,7 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error { } // Notify the server about the opened document - if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(DetectLanguageID(uri)), 1, string(content)); err != nil { + if err = c.client.NotifyDidOpenTextDocument(ctx, uri, string(powernap.DetectLanguage(filepath)), 1, string(content)); err != nil { return err } @@ -574,18 +577,66 @@ func (c *Client) FindReferences(ctx context.Context, filepath string, line, char return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration) } -// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory. -// Uses glob patterns to match files, allowing for more flexible matching. -func HasRootMarkers(dir string, rootMarkers []string) bool { - if len(rootMarkers) == 0 { - return true +// FilterMatching gets a list of configs and only returns the ones with +// matching root markers. +func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) map[string]*powernapconfig.ServerConfig { + result := map[string]*powernapconfig.ServerConfig{} + if len(servers) == 0 { + return result } - for _, pattern := range rootMarkers { - // Use fsext.GlobWithDoubleStar to find matches - matches, _, err := fsext.GlobWithDoubleStar(pattern, dir, 1) - if err == nil && len(matches) > 0 { - return true + + type serverPatterns struct { + server *powernapconfig.ServerConfig + patterns []string + } + normalized := make(map[string]serverPatterns, len(servers)) + for name, server := range servers { + if len(server.RootMarkers) == 0 { + continue + } + patterns := make([]string, len(server.RootMarkers)) + for i, p := range server.RootMarkers { + patterns[i] = filepath.ToSlash(p) } + normalized[name] = serverPatterns{server: server, patterns: patterns} } - return false + + walker := fsext.NewFastGlobWalker(dir) + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + + if walker.ShouldSkip(path) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + relPath, err := filepath.Rel(dir, path) + if err != nil { + return nil + } + relPath = filepath.ToSlash(relPath) + + for name, sp := range normalized { + for _, pattern := range sp.patterns { + matched, err := doublestar.Match(pattern, relPath) + if err != nil || !matched { + continue + } + result[name] = sp.server + delete(normalized, name) + break + } + } + + if len(normalized) == 0 { + return filepath.SkipAll + } + return nil + }) + + return result } diff --git a/internal/lsp/filtermatching_test.go b/internal/lsp/filtermatching_test.go new file mode 100644 index 0000000000000000000000000000000000000000..40c796916b73169b882404eecfb4625e7baaa85b --- /dev/null +++ b/internal/lsp/filtermatching_test.go @@ -0,0 +1,111 @@ +package lsp + +import ( + "os" + "path/filepath" + "testing" + + powernapconfig "github.com/charmbracelet/x/powernap/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestFilterMatching(t *testing.T) { + t.Parallel() + + t.Run("matches servers with existing root markers", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644)) + + servers := map[string]*powernapconfig.ServerConfig{ + "gopls": {RootMarkers: []string{"go.mod", "go.work"}}, + "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}}, + "typescript-lsp": {RootMarkers: []string{"package.json", "tsconfig.json"}}, + } + + result := FilterMatching(tmpDir, servers) + + require.Contains(t, result, "gopls") + require.Contains(t, result, "rust-analyzer") + require.NotContains(t, result, "typescript-lsp") + }) + + t.Run("returns empty for empty servers", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + result := FilterMatching(tmpDir, map[string]*powernapconfig.ServerConfig{}) + + require.Empty(t, result) + }) + + t.Run("returns empty when no markers match", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + servers := map[string]*powernapconfig.ServerConfig{ + "gopls": {RootMarkers: []string{"go.mod"}}, + "python": {RootMarkers: []string{"pyproject.toml"}}, + } + + result := FilterMatching(tmpDir, servers) + + require.Empty(t, result) + }) + + t.Run("glob patterns work", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "src"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "src", "main.go"), []byte("package main"), 0o644)) + + servers := map[string]*powernapconfig.ServerConfig{ + "gopls": {RootMarkers: []string{"**/*.go"}}, + "python": {RootMarkers: []string{"**/*.py"}}, + } + + result := FilterMatching(tmpDir, servers) + + require.Contains(t, result, "gopls") + require.NotContains(t, result, "python") + }) + + t.Run("servers with empty root markers are not included", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644)) + + servers := map[string]*powernapconfig.ServerConfig{ + "gopls": {RootMarkers: []string{"go.mod"}}, + "generic": {RootMarkers: []string{}}, + } + + result := FilterMatching(tmpDir, servers) + + require.Contains(t, result, "gopls") + require.NotContains(t, result, "generic") + }) + + t.Run("stops early when all servers match", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Cargo.toml"), []byte("[package]"), 0o644)) + + servers := map[string]*powernapconfig.ServerConfig{ + "gopls": {RootMarkers: []string{"go.mod"}}, + "rust-analyzer": {RootMarkers: []string{"Cargo.toml"}}, + } + + result := FilterMatching(tmpDir, servers) + + require.Len(t, result, 2) + require.Contains(t, result, "gopls") + require.Contains(t, result, "rust-analyzer") + }) +} diff --git a/internal/lsp/language.go b/internal/lsp/language.go deleted file mode 100644 index 7d6a1517e849b6f09352447b2acb05539b3220af..0000000000000000000000000000000000000000 --- a/internal/lsp/language.go +++ /dev/null @@ -1,132 +0,0 @@ -package lsp - -import ( - "path/filepath" - "strings" - - "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" -) - -func DetectLanguageID(uri string) protocol.LanguageKind { - ext := strings.ToLower(filepath.Ext(uri)) - switch ext { - case ".abap": - return protocol.LangABAP - case ".bat": - return protocol.LangWindowsBat - case ".bib", ".bibtex": - return protocol.LangBibTeX - case ".clj": - return protocol.LangClojure - case ".coffee": - return protocol.LangCoffeescript - case ".c": - return protocol.LangC - case ".cpp", ".cxx", ".cc", ".c++": - return protocol.LangCPP - case ".cs": - return protocol.LangCSharp - case ".css": - return protocol.LangCSS - case ".d": - return protocol.LangD - case ".pas", ".pascal": - return protocol.LangDelphi - case ".diff", ".patch": - return protocol.LangDiff - case ".dart": - return protocol.LangDart - case ".dockerfile": - return protocol.LangDockerfile - case ".ex", ".exs": - return protocol.LangElixir - case ".erl", ".hrl": - return protocol.LangErlang - case ".fs", ".fsi", ".fsx", ".fsscript": - return protocol.LangFSharp - case ".gitcommit": - return protocol.LangGitCommit - case ".gitrebase": - return protocol.LangGitRebase - case ".go": - return protocol.LangGo - case ".groovy": - return protocol.LangGroovy - case ".hbs", ".handlebars": - return protocol.LangHandlebars - case ".hs": - return protocol.LangHaskell - case ".html", ".htm": - return protocol.LangHTML - case ".ini": - return protocol.LangIni - case ".java": - return protocol.LangJava - case ".js": - return protocol.LangJavaScript - case ".jsx": - return protocol.LangJavaScriptReact - case ".json": - return protocol.LangJSON - case ".tex", ".latex": - return protocol.LangLaTeX - case ".less": - return protocol.LangLess - case ".lua": - return protocol.LangLua - case ".makefile", "makefile": - return protocol.LangMakefile - case ".md", ".markdown": - return protocol.LangMarkdown - case ".m": - return protocol.LangObjectiveC - case ".mm": - return protocol.LangObjectiveCPP - case ".pl": - return protocol.LangPerl - case ".pm": - return protocol.LangPerl6 - case ".php": - return protocol.LangPHP - case ".ps1", ".psm1": - return protocol.LangPowershell - case ".pug", ".jade": - return protocol.LangPug - case ".py": - return protocol.LangPython - case ".r": - return protocol.LangR - case ".cshtml", ".razor": - return protocol.LangRazor - case ".rb": - return protocol.LangRuby - case ".rs": - return protocol.LangRust - case ".scss": - return protocol.LangSCSS - case ".sass": - return protocol.LangSASS - case ".scala": - return protocol.LangScala - case ".shader": - return protocol.LangShaderLab - case ".sh", ".bash", ".zsh", ".ksh": - return protocol.LangShellScript - case ".sql": - return protocol.LangSQL - case ".swift": - return protocol.LangSwift - case ".ts": - return protocol.LangTypeScript - case ".tsx": - return protocol.LangTypeScriptReact - case ".xml": - return protocol.LangXML - case ".xsl": - return protocol.LangXSL - case ".yaml", ".yml": - return protocol.LangYAML - default: - return protocol.LanguageKind("") // Unknown language - } -} diff --git a/internal/lsp/rootmarkers_test.go b/internal/lsp/rootmarkers_test.go deleted file mode 100644 index 7b3a3c0905799865808b9b1ae0dff992e00ed34c..0000000000000000000000000000000000000000 --- a/internal/lsp/rootmarkers_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package lsp - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestHasRootMarkers(t *testing.T) { - t.Parallel() - - // Create a temporary directory for testing - tmpDir := t.TempDir() - - // Test with empty root markers (should return true) - require.True(t, HasRootMarkers(tmpDir, []string{})) - - // Test with non-existent markers - require.False(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"})) - - // Create a go.mod file - goModPath := filepath.Join(tmpDir, "go.mod") - err := os.WriteFile(goModPath, []byte("module test"), 0o644) - require.NoError(t, err) - - // Test with existing marker - require.True(t, HasRootMarkers(tmpDir, []string{"go.mod", "package.json"})) - - // Test with only non-existent markers - require.False(t, HasRootMarkers(tmpDir, []string{"package.json", "Cargo.toml"})) - - // Test with glob patterns - require.True(t, HasRootMarkers(tmpDir, []string{"*.mod"})) - require.False(t, HasRootMarkers(tmpDir, []string{"*.json"})) -} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 9b3d52dadb9a7677bdb5db4b3a8360e7385775ba..a454605c8ee2938fa02d98d9770704388d0bd38a 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -480,8 +480,6 @@ func (m *sidebarCmp) filesBlock() string { func (m *sidebarCmp) lspBlock() string { // Limit the number of LSPs shown _, maxLSPs, _ := m.getDynamicLimits() - lspConfigs := config.Get().LSP.Sorted() - maxLSPs = min(len(lspConfigs), maxLSPs) return lspcomponent.RenderLSPBlock(m.lspClients, lspcomponent.RenderOptions{ MaxWidth: m.getMaxWidth(), diff --git a/internal/tui/components/lsp/lsp.go b/internal/tui/components/lsp/lsp.go index f9118143cbfd9a7bf19aa569bc85448746debecd..3379c2c9acfd7e7e10d6e6777e2554d0b0db2144 100644 --- a/internal/tui/components/lsp/lsp.go +++ b/internal/tui/components/lsp/lsp.go @@ -2,6 +2,8 @@ package lsp import ( "fmt" + "maps" + "slices" "strings" "charm.land/lipgloss/v2" @@ -35,32 +37,32 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption lspList = append(lspList, section, "") } - lspConfigs := config.Get().LSP.Sorted() - if len(lspConfigs) == 0 { + // Get LSP states + lsps := slices.SortedFunc(maps.Values(app.GetLSPStates()), func(a, b app.LSPClientInfo) int { + return strings.Compare(a.Name, b.Name) + }) + if len(lsps) == 0 { lspList = append(lspList, t.S().Base.Foreground(t.Border).Render("None")) return lspList } - // Get LSP states - lspStates := app.GetLSPStates() - // Determine how many items to show - maxItems := len(lspConfigs) + maxItems := len(lsps) if opts.MaxItems > 0 { - maxItems = min(opts.MaxItems, len(lspConfigs)) + maxItems = min(opts.MaxItems, len(lsps)) } - for i, l := range lspConfigs { + for i, info := range lsps { if i >= maxItems { break } - icon, description := iconAndDescription(l, t, lspStates) + icon, description := iconAndDescription(t, info) // Calculate diagnostic counts if we have LSP clients var extraContent string if lspClients != nil { - if client, ok := lspClients.Get(l.Name); ok { + if client, ok := lspClients.Get(info.Name); ok { counts := client.GetDiagnosticCounts() errs := []string{} if counts.Error > 0 { @@ -83,7 +85,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption core.Status( core.StatusOpts{ Icon: icon.String(), - Title: l.Name, + Title: info.Name, Description: description, ExtraContent: extraContent, }, @@ -95,12 +97,7 @@ func RenderLSPList(lspClients *csync.Map[string, *lsp.Client], opts RenderOption return lspList } -func iconAndDescription(l config.LSP, t *styles.Theme, states map[string]app.LSPClientInfo) (lipgloss.Style, string) { - if l.LSP.Disabled { - return t.ItemOfflineIcon.Foreground(t.FgMuted), t.S().Subtle.Render("disabled") - } - - info := states[l.Name] +func iconAndDescription(t *styles.Theme, info app.LSPClientInfo) (lipgloss.Style, string) { switch info.State { case lsp.StateStarting: return t.ItemBusyIcon, t.S().Subtle.Render("starting...") From 50ae9f26e0d3f83fd76b0cf50b7a38925abf7548 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:27:46 +0000 Subject: [PATCH 13/75] chore: auto-update files --- internal/agent/hyper/provider.json | 2 +- schema.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/agent/hyper/provider.json b/internal/agent/hyper/provider.json index 5558750e38e35024615b41b71243888a1a1ebd6c..d2d0fc0d6edbce4e4e87626bcd2f09af4c9c8f14 100644 --- a/internal/agent/hyper/provider.json +++ b/internal/agent/hyper/provider.json @@ -1 +1 @@ -{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-sonnet-4-5","default_small_model_id":"claude-3-5-haiku","models":[{"id":"Kimi-K2-0905","name":"Kimi K2 0905","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"claude-3-5-haiku","name":"Claude 3.5 Haiku","cost_per_1m_in":0.7999999999999999,"cost_per_1m_out":4,"cost_per_1m_in_cached":1,"cost_per_1m_out_cached":0.08,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-5-sonnet","name":"Claude 3.5 Sonnet (New)","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":5000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"claude-3-7-sonnet","name":"Claude 3.7 Sonnet","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-haiku-4-5","name":"Claude 4.5 Haiku","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4","name":"Claude Opus 4","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-1","name":"Claude Opus 4.1","cost_per_1m_in":15,"cost_per_1m_out":75,"cost_per_1m_in_cached":18.75,"cost_per_1m_out_cached":1.5,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4","name":"Claude Sonnet 4","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","cost_per_1m_in":0.3,"cost_per_1m_out":2.5,"cost_per_1m_in_cached":0.3833,"cost_per_1m_out_cached":0.075,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":1.625,"cost_per_1m_out_cached":0.31,"context_window":1048576,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM-4.6","cost_per_1m_in":0.6,"cost_per_1m_out":2.2,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":204800,"default_max_tokens":131072,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-4.1","name":"GPT-4.1","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-mini","name":"GPT-4.1 Mini","cost_per_1m_in":0.39999999999999997,"cost_per_1m_out":1.5999999999999999,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.09999999999999999,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4.1-nano","name":"GPT-4.1 Nano","cost_per_1m_in":0.09999999999999999,"cost_per_1m_out":0.39999999999999997,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.024999999999999998,"context_window":1047576,"default_max_tokens":16384,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o","name":"GPT-4o","cost_per_1m_in":2.5,"cost_per_1m_out":10,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":1.25,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-4o-mini","name":"GPT-4o-mini","cost_per_1m_in":0.15,"cost_per_1m_out":0.6,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.075,"context_window":128000,"default_max_tokens":8192,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"gpt-5","name":"GPT-5","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-codex","name":"GPT-5 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-mini","name":"GPT-5 Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5-nano","name":"GPT-5 Nano","cost_per_1m_in":0.05,"cost_per_1m_out":0.4,"cost_per_1m_in_cached":0.005,"cost_per_1m_out_cached":0.005,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1","name":"GPT-5.1","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT-5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3","name":"o3","cost_per_1m_in":2,"cost_per_1m_out":8,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"o3-mini","name":"o3 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.55,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"o4-mini","name":"o4 Mini","cost_per_1m_in":1.1,"cost_per_1m_out":4.4,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.275,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"qwen3-coder-480b-a35b-instruct","name":"Qwen 3 480B Coder","cost_per_1m_in":0.82,"cost_per_1m_out":3.29,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":131072,"default_max_tokens":65536,"can_reason":false,"supports_attachments":false,"options":{}}]} \ No newline at end of file +{"name":"Charm Hyper","id":"hyper","api_endpoint":"https://console.charm.land/api/v1/fantasy","type":"hyper","default_large_model_id":"claude-opus-4-5","default_small_model_id":"claude-haiku-4-5","models":[{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","cost_per_1m_in":1,"cost_per_1m_out":5,"cost_per_1m_in_cached":1.25,"cost_per_1m_out_cached":0.09999999999999999,"context_window":200000,"default_max_tokens":32000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-opus-4-5","name":"Claude Opus 4.5","cost_per_1m_in":5,"cost_per_1m_out":25,"cost_per_1m_in_cached":6.25,"cost_per_1m_out_cached":0.5,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","cost_per_1m_in":3,"cost_per_1m_out":15,"cost_per_1m_in_cached":3.75,"cost_per_1m_out_cached":0.3,"context_window":200000,"default_max_tokens":50000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"gemini-3-flash","name":"Gemini 3 Flash","cost_per_1m_in":0.5,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.049999999999999996,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro","cost_per_1m_in":2,"cost_per_1m_out":12,"cost_per_1m_in_cached":0.19999999999999998,"cost_per_1m_out_cached":0,"context_window":1000000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"glm-4.6","name":"GLM 4.6","cost_per_1m_in":0.44999999999999996,"cost_per_1m_out":1.7999999999999998,"cost_per_1m_in_cached":0.11,"cost_per_1m_out_cached":0,"context_window":200000,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"glm-4.7","name":"GLM 4.7","cost_per_1m_in":0.43,"cost_per_1m_out":1.75,"cost_per_1m_in_cached":0.08,"cost_per_1m_out_cached":0,"context_window":202752,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"gpt-5.1-codex","name":"GPT 5.1 Codex","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-max","name":"GPT 5.1 Codex Max","cost_per_1m_in":1.25,"cost_per_1m_out":10,"cost_per_1m_in_cached":0.125,"cost_per_1m_out_cached":0.125,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.1-codex-mini","name":"GPT 5.1 Codex Mini","cost_per_1m_in":0.25,"cost_per_1m_out":2,"cost_per_1m_in_cached":0.025,"cost_per_1m_out_cached":0.025,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2","name":"GPT 5.2","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"gpt-5.2-codex","name":"GPT 5.2 Codex","cost_per_1m_in":1.75,"cost_per_1m_out":14,"cost_per_1m_in_cached":0.175,"cost_per_1m_out_cached":0.175,"context_window":400000,"default_max_tokens":128000,"can_reason":true,"reasoning_levels":["minimal","low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-non-reasoning","name":"Grok 4.1 Fast Non Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":false,"supports_attachments":true,"options":{}},{"id":"grok-4.1-fast-reasoning","name":"Grok 4.1 Fast Reasoning","cost_per_1m_in":0.2,"cost_per_1m_out":0.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.05,"context_window":2000000,"default_max_tokens":200000,"can_reason":true,"supports_attachments":true,"options":{}},{"id":"grok-code-fast-1","name":"Grok Code Fast","cost_per_1m_in":0.2,"cost_per_1m_out":1.5,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0.02,"context_window":256000,"default_max_tokens":20000,"can_reason":true,"supports_attachments":false,"options":{}},{"id":"kimi-k2-0905","name":"Kimi K2","cost_per_1m_in":0.55,"cost_per_1m_out":2.19,"cost_per_1m_in_cached":0,"cost_per_1m_out_cached":0,"context_window":256000,"default_max_tokens":10000,"can_reason":true,"default_reasoning_effort":"medium","supports_attachments":false,"options":{}},{"id":"kimi-k2.5","name":"Kimi K2.5","cost_per_1m_in":0.6,"cost_per_1m_out":3,"cost_per_1m_in_cached":0.09999999999999999,"cost_per_1m_out_cached":0,"context_window":262114,"default_max_tokens":8000,"can_reason":true,"reasoning_levels":["low","medium","high"],"default_reasoning_effort":"medium","supports_attachments":true,"options":{}}]} \ No newline at end of file diff --git a/schema.json b/schema.json index 6eeaa40c1865ebb5e46f70964f4eba69cf47013e..0d236265a09cf301553da812c67079323c9ea20a 100644 --- a/schema.json +++ b/schema.json @@ -435,6 +435,10 @@ "CLAUDE.md", "docs/LLMs.md" ] + }, + "auto_lsp": { + "type": "boolean", + "description": "Automatically setup LSPs based on root markers" } }, "additionalProperties": false, From 99aabb0179cb65f46166b4add30156360afb0ba9 Mon Sep 17 00:00:00 2001 From: huaiyuWangh <34158348+huaiyuWangh@users.noreply.github.com> Date: Wed, 28 Jan 2026 03:30:09 +0800 Subject: [PATCH 14/75] fix: schema incorrectly marks optional fields as required (#1996) This fixes two schema validation issues: 1. tools.ls incorrectly marked as required - Changed Tools.Ls and Config.Tools from omitzero to omitempty - The invopop/jsonschema library doesn't recognize Go 1.25's omitzero tag 2. lsp.command incorrectly marked as required - Removed jsonschema:"required" tag from LSPConfig.Command - The project's own crush.json doesn't include command field for gopls After this fix, users can use minimal configurations without being forced to specify tools.ls or lsp.command fields. --- internal/config/config.go | 6 +++--- schema.json | 15 +++------------ 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 510685325fa779c7f53842435049478efeb389fb..d18d2d9c61d2f791ab9c6f9a0b7cd41029b70e60 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -187,7 +187,7 @@ type MCPConfig struct { type LSPConfig struct { Disabled bool `json:"disabled,omitempty" jsonschema:"description=Whether this LSP server is disabled,default=false"` - Command string `json:"command,omitempty" jsonschema:"required,description=Command to execute for the LSP server,example=gopls"` + Command string `json:"command,omitempty" jsonschema:"description=Command to execute for the LSP server,example=gopls"` Args []string `json:"args,omitempty" jsonschema:"description=Arguments to pass to the LSP server command"` Env map[string]string `json:"env,omitempty" jsonschema:"description=Environment variables to set to the LSP server command"` FileTypes []string `json:"filetypes,omitempty" jsonschema:"description=File types this LSP server handles,example=go,example=mod,example=rs,example=c,example=js,example=ts"` @@ -347,7 +347,7 @@ type Agent struct { } type Tools struct { - Ls ToolLs `json:"ls,omitzero"` + Ls ToolLs `json:"ls,omitempty"` } type ToolLs struct { @@ -380,7 +380,7 @@ type Config struct { Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"` - Tools Tools `json:"tools,omitzero" jsonschema:"description=Tool configurations"` + Tools Tools `json:"tools,omitempty" jsonschema:"description=Tool configurations"` Agents map[string]Agent `json:"-"` diff --git a/schema.json b/schema.json index 0d236265a09cf301553da812c67079323c9ea20a..47b19589f29cdf6165f0b5c93a97168e3396e6bd 100644 --- a/schema.json +++ b/schema.json @@ -92,10 +92,7 @@ } }, "additionalProperties": false, - "type": "object", - "required": [ - "tools" - ] + "type": "object" }, "LSPConfig": { "properties": { @@ -162,10 +159,7 @@ } }, "additionalProperties": false, - "type": "object", - "required": [ - "command" - ] + "type": "object" }, "LSPs": { "additionalProperties": { @@ -702,10 +696,7 @@ } }, "additionalProperties": false, - "type": "object", - "required": [ - "ls" - ] + "type": "object" } } } From c6b0a8a13ec47ebddcbc2b6ab14d2006cc1d4acd Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 27 Jan 2026 17:25:57 -0500 Subject: [PATCH 15/75] refactor: terminal capability handling (#2014) --- internal/cmd/root.go | 2 +- internal/ui/common/capabilities.go | 133 +++++++++++++++++++++++++++++ internal/ui/dialog/filepicker.go | 18 ++-- internal/ui/image/image.go | 50 ----------- internal/ui/model/ui.go | 35 ++------ 5 files changed, 153 insertions(+), 85 deletions(-) create mode 100644 internal/ui/common/capabilities.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 351c9d414dd28b596374cf3a99459a1098d3c41b..577d4ccb4abaa79275a5a556c463cb52b16aab11 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -98,7 +98,6 @@ crush -y slog.Info("New UI in control!") com := common.DefaultCommon(app) ui := ui.New(com) - ui.QueryCapabilities = shouldQueryCapabilities(env) model = ui } else { ui := tui.New(app) @@ -303,6 +302,7 @@ func createDotCrushDir(dir string) error { return nil } +// TODO: Remove me after dropping the old TUI. func shouldQueryCapabilities(env uv.Environ) bool { const osVendorTypeApple = "Apple" termType := env.Getenv("TERM") diff --git a/internal/ui/common/capabilities.go b/internal/ui/common/capabilities.go new file mode 100644 index 0000000000000000000000000000000000000000..6636976d7d4f86d9283be2db759b44f948ad40f5 --- /dev/null +++ b/internal/ui/common/capabilities.go @@ -0,0 +1,133 @@ +package common + +import ( + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + xstrings "github.com/charmbracelet/x/exp/strings" +) + +// Capabilities define different terminal capabilities supported. +type Capabilities struct { + // Profile is the terminal color profile used to determine how colors are + // rendered. + Profile colorprofile.Profile + // Columns is the number of character columns in the terminal. + Columns int + // Rows is the number of character rows in the terminal. + Rows int + // PixelX is the width of the terminal in pixels. + PixelX int + // PixelY is the height of the terminal in pixels. + PixelY int + // KittyGraphics indicates whether the terminal supports the Kitty graphics + // protocol. + KittyGraphics bool + // SixelGraphics indicates whether the terminal supports Sixel graphics. + SixelGraphics bool + // Env is the terminal environment variables. + Env uv.Environ + // TerminalVersion is the terminal version string. + TerminalVersion string + // ReportFocusEvents indicates whether the terminal supports focus events. + ReportFocusEvents bool +} + +// Update updates the capabilities based on the given message. +func (c *Capabilities) Update(msg any) { + switch m := msg.(type) { + case tea.EnvMsg: + c.Env = uv.Environ(m) + case tea.ColorProfileMsg: + c.Profile = m.Profile + case tea.WindowSizeMsg: + c.Columns = m.Width + c.Rows = m.Height + case uv.WindowPixelSizeEvent: + c.PixelX = m.Width + c.PixelY = m.Height + case uv.KittyGraphicsEvent: + c.KittyGraphics = true + case uv.PrimaryDeviceAttributesEvent: + if slices.Contains(m, 4) { + c.SixelGraphics = true + } + case tea.TerminalVersionMsg: + c.TerminalVersion = m.Name + case uv.ModeReportEvent: + switch m.Mode { + case ansi.ModeFocusEvent: + c.ReportFocusEvents = modeSupported(m.Value) + } + } +} + +// QueryCmd returns a [tea.Cmd] that queries the terminal for different +// capabilities. +func QueryCmd(env uv.Environ) tea.Cmd { + var sb strings.Builder + sb.WriteString(ansi.RequestPrimaryDeviceAttributes) + + // Queries that should only be sent to "smart" normal terminals. + shouldQueryFor := shouldQueryCapabilities(env) + if shouldQueryFor { + sb.WriteString(ansi.RequestNameVersion) + // sb.WriteString(ansi.RequestModeFocusEvent) // TODO: re-enable when we need notifications. + sb.WriteString(ansi.WindowOp(14)) // Window size in pixels + kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24") + if _, isTmux := env.LookupEnv("TMUX"); isTmux { + kittyReq = ansi.TmuxPassthrough(kittyReq) + } + sb.WriteString(kittyReq) + } + + return tea.Raw(sb.String()) +} + +// SupportsTrueColor returns true if the terminal supports true color. +func (c Capabilities) SupportsTrueColor() bool { + return c.Profile == colorprofile.TrueColor +} + +// SupportsKittyGraphics returns true if the terminal supports Kitty graphics. +func (c Capabilities) SupportsKittyGraphics() bool { + return c.KittyGraphics +} + +// SupportsSixelGraphics returns true if the terminal supports Sixel graphics. +func (c Capabilities) SupportsSixelGraphics() bool { + return c.SixelGraphics +} + +// CellSize returns the size of a single terminal cell in pixels. +func (c Capabilities) CellSize() (width, height int) { + if c.Columns == 0 || c.Rows == 0 { + return 0, 0 + } + return c.PixelX / c.Columns, c.PixelY / c.Rows +} + +func modeSupported(v ansi.ModeSetting) bool { + return v.IsSet() || v.IsReset() +} + +// kittyTerminals defines terminals supporting querying capabilities. +var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"} + +func shouldQueryCapabilities(env uv.Environ) bool { + const osVendorTypeApple = "Apple" + termType := env.Getenv("TERM") + termProg, okTermProg := env.LookupEnv("TERM_PROGRAM") + _, okSSHTTY := env.LookupEnv("SSH_TTY") + if okTermProg && strings.Contains(termProg, osVendorTypeApple) { + return false + } + return (!okTermProg && !okSSHTTY) || + (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) || + // Terminals that do support XTVERSION. + xstrings.ContainsAnyOf(termType, kittyTerminals...) +} diff --git a/internal/ui/dialog/filepicker.go b/internal/ui/dialog/filepicker.go index ce4adcf8b2dc759f5eceff6ad0d7f6d1728fb7de..4b0b844e4ed869a4347af10e9d0b1b3c70a7d2f0 100644 --- a/internal/ui/dialog/filepicker.go +++ b/internal/ui/dialog/filepicker.go @@ -29,7 +29,7 @@ type FilePicker struct { imgEnc fimage.Encoding imgPrevWidth, imgPrevHeight int - cellSize fimage.CellSize + cellSizeW, cellSizeH int fp filepicker.Model help help.Model @@ -47,6 +47,14 @@ type FilePicker struct { } } +// CellSize returns the cell size used for image rendering. +func (f *FilePicker) CellSize() fimage.CellSize { + return fimage.CellSize{ + Width: f.cellSizeW, + Height: f.cellSizeH, + } +} + var _ Dialog = (*FilePicker)(nil) // NewFilePicker creates a new [FilePicker] dialog. @@ -103,12 +111,12 @@ func NewFilePicker(com *common.Common) (*FilePicker, tea.Cmd) { } // SetImageCapabilities sets the image capabilities for the [FilePicker]. -func (f *FilePicker) SetImageCapabilities(caps *fimage.Capabilities) { +func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) { if caps != nil { - if caps.SupportsKittyGraphics { + if caps.SupportsKittyGraphics() { f.imgEnc = fimage.EncodingKitty } - f.cellSize = caps.CellSize() + f.cellSizeW, f.cellSizeH = caps.CellSize() _, f.isTmux = caps.Env.LookupEnv("TMUX") } } @@ -186,7 +194,7 @@ func (f *FilePicker) HandleMsg(msg tea.Msg) Action { img, err := loadImage(selFile) if err == nil { cmds = append(cmds, tea.Sequence( - f.imgEnc.Transmit(selFile, img, f.cellSize, f.imgPrevWidth, f.imgPrevHeight, f.isTmux), + f.imgEnc.Transmit(selFile, img, f.CellSize(), f.imgPrevWidth, f.imgPrevHeight, f.isTmux), func() tea.Msg { f.previewingImage = true return nil diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 6af76531ff5b542f180e38fb7db105e4a86b49b6..5644146fec5b1e4e1e3a96c92a315c0bf986180d 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -13,62 +13,12 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/crush/internal/uiutil" - uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi/kitty" "github.com/disintegration/imaging" paintbrush "github.com/jordanella/go-ansi-paintbrush" ) -// Capabilities represents the capabilities of displaying images on the -// terminal. -type Capabilities struct { - // Columns is the number of character columns in the terminal. - Columns int - // Rows is the number of character rows in the terminal. - Rows int - // PixelWidth is the width of the terminal in pixels. - PixelWidth int - // PixelHeight is the height of the terminal in pixels. - PixelHeight int - // SupportsKittyGraphics indicates whether the terminal supports the Kitty - // graphics protocol. - SupportsKittyGraphics bool - // Env is the terminal environment variables. - Env uv.Environ -} - -// CellSize returns the size of a single terminal cell in pixels. -func (c Capabilities) CellSize() CellSize { - return CalculateCellSize(c.PixelWidth, c.PixelHeight, c.Columns, c.Rows) -} - -// CalculateCellSize calculates the size of a single terminal cell in pixels -// based on the terminal's pixel dimensions and character dimensions. -func CalculateCellSize(pixelWidth, pixelHeight, charWidth, charHeight int) CellSize { - if charWidth == 0 || charHeight == 0 { - return CellSize{} - } - - return CellSize{ - Width: pixelWidth / charWidth, - Height: pixelHeight / charHeight, - } -} - -// RequestCapabilities is a [tea.Cmd] that requests the terminal to report -// its image related capabilities to the program. -func RequestCapabilities(env uv.Environ) tea.Cmd { - winOpReq := ansi.WindowOp(14) // Window size in pixels - // ID 31 is just a random ID used to detect Kitty graphics support. - kittyReq := ansi.KittyGraphics([]byte("AAAA"), "i=31", "s=1", "v=1", "a=q", "t=d", "f=24") - if _, isTmux := env.LookupEnv("TMUX"); isTmux { - kittyReq = ansi.TmuxPassthrough(kittyReq) - } - - return tea.Raw(winOpReq + kittyReq) -} - // TransmittedMsg is a message indicating that an image has been transmitted to // the terminal. type TransmittedMsg struct { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 58a6525672310684ff7950ab33c48bce3b00ddfe..1f2d7f86ef1953bf97e98109cbbe5d791c94122f 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -42,7 +42,6 @@ import ( "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/completions" "github.com/charmbracelet/crush/internal/ui/dialog" - timage "github.com/charmbracelet/crush/internal/ui/image" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/uiutil" @@ -145,9 +144,8 @@ type UI struct { // terminal. sendProgressBar bool - // QueryCapabilities instructs the TUI to query for the terminal version when it - // starts. - QueryCapabilities bool + // caps hold different terminal capabilities that we query for. + caps common.Capabilities // Editor components textarea textarea.Model @@ -182,9 +180,6 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string - // imgCaps stores the terminal image capabilities. - imgCaps timage.Capabilities - // custom commands & mcp commands customCommands []commands.CustomCommand mcpPrompts []commands.MCPPrompt @@ -304,9 +299,6 @@ func New(com *common.Common) *UI { // Init initializes the UI model. func (m *UI) Init() tea.Cmd { var cmds []tea.Cmd - if m.QueryCapabilities { - cmds = append(cmds, tea.RequestTerminalVersion) - } if m.state == uiOnboarding { if cmd := m.openModelsDialog(); cmd != nil { cmds = append(cmds, cmd) @@ -363,19 +355,15 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateLayoutAndSize() } } + // Update terminal capabilities + m.caps.Update(msg) switch msg := msg.(type) { case tea.EnvMsg: // Is this Windows Terminal? if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } - m.imgCaps.Env = uv.Environ(msg) - // Only query for image capabilities if the terminal is known to - // support Kitty graphics protocol. This prevents character bleeding - // on terminals that don't understand the APC escape sequences. - if m.QueryCapabilities { - cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env)) - } + cmds = append(cmds, common.QueryCmd(uv.Environ(msg))) case loadSessionMsg: if m.forceCompactMode { m.isCompact = true @@ -521,8 +509,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.updateLayoutAndSize() - // XXX: We need to store cell dimensions for image rendering. - m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height case tea.KeyboardEnhancementsMsg: m.keyenh = msg if msg.SupportsKeyDisambiguation() { @@ -689,16 +675,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.completionsOpen { m.completions.SetFiles(msg.Files) } - case uv.WindowPixelSizeEvent: - // [timage.RequestCapabilities] requests the terminal to send a window - // size event to help determine pixel dimensions. - m.imgCaps.PixelWidth = msg.Width - m.imgCaps.PixelHeight = msg.Height case uv.KittyGraphicsEvent: - // [timage.RequestCapabilities] sends a Kitty graphics query and this - // captures the response. Any response means the terminal understands - // the protocol. - m.imgCaps.SupportsKittyGraphics = true if !bytes.HasPrefix(msg.Payload, []byte("OK")) { slog.Warn("unexpected Kitty graphics response", "response", string(msg.Payload), @@ -2776,7 +2753,7 @@ func (m *UI) openFilesDialog() tea.Cmd { } filePicker, cmd := dialog.NewFilePicker(m.com) - filePicker.SetImageCapabilities(&m.imgCaps) + filePicker.SetImageCapabilities(&m.caps) m.dialog.OpenDialog(filePicker) return cmd From fea878e4d4c315f91c190d589891eddbeb8f7ac4 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 28 Jan 2026 10:41:57 -0300 Subject: [PATCH 16/75] feat(mcp): support server side instructions (#2015) * feat(mcp): support server side instructions Signed-off-by: Carlos Alexandro Becker * fix: empty lines Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- internal/agent/agent.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 815ba2fa8f3c78db8de593849a83ed161e1ee008..74a1a9f0c94483268b4b3558c7d4ca7a9899c7ef 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -32,6 +32,7 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/agent/tools" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/message" @@ -167,6 +168,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy largeModel := a.largeModel.Get() systemPrompt := a.systemPrompt.Get() promptPrefix := a.systemPromptPrefix.Get() + var instructions strings.Builder + + for _, server := range mcp.GetStates() { + if server.State != mcp.StateConnected { + continue + } + if s := server.Client.InitializeResult().Instructions; s != "" { + instructions.WriteString(s) + instructions.WriteString("\n\n") + } + } + + if s := instructions.String(); s != "" { + systemPrompt += "\n\n\n" + s + "\n" + } if len(agentTools) > 0 { // Add Anthropic caching to the last tool. From 9602140845188f053c30d980cc85cdacb99f0f6f Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 28 Jan 2026 11:27:29 -0300 Subject: [PATCH 17/75] ci: format nix (#2009) Signed-off-by: Carlos Alexandro Becker --- .goreleaser.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index 784201677ed863e460818d98ac54e651bbfb7fee..0ba2b1eccdf6de70c3e39d9111074a84658bd2a3 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -268,6 +268,7 @@ nix: name: "Charm" email: "charmcli@users.noreply.github.com" license: fsl11Mit + formatter: nixfmt skip_upload: "{{ with .Prerelease }}true{{ end }}" extra_install: |- installManPage ./manpages/crush.1.gz From 008be3f20fedc4eeda40e6a142883fa47dc9ecba Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:42:39 -0300 Subject: [PATCH 18/75] chore(legal): @oug-t has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 5b5e74252b831d49bdec16557311a8e39de71b16..7d0cf20f6a37d57d7da19c324d836d784a881811 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1127,6 +1127,14 @@ "created_at": "2026-01-24T22:42:46Z", "repoId": 987670088, "pullRequestNo": 1978 + }, + { + "name": "oug-t", + "id": 252025851, + "comment_id": 3811704206, + "created_at": "2026-01-28T14:42:29Z", + "repoId": 987670088, + "pullRequestNo": 2022 } ] } \ No newline at end of file From 5011ba264a8b2c854d0f72d1364bb2a78f89e01e Mon Sep 17 00:00:00 2001 From: Tommy Guo Date: Wed, 28 Jan 2026 09:58:35 -0500 Subject: [PATCH 19/75] docs: improve clarity and fluency of mandarin tagline (#2022) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cd68cb962de3518cce6f86ac3513d388bf9bfcd0..6e167345dd92ffb7a4d56241e9da7258a7c89b97 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Your new coding bestie, now available in your favourite terminal.
Your tools, your code, and your workflows, wired into your LLM of choice.

-

你的新编程伙伴,现在就在你最爱的终端中。
你的工具、代码和工作流,都与您选择的 LLM 模型紧密相连。

+

终端里的编程新搭档,
无缝接入你的工具、代码与工作流,全面兼容主流 LLM 模型。

Crush Demo

From de64b00392249ff77ab1a178234ab4e223f11fa6 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 28 Jan 2026 14:35:03 -0300 Subject: [PATCH 20/75] fix: decouple thinking/reasoning from provider type (#2032) Signed-off-by: Carlos Alexandro Becker --- internal/tui/components/chat/sidebar/sidebar.go | 7 ++----- internal/tui/components/dialogs/commands/commands.go | 4 +--- internal/ui/dialog/commands.go | 4 +--- internal/ui/model/sidebar.go | 6 ++---- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index a454605c8ee2938fa02d98d9770704388d0bd38a..40bc8821e0a3dc7c3dec62bbcde34a5241ec4aa7 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -8,7 +8,6 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/diff" @@ -548,7 +547,6 @@ func (s *sidebarCmp) currentModelBlock() string { selectedModel := cfg.Models[agentCfg.Model] model := config.Get().GetModelByType(agentCfg.Model) - modelProvider := config.Get().GetProviderForModel(agentCfg.Model) t := styles.CurrentTheme() @@ -560,15 +558,14 @@ func (s *sidebarCmp) currentModelBlock() string { } if model.CanReason { reasoningInfoStyle := t.S().Subtle.PaddingLeft(2) - switch modelProvider.Type { - case catwalk.TypeAnthropic: + if len(model.ReasoningLevels) == 0 { formatter := cases.Title(language.English, cases.NoLower) if selectedModel.Think { parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on"))) } else { parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking off"))) } - default: + } else { reasoningEffort := model.DefaultReasoningEffort if selectedModel.ReasoningEffort != "" { reasoningEffort = selectedModel.ReasoningEffort diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index cde5b203ca985f81c390d02725ef04d11a5cd518..3c86c984561f96350b2b621c15ae14be9649ae36 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -10,10 +10,8 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent" - "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" @@ -364,7 +362,7 @@ func (c *commandDialogCmp) defaultCommands() []Command { selectedModel := cfg.Models[agentCfg.Model] // Anthropic models: thinking toggle - if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) { + if model.CanReason && len(model.ReasoningLevels) == 0 { status := "Enable" if selectedModel.Think { status = "Disable" diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 6595b56fb702069b6a0f0786ee25cd4e94f13642..2422c39cc79b9ce1b71b5891ad55c2f4107c9295 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -9,8 +9,6 @@ import ( "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" - "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" @@ -405,7 +403,7 @@ func (c *Commands) defaultCommands() []*CommandItem { selectedModel := cfg.Models[agentCfg.Model] // Anthropic models: thinking toggle - if providerCfg.Type == catwalk.TypeAnthropic || providerCfg.Type == catwalk.Type(hyper.Name) { + if model.CanReason && len(model.ReasoningLevels) == 0 { status := "Enable" if selectedModel.Think { status = "Disable" diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 7e6a61864a42f37ba7bf1c955b6844f4c488b942..7316025aaedad67688b226cf1c7c37314f3b7a30 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -5,7 +5,6 @@ import ( "fmt" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/logo" uv "github.com/charmbracelet/ultraviolet" @@ -28,14 +27,13 @@ func (m *UI) modelInfo(width int) string { // Only check reasoning if model can reason if model.CatwalkCfg.CanReason { - switch providerConfig.Type { - case catwalk.TypeAnthropic: + if model.ModelCfg.ReasoningEffort == "" { if model.ModelCfg.Think { reasoningInfo = "Thinking On" } else { reasoningInfo = "Thinking Off" } - default: + } else { formatter := cases.Title(language.English, cases.NoLower) reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort) reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)) From daf786fe3df633bf146b5a3246866c173e9d8370 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 28 Jan 2026 14:35:22 -0300 Subject: [PATCH 21/75] fix(stats): resizing breaks pie charts (#2030) resizing the browser would "break" the pie charts, cutting them off Signed-off-by: Carlos Alexandro Becker --- internal/cmd/stats/index.css | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/internal/cmd/stats/index.css b/internal/cmd/stats/index.css index b01c84442f6cbe1675f46ec02a65d801d0abed2d..0216f9f79bd6bd16f77a5fd0ec14e9c142815436 100644 --- a/internal/cmd/stats/index.css +++ b/internal/cmd/stats/index.css @@ -189,20 +189,15 @@ body { } .chart-row { - display: grid; - grid-template-columns: repeat(2, 1fr); + display: flex; + flex-wrap: wrap; gap: 1.5rem; width: 100%; } .chart-row .chart-card { - width: 100%; -} - -@media (max-width: 1024px) { - .chart-row { - grid-template-columns: 1fr; - } + flex: 1 1 300px; + max-width: calc((100% - 1.5rem) / 2); } .chart-card h2 { From a81443ca4d7fd90bbb1efc83834cf45cd0d72c05 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 29 Jan 2026 04:05:20 -0300 Subject: [PATCH 22/75] chore(legal): @liannnix has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 7d0cf20f6a37d57d7da19c324d836d784a881811..e03ad52ee49a9b000bd8cb935f4da628158ed0ef 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1135,6 +1135,14 @@ "created_at": "2026-01-28T14:42:29Z", "repoId": 987670088, "pullRequestNo": 2022 + }, + { + "name": "liannnix", + "id": 779758, + "comment_id": 3815867093, + "created_at": "2026-01-29T07:05:12Z", + "repoId": 987670088, + "pullRequestNo": 2043 } ] } \ No newline at end of file From 3a929ffcff89aba677c2fb7620e93870f1c47f5b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 29 Jan 2026 10:19:00 -0300 Subject: [PATCH 23/75] feat: filetracker per session (#2033) * feat: filetracker per session Signed-off-by: Carlos Alexandro Becker * fix: only in the new ui Signed-off-by: Carlos Alexandro Becker * fix: tests, lint Signed-off-by: Carlos Alexandro Becker * fix: old tui Signed-off-by: Carlos Alexandro Becker * test: added test, improve schema Signed-off-by: Carlos Alexandro Becker * test: synctest Signed-off-by: Carlos Alexandro Becker * test: fix race Signed-off-by: Carlos Alexandro Becker * fix: relpath Signed-off-by: Carlos Alexandro Becker * fix: simplify Signed-off-by: Carlos Alexandro Becker * chore: trigger ci Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- internal/agent/agentic_fetch_tool.go | 2 +- internal/agent/common_test.go | 12 +- internal/agent/coordinator.go | 12 +- internal/agent/tools/edit.go | 53 ++++---- internal/agent/tools/multiedit.go | 35 +++--- internal/agent/tools/multiedit_test.go | 14 --- internal/agent/tools/view.go | 20 +-- internal/agent/tools/write.go | 25 ++-- internal/app/app.go | 4 + internal/db/db.go | 20 +++ .../20260127000000_add_read_files_table.sql | 20 +++ internal/db/models.go | 6 + internal/db/querier.go | 2 + internal/db/read_files.sql.go | 57 +++++++++ internal/db/sql/read_files.sql | 15 +++ internal/filetracker/filetracker.go | 70 ----------- internal/filetracker/service.go | 70 +++++++++++ internal/filetracker/service_test.go | 116 ++++++++++++++++++ internal/tui/components/chat/editor/editor.go | 27 +++- internal/tui/page/chat/chat.go | 3 + internal/ui/model/ui.go | 27 ++-- 21 files changed, 446 insertions(+), 164 deletions(-) create mode 100644 internal/db/migrations/20260127000000_add_read_files_table.sql create mode 100644 internal/db/read_files.sql.go create mode 100644 internal/db/sql/read_files.sql delete mode 100644 internal/filetracker/filetracker.go create mode 100644 internal/filetracker/service.go create mode 100644 internal/filetracker/service_test.go diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 89d3535720f8452111f12f4df4eb691e39253bed..08da0e870187f537c9c88ac6a2b6ada97ff6fc88 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -168,7 +168,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) ( tools.NewGlobTool(tmpDir), tools.NewGrepTool(tmpDir), tools.NewSourcegraphTool(client), - tools.NewViewTool(c.lspClients, c.permissions, tmpDir), + tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, tmpDir), } agent := NewSessionAgent(SessionAgentOptions{ diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 3f4e8daddbd4de34e788bce59a9573c00d940252..2bb5e5650bcb3280ddb95bdcea7d588a2eea7643 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -20,6 +20,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/message" @@ -37,6 +38,7 @@ type fakeEnv struct { messages message.Service permissions permission.Service history history.Service + filetracker *filetracker.Service lspClients *csync.Map[string, *lsp.Client] } @@ -117,6 +119,7 @@ func testEnv(t *testing.T) fakeEnv { permissions := permission.NewPermissionService(workingDir, true, []string{}) history := history.NewService(q, conn) + filetrackerService := filetracker.NewService(q) lspClients := csync.NewMap[string, *lsp.Client]() t.Cleanup(func() { @@ -130,6 +133,7 @@ func testEnv(t *testing.T) fakeEnv { messages, permissions, history, + &filetrackerService, lspClients, } } @@ -200,15 +204,15 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel allTools := []fantasy.AgentTool{ tools.NewBashTool(env.permissions, env.workingDir, cfg.Options.Attribution, modelName), tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()), - tools.NewEditTool(env.lspClients, env.permissions, env.history, env.workingDir), - tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, env.workingDir), + tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir), + tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir), tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()), tools.NewGlobTool(env.workingDir), tools.NewGrepTool(env.workingDir), tools.NewLsTool(env.permissions, env.workingDir, cfg.Tools.Ls), tools.NewSourcegraphTool(r.GetDefaultClient()), - tools.NewViewTool(env.lspClients, env.permissions, env.workingDir), - tools.NewWriteTool(env.lspClients, env.permissions, env.history, env.workingDir), + tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir), + tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir), } return testSessionAgent(env, large, small, systemPrompt, allTools...), nil diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 8c2a785b2f8ffeb77bbf52bb9653e8a98369303b..fd65072fd4eb297b8eddcb38aafe50d595601f82 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -22,6 +22,7 @@ import ( "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/log" "github.com/charmbracelet/crush/internal/lsp" @@ -64,6 +65,7 @@ type coordinator struct { messages message.Service permissions permission.Service history history.Service + filetracker filetracker.Service lspClients *csync.Map[string, *lsp.Client] currentAgent SessionAgent @@ -79,6 +81,7 @@ func NewCoordinator( messages message.Service, permissions permission.Service, history history.Service, + filetracker filetracker.Service, lspClients *csync.Map[string, *lsp.Client], ) (Coordinator, error) { c := &coordinator{ @@ -87,6 +90,7 @@ func NewCoordinator( messages: messages, permissions: permissions, history: history, + filetracker: filetracker, lspClients: lspClients, agents: make(map[string]SessionAgent), } @@ -393,16 +397,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan tools.NewJobOutputTool(), tools.NewJobKillTool(), tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil), - tools.NewEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()), - tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()), + tools.NewEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), + tools.NewMultiEditTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil), tools.NewGlobTool(c.cfg.WorkingDir()), tools.NewGrepTool(c.cfg.WorkingDir()), tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Tools.Ls), tools.NewSourcegraphTool(nil), tools.NewTodosTool(c.sessions), - tools.NewViewTool(c.lspClients, c.permissions, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...), - tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.cfg.WorkingDir()), + tools.NewViewTool(c.lspClients, c.permissions, c.filetracker, c.cfg.WorkingDir(), c.cfg.Options.SkillsPaths...), + tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), ) if len(c.cfg.LSP) > 0 { diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index 2c9b15abfe148fb881ee90f75f207c1134776281..74b84c784796a97db2f379cf61fb3eb8b18934d4 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -56,10 +56,17 @@ type editContext struct { ctx context.Context permissions permission.Service files history.Service + filetracker filetracker.Service workingDir string } -func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool { +func NewEditTool( + lspClients *csync.Map[string, *lsp.Client], + permissions permission.Service, + files history.Service, + filetracker filetracker.Service, + workingDir string, +) fantasy.AgentTool { return fantasy.NewAgentTool( EditToolName, string(editDescription), @@ -73,7 +80,7 @@ func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss var response fantasy.ToolResponse var err error - editCtx := editContext{ctx, permissions, files, workingDir} + editCtx := editContext{ctx, permissions, files, filetracker, workingDir} if params.OldString == "" { response, err = createNewFile(editCtx, params.FilePath, params.NewString, call) @@ -168,8 +175,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool slog.Error("Error creating file history version", "error", err) } - filetracker.RecordWrite(filePath) - filetracker.RecordRead(filePath) + edit.filetracker.RecordRead(edit.ctx, sessionID, filePath) return fantasy.WithResponseMetadata( fantasy.NewTextResponse("File created: "+filePath), @@ -195,12 +201,17 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } - if filetracker.LastReadTime(filePath).IsZero() { + sessionID := GetSessionFromContext(edit.ctx) + if sessionID == "" { + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content") + } + + lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath) + if lastRead.IsZero() { return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil } - modTime := fileInfo.ModTime() - lastRead := filetracker.LastReadTime(filePath) + modTime := fileInfo.ModTime().Truncate(time.Second) if modTime.After(lastRead) { return fantasy.NewTextErrorResponse( fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)", @@ -236,12 +247,6 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool newContent = oldContent[:index] + oldContent[index+len(oldString):] } - sessionID := GetSessionFromContext(edit.ctx) - - if sessionID == "" { - return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for deleting content") - } - _, additions, removals := diff.GenerateDiff( oldContent, newContent, @@ -301,8 +306,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool slog.Error("Error creating file history version", "error", err) } - filetracker.RecordWrite(filePath) - filetracker.RecordRead(filePath) + edit.filetracker.RecordRead(edit.ctx, sessionID, filePath) return fantasy.WithResponseMetadata( fantasy.NewTextResponse("Content deleted from file: "+filePath), @@ -328,12 +332,17 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil } - if filetracker.LastReadTime(filePath).IsZero() { + sessionID := GetSessionFromContext(edit.ctx) + if sessionID == "" { + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for edit a file") + } + + lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, filePath) + if lastRead.IsZero() { return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil } - modTime := fileInfo.ModTime() - lastRead := filetracker.LastReadTime(filePath) + modTime := fileInfo.ModTime().Truncate(time.Second) if modTime.After(lastRead) { return fantasy.NewTextErrorResponse( fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)", @@ -369,11 +378,6 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep if oldContent == newContent { return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil } - sessionID := GetSessionFromContext(edit.ctx) - - if sessionID == "" { - return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file") - } _, additions, removals := diff.GenerateDiff( oldContent, newContent, @@ -433,8 +437,7 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep slog.Error("Error creating file history version", "error", err) } - filetracker.RecordWrite(filePath) - filetracker.RecordRead(filePath) + edit.filetracker.RecordRead(edit.ctx, sessionID, filePath) return fantasy.WithResponseMetadata( fantasy.NewTextResponse("Content replaced in file: "+filePath), diff --git a/internal/agent/tools/multiedit.go b/internal/agent/tools/multiedit.go index 0640228d23230e6a49d8e1405f371c099031fbf7..48736ebf311230a28b51702e0ddd3ff8df19b284 100644 --- a/internal/agent/tools/multiedit.go +++ b/internal/agent/tools/multiedit.go @@ -58,7 +58,13 @@ const MultiEditToolName = "multiedit" //go:embed multiedit.md var multieditDescription []byte -func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool { +func NewMultiEditTool( + lspClients *csync.Map[string, *lsp.Client], + permissions permission.Service, + files history.Service, + filetracker filetracker.Service, + workingDir string, +) fantasy.AgentTool { return fantasy.NewAgentTool( MultiEditToolName, string(multieditDescription), @@ -81,7 +87,7 @@ func NewMultiEditTool(lspClients *csync.Map[string, *lsp.Client], permissions pe var response fantasy.ToolResponse var err error - editCtx := editContext{ctx, permissions, files, workingDir} + editCtx := editContext{ctx, permissions, files, filetracker, workingDir} // Handle file creation case (first edit has empty old_string) if len(params.Edits) > 0 && params.Edits[0].OldString == "" { response, err = processMultiEditWithCreation(editCtx, params, call) @@ -210,8 +216,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call slog.Error("Error creating file history version", "error", err) } - filetracker.RecordWrite(params.FilePath) - filetracker.RecordRead(params.FilePath) + edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath) var message string if len(failedEdits) > 0 { @@ -247,14 +252,19 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil } + sessionID := GetSessionFromContext(edit.ctx) + if sessionID == "" { + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file") + } + // Check if file was read before editing - if filetracker.LastReadTime(params.FilePath).IsZero() { + lastRead := edit.filetracker.LastReadTime(edit.ctx, sessionID, params.FilePath) + if lastRead.IsZero() { return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil } - // Check if file was modified since last read - modTime := fileInfo.ModTime() - lastRead := filetracker.LastReadTime(params.FilePath) + // Check if file was modified since last read. + modTime := fileInfo.ModTime().Truncate(time.Second) if modTime.After(lastRead) { return fantasy.NewTextErrorResponse( fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)", @@ -301,12 +311,6 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call return fantasy.NewTextErrorResponse("no changes made - all edits resulted in identical content"), nil } - // Get session and message IDs - sessionID := GetSessionFromContext(edit.ctx) - if sessionID == "" { - return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for editing file") - } - // Generate diff and check permissions _, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir)) @@ -369,8 +373,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call slog.Error("Error creating file history version", "error", err) } - filetracker.RecordWrite(params.FilePath) - filetracker.RecordRead(params.FilePath) + edit.filetracker.RecordRead(edit.ctx, sessionID, params.FilePath) var message string if len(failedEdits) > 0 { diff --git a/internal/agent/tools/multiedit_test.go b/internal/agent/tools/multiedit_test.go index b6d575435e63dcd62a4dc9a7efb76cf13c14ad05..1ca2a6f7689e345ac944889f1f92284de0652f90 100644 --- a/internal/agent/tools/multiedit_test.go +++ b/internal/agent/tools/multiedit_test.go @@ -6,10 +6,7 @@ import ( "path/filepath" "testing" - "github.com/charmbracelet/crush/internal/csync" - "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/history" - "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" "github.com/stretchr/testify/require" @@ -111,17 +108,6 @@ func TestMultiEditSequentialApplication(t *testing.T) { err := os.WriteFile(testFile, []byte(content), 0o644) require.NoError(t, err) - // Mock components. - lspClients := csync.NewMap[string, *lsp.Client]() - permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()} - files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()} - - // Create multiedit tool. - _ = NewMultiEditTool(lspClients, permissions, files, tmpDir) - - // Simulate reading the file first. - filetracker.RecordRead(testFile) - // Manually test the sequential application logic. currentContent := content diff --git a/internal/agent/tools/view.go b/internal/agent/tools/view.go index 35865cf43f7c587d60764b3ed177374940bbe2dc..b26267fcef3b296babc3c9dbcee64336ef162b75 100644 --- a/internal/agent/tools/view.go +++ b/internal/agent/tools/view.go @@ -47,7 +47,13 @@ const ( MaxLineLength = 2000 ) -func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool { +func NewViewTool( + lspClients *csync.Map[string, *lsp.Client], + permissions permission.Service, + filetracker filetracker.Service, + workingDir string, + skillsPaths ...string, +) fantasy.AgentTool { return fantasy.NewAgentTool( ViewToolName, string(viewDescription), @@ -74,13 +80,13 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..") isSkillFile := isInSkillsPath(absFilePath, skillsPaths) + sessionID := GetSessionFromContext(ctx) + if sessionID == "" { + return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory") + } + // Request permission for files outside working directory, unless it's a skill file. if isOutsideWorkDir && !isSkillFile { - sessionID := GetSessionFromContext(ctx) - if sessionID == "" { - return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory") - } - granted, err := permissions.Request(ctx, permission.CreatePermissionRequest{ SessionID: sessionID, @@ -190,7 +196,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss } output += "\n\n" output += getDiagnostics(filePath, lspClients) - filetracker.RecordRead(filePath) + filetracker.RecordRead(ctx, sessionID, filePath) return fantasy.WithResponseMetadata( fantasy.NewTextResponse(output), ViewResponseMetadata{ diff --git a/internal/agent/tools/write.go b/internal/agent/tools/write.go index 8becaea3c08157897dcece7b3d5d4de5cb2ee929..c2f5c7d1c83efd0731e8623c1e9cbb98b9bfdd2f 100644 --- a/internal/agent/tools/write.go +++ b/internal/agent/tools/write.go @@ -44,7 +44,13 @@ type WriteResponseMetadata struct { const WriteToolName = "write" -func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool { +func NewWriteTool( + lspClients *csync.Map[string, *lsp.Client], + permissions permission.Service, + files history.Service, + filetracker filetracker.Service, + workingDir string, +) fantasy.AgentTool { return fantasy.NewAgentTool( WriteToolName, string(writeDescription), @@ -57,6 +63,11 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis return fantasy.NewTextErrorResponse("content is required"), nil } + sessionID := GetSessionFromContext(ctx) + if sessionID == "" { + return fantasy.ToolResponse{}, fmt.Errorf("session_id is required") + } + filePath := filepathext.SmartJoin(workingDir, params.FilePath) fileInfo, err := os.Stat(filePath) @@ -65,8 +76,8 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil } - modTime := fileInfo.ModTime() - lastRead := filetracker.LastReadTime(filePath) + modTime := fileInfo.ModTime().Truncate(time.Second) + lastRead := filetracker.LastReadTime(ctx, sessionID, filePath) if modTime.After(lastRead) { return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.", filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil @@ -93,11 +104,6 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis } } - sessionID := GetSessionFromContext(ctx) - if sessionID == "" { - return fantasy.ToolResponse{}, fmt.Errorf("session_id is required") - } - diff, additions, removals := diff.GenerateDiff( oldContent, params.Content, @@ -153,8 +159,7 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis slog.Error("Error creating file history version", "error", err) } - filetracker.RecordWrite(filePath) - filetracker.RecordRead(filePath) + filetracker.RecordRead(ctx, sessionID, filePath) notifyLSPs(ctx, lspClients, params.FilePath) diff --git a/internal/app/app.go b/internal/app/app.go index ef6e636e44eeea9407557ca48f8ba9bd8eba72b2..647d90c9cfe29402b00ef5743f3a84f5e1b681ab 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -23,6 +23,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/format" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/log" @@ -53,6 +54,7 @@ type App struct { Messages message.Service History history.Service Permissions permission.Service + FileTracker filetracker.Service AgentCoordinator agent.Coordinator @@ -87,6 +89,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { Messages: messages, History: files, Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools), + FileTracker: filetracker.NewService(q), LSPClients: csync.NewMap[string, *lsp.Client](), globalCtx: ctx, @@ -460,6 +463,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error { app.Messages, app.Permissions, app.History, + app.FileTracker, app.LSPClients, ) if err != nil { diff --git a/internal/db/db.go b/internal/db/db.go index a4e430c720f33f4cd3c0b9710633595ef5c5fa1f..739c2087e1c1e125875d5006c86f85de37fed3be 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -57,6 +57,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getFileByPathAndSessionStmt, err = db.PrepareContext(ctx, getFileByPathAndSession); err != nil { return nil, fmt.Errorf("error preparing query GetFileByPathAndSession: %w", err) } + if q.getFileReadStmt, err = db.PrepareContext(ctx, getFileRead); err != nil { + return nil, fmt.Errorf("error preparing query GetFileRead: %w", err) + } if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil { return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err) } @@ -111,6 +114,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listUserMessagesBySessionStmt, err = db.PrepareContext(ctx, listUserMessagesBySession); err != nil { return nil, fmt.Errorf("error preparing query ListUserMessagesBySession: %w", err) } + if q.recordFileReadStmt, err = db.PrepareContext(ctx, recordFileRead); err != nil { + return nil, fmt.Errorf("error preparing query RecordFileRead: %w", err) + } if q.updateMessageStmt, err = db.PrepareContext(ctx, updateMessage); err != nil { return nil, fmt.Errorf("error preparing query UpdateMessage: %w", err) } @@ -180,6 +186,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getFileByPathAndSessionStmt: %w", cerr) } } + if q.getFileReadStmt != nil { + if cerr := q.getFileReadStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getFileReadStmt: %w", cerr) + } + } if q.getHourDayHeatmapStmt != nil { if cerr := q.getHourDayHeatmapStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr) @@ -270,6 +281,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listUserMessagesBySessionStmt: %w", cerr) } } + if q.recordFileReadStmt != nil { + if cerr := q.recordFileReadStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing recordFileReadStmt: %w", cerr) + } + } if q.updateMessageStmt != nil { if cerr := q.updateMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateMessageStmt: %w", cerr) @@ -335,6 +351,7 @@ type Queries struct { getAverageResponseTimeStmt *sql.Stmt getFileStmt *sql.Stmt getFileByPathAndSessionStmt *sql.Stmt + getFileReadStmt *sql.Stmt getHourDayHeatmapStmt *sql.Stmt getMessageStmt *sql.Stmt getRecentActivityStmt *sql.Stmt @@ -353,6 +370,7 @@ type Queries struct { listNewFilesStmt *sql.Stmt listSessionsStmt *sql.Stmt listUserMessagesBySessionStmt *sql.Stmt + recordFileReadStmt *sql.Stmt updateMessageStmt *sql.Stmt updateSessionStmt *sql.Stmt updateSessionTitleAndUsageStmt *sql.Stmt @@ -373,6 +391,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAverageResponseTimeStmt: q.getAverageResponseTimeStmt, getFileStmt: q.getFileStmt, getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt, + getFileReadStmt: q.getFileReadStmt, getHourDayHeatmapStmt: q.getHourDayHeatmapStmt, getMessageStmt: q.getMessageStmt, getRecentActivityStmt: q.getRecentActivityStmt, @@ -391,6 +410,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listNewFilesStmt: q.listNewFilesStmt, listSessionsStmt: q.listSessionsStmt, listUserMessagesBySessionStmt: q.listUserMessagesBySessionStmt, + recordFileReadStmt: q.recordFileReadStmt, updateMessageStmt: q.updateMessageStmt, updateSessionStmt: q.updateSessionStmt, updateSessionTitleAndUsageStmt: q.updateSessionTitleAndUsageStmt, diff --git a/internal/db/migrations/20260127000000_add_read_files_table.sql b/internal/db/migrations/20260127000000_add_read_files_table.sql new file mode 100644 index 0000000000000000000000000000000000000000..1161f1992885fc66e309024a0d874565ea276229 --- /dev/null +++ b/internal/db/migrations/20260127000000_add_read_files_table.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS read_files ( + session_id TEXT NOT NULL CHECK (session_id != ''), + path TEXT NOT NULL CHECK (path != ''), + read_at INTEGER NOT NULL, -- Unix timestamp in seconds when file was last read + FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE, + PRIMARY KEY (path, session_id) +); + +CREATE INDEX IF NOT EXISTS idx_read_files_session_id ON read_files (session_id); +CREATE INDEX IF NOT EXISTS idx_read_files_path ON read_files (path); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_read_files_path; +DROP INDEX IF EXISTS idx_read_files_session_id; +DROP TABLE IF EXISTS read_files; +-- +goose StatementEnd diff --git a/internal/db/models.go b/internal/db/models.go index 317e7c92e09c857ee610832e365af2c4ecc90181..a105074ab9e6320bd92b90121e7694b1f8cd1e5a 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -31,6 +31,12 @@ type Message struct { IsSummaryMessage int64 `json:"is_summary_message"` } +type ReadFile struct { + SessionID string `json:"session_id"` + Path string `json:"path"` + ReadAt int64 `json:"read_at"` // Unix timestamp when file was last read +} + type Session struct { ID string `json:"id"` ParentSessionID sql.NullString `json:"parent_session_id"` diff --git a/internal/db/querier.go b/internal/db/querier.go index 394ba1f71aea47c93956e91fcaf07e02f65098b8..c233fd59f63f8b46d3e6d62e1c162f47d6d34e3f 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -20,6 +20,7 @@ type Querier interface { GetAverageResponseTime(ctx context.Context) (int64, error) GetFile(ctx context.Context, id string) (File, error) GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) + GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error) GetMessage(ctx context.Context, id string) (Message, error) GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error) @@ -38,6 +39,7 @@ type Querier interface { ListNewFiles(ctx context.Context) ([]File, error) ListSessions(ctx context.Context) ([]Session, error) ListUserMessagesBySession(ctx context.Context, sessionID string) ([]Message, error) + RecordFileRead(ctx context.Context, arg RecordFileReadParams) error UpdateMessage(ctx context.Context, arg UpdateMessageParams) error UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) UpdateSessionTitleAndUsage(ctx context.Context, arg UpdateSessionTitleAndUsageParams) error diff --git a/internal/db/read_files.sql.go b/internal/db/read_files.sql.go new file mode 100644 index 0000000000000000000000000000000000000000..b18907c1f27a3c753b6b1a2cf1ca0563c3fd78d5 --- /dev/null +++ b/internal/db/read_files.sql.go @@ -0,0 +1,57 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: read_files.sql + +package db + +import ( + "context" +) + +const getFileRead = `-- name: GetFileRead :one +SELECT session_id, path, read_at FROM read_files +WHERE session_id = ? AND path = ? LIMIT 1 +` + +type GetFileReadParams struct { + SessionID string `json:"session_id"` + Path string `json:"path"` +} + +func (q *Queries) GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) { + row := q.queryRow(ctx, q.getFileReadStmt, getFileRead, arg.SessionID, arg.Path) + var i ReadFile + err := row.Scan( + &i.SessionID, + &i.Path, + &i.ReadAt, + ) + return i, err +} + +const recordFileRead = `-- name: RecordFileRead :exec +INSERT INTO read_files ( + session_id, + path, + read_at +) VALUES ( + ?, + ?, + strftime('%s', 'now') +) ON CONFLICT(path, session_id) DO UPDATE SET + read_at = excluded.read_at +` + +type RecordFileReadParams struct { + SessionID string `json:"session_id"` + Path string `json:"path"` +} + +func (q *Queries) RecordFileRead(ctx context.Context, arg RecordFileReadParams) error { + _, err := q.exec(ctx, q.recordFileReadStmt, recordFileRead, + arg.SessionID, + arg.Path, + ) + return err +} diff --git a/internal/db/sql/read_files.sql b/internal/db/sql/read_files.sql new file mode 100644 index 0000000000000000000000000000000000000000..f607312c2ba8660aa2c7030e415ce2ca7320cd6d --- /dev/null +++ b/internal/db/sql/read_files.sql @@ -0,0 +1,15 @@ +-- name: RecordFileRead :exec +INSERT INTO read_files ( + session_id, + path, + read_at +) VALUES ( + ?, + ?, + strftime('%s', 'now') +) ON CONFLICT(path, session_id) DO UPDATE SET + read_at = excluded.read_at; + +-- name: GetFileRead :one +SELECT * FROM read_files +WHERE session_id = ? AND path = ? LIMIT 1; diff --git a/internal/filetracker/filetracker.go b/internal/filetracker/filetracker.go deleted file mode 100644 index 534a19dacdc209f7ef2d9c5b107cb5f88a665ee5..0000000000000000000000000000000000000000 --- a/internal/filetracker/filetracker.go +++ /dev/null @@ -1,70 +0,0 @@ -// Package filetracker tracks file read/write times to prevent editing files -// that haven't been read, and to detect external modifications. -// -// TODO: Consider moving this to persistent storage (e.g., the database) to -// preserve file access history across sessions. -// We would need to make sure to handle the case where we reload a session and the underlying files did change. -package filetracker - -import ( - "sync" - "time" -) - -// record tracks when a file was read/written. -type record struct { - path string - readTime time.Time - writeTime time.Time -} - -var ( - records = make(map[string]record) - recordMutex sync.RWMutex -) - -// RecordRead records when a file was read. -func RecordRead(path string) { - recordMutex.Lock() - defer recordMutex.Unlock() - - rec, exists := records[path] - if !exists { - rec = record{path: path} - } - rec.readTime = time.Now() - records[path] = rec -} - -// LastReadTime returns when a file was last read. Returns zero time if never -// read. -func LastReadTime(path string) time.Time { - recordMutex.RLock() - defer recordMutex.RUnlock() - - rec, exists := records[path] - if !exists { - return time.Time{} - } - return rec.readTime -} - -// RecordWrite records when a file was written. -func RecordWrite(path string) { - recordMutex.Lock() - defer recordMutex.Unlock() - - rec, exists := records[path] - if !exists { - rec = record{path: path} - } - rec.writeTime = time.Now() - records[path] = rec -} - -// Reset clears all file tracking records. Useful for testing. -func Reset() { - recordMutex.Lock() - defer recordMutex.Unlock() - records = make(map[string]record) -} diff --git a/internal/filetracker/service.go b/internal/filetracker/service.go new file mode 100644 index 0000000000000000000000000000000000000000..8f080d124e49dfc32f43796194c09ac22beaa9f1 --- /dev/null +++ b/internal/filetracker/service.go @@ -0,0 +1,70 @@ +// Package filetracker provides functionality to track file reads in sessions. +package filetracker + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/charmbracelet/crush/internal/db" +) + +// Service defines the interface for tracking file reads in sessions. +type Service interface { + // RecordRead records when a file was read. + RecordRead(ctx context.Context, sessionID, path string) + + // LastReadTime returns when a file was last read. + // Returns zero time if never read. + LastReadTime(ctx context.Context, sessionID, path string) time.Time +} + +type service struct { + q *db.Queries +} + +// NewService creates a new file tracker service. +func NewService(q *db.Queries) Service { + return &service{q: q} +} + +// RecordRead records when a file was read. +func (s *service) RecordRead(ctx context.Context, sessionID, path string) { + if err := s.q.RecordFileRead(ctx, db.RecordFileReadParams{ + SessionID: sessionID, + Path: relpath(path), + }); err != nil { + slog.Error("Error recording file read", "error", err, "file", path) + } +} + +// LastReadTime returns when a file was last read. +// Returns zero time if never read. +func (s *service) LastReadTime(ctx context.Context, sessionID, path string) time.Time { + readFile, err := s.q.GetFileRead(ctx, db.GetFileReadParams{ + SessionID: sessionID, + Path: relpath(path), + }) + if err != nil { + return time.Time{} + } + + return time.Unix(readFile.ReadAt, 0) +} + +func relpath(path string) string { + path = filepath.Clean(path) + basepath, err := os.Getwd() + if err != nil { + slog.Warn("Error getting basepath", "error", err) + return path + } + relpath, err := filepath.Rel(basepath, path) + if err != nil { + slog.Warn("Error getting relpath", "error", err) + return path + } + return relpath +} diff --git a/internal/filetracker/service_test.go b/internal/filetracker/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c7fb15090dd31e9591c5c3b9c2a256c839aea3f6 --- /dev/null +++ b/internal/filetracker/service_test.go @@ -0,0 +1,116 @@ +package filetracker + +import ( + "context" + "testing" + "testing/synctest" + "time" + + "github.com/charmbracelet/crush/internal/db" + "github.com/stretchr/testify/require" +) + +type testEnv struct { + ctx context.Context + q *db.Queries + svc Service +} + +func setupTest(t *testing.T) *testEnv { + t.Helper() + + conn, err := db.Connect(t.Context(), t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { conn.Close() }) + + q := db.New(conn) + return &testEnv{ + ctx: t.Context(), + q: q, + svc: NewService(q), + } +} + +func (e *testEnv) createSession(t *testing.T, sessionID string) { + t.Helper() + _, err := e.q.CreateSession(e.ctx, db.CreateSessionParams{ + ID: sessionID, + Title: "Test Session", + }) + require.NoError(t, err) +} + +func TestService_RecordRead(t *testing.T) { + env := setupTest(t) + + sessionID := "test-session-1" + path := "/path/to/file.go" + env.createSession(t, sessionID) + + env.svc.RecordRead(env.ctx, sessionID, path) + + lastRead := env.svc.LastReadTime(env.ctx, sessionID, path) + require.False(t, lastRead.IsZero(), "expected non-zero time after recording read") + require.WithinDuration(t, time.Now(), lastRead, 2*time.Second) +} + +func TestService_LastReadTime_NotFound(t *testing.T) { + env := setupTest(t) + + lastRead := env.svc.LastReadTime(env.ctx, "nonexistent-session", "/nonexistent/path") + require.True(t, lastRead.IsZero(), "expected zero time for unread file") +} + +func TestService_RecordRead_UpdatesTimestamp(t *testing.T) { + env := setupTest(t) + + sessionID := "test-session-2" + path := "/path/to/file.go" + env.createSession(t, sessionID) + + env.svc.RecordRead(env.ctx, sessionID, path) + firstRead := env.svc.LastReadTime(env.ctx, sessionID, path) + require.False(t, firstRead.IsZero()) + + synctest.Test(t, func(t *testing.T) { + time.Sleep(100 * time.Millisecond) + synctest.Wait() + env.svc.RecordRead(env.ctx, sessionID, path) + secondRead := env.svc.LastReadTime(env.ctx, sessionID, path) + + require.False(t, secondRead.Before(firstRead), "second read time should not be before first") + }) +} + +func TestService_RecordRead_DifferentSessions(t *testing.T) { + env := setupTest(t) + + path := "/shared/file.go" + session1, session2 := "session-1", "session-2" + env.createSession(t, session1) + env.createSession(t, session2) + + env.svc.RecordRead(env.ctx, session1, path) + + lastRead1 := env.svc.LastReadTime(env.ctx, session1, path) + require.False(t, lastRead1.IsZero()) + + lastRead2 := env.svc.LastReadTime(env.ctx, session2, path) + require.True(t, lastRead2.IsZero(), "session 2 should not see session 1's read") +} + +func TestService_RecordRead_DifferentPaths(t *testing.T) { + env := setupTest(t) + + sessionID := "test-session-3" + path1, path2 := "/path/to/file1.go", "/path/to/file2.go" + env.createSession(t, sessionID) + + env.svc.RecordRead(env.ctx, sessionID, path1) + + lastRead1 := env.svc.LastReadTime(env.ctx, sessionID, path1) + require.False(t, lastRead1.IsZero()) + + lastRead2 := env.svc.LastReadTime(env.ctx, sessionID, path2) + require.True(t, lastRead2.IsZero(), "path2 should not be recorded") +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index ba832b415133305fccbefa37da6b749405feb2c6..575c23114a9115209db7a2a02e642fe5f2246541 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -1,6 +1,7 @@ package editor import ( + "context" "fmt" "math/rand" "net/http" @@ -17,7 +18,6 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/app" - "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/session" @@ -66,6 +66,7 @@ type editorCmp struct { x, y int app *app.App session session.Session + sessionFileReads []string textarea textarea.Model attachments []message.Attachment deleteMode bool @@ -181,6 +182,9 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { + case chat.SessionClearedMsg: + m.session = session.Session{} + m.sessionFileReads = nil case tea.WindowSizeMsg: return m, m.repositionCompletions case filepicker.FilePickedMsg: @@ -212,19 +216,27 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { m.completionsStartIndex = 0 } absPath, _ := filepath.Abs(item.Path) + + ctx := context.Background() + // Skip attachment if file was already read and hasn't been modified. - lastRead := filetracker.LastReadTime(absPath) - if !lastRead.IsZero() { - if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) { - return m, nil + if m.session.ID != "" { + lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath) + if !lastRead.IsZero() { + if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) { + return m, nil + } } + } else if slices.Contains(m.sessionFileReads, absPath) { + return m, nil } + + m.sessionFileReads = append(m.sessionFileReads, absPath) content, err := os.ReadFile(item.Path) if err != nil { // if it fails, let the LLM handle it later. return m, nil } - filetracker.RecordRead(absPath) m.attachments = append(m.attachments, message.Attachment{ FilePath: item.Path, FileName: filepath.Base(item.Path), @@ -662,6 +674,9 @@ func (c *editorCmp) Bindings() []key.Binding { // we need to move some functionality to the page level func (c *editorCmp) SetSession(session session.Session) tea.Cmd { c.session = session + for _, path := range c.sessionFileReads { + c.app.FileTracker.RecordRead(context.Background(), session.ID, path) + } return nil } diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 9a4b69f5507fbb62b7ee93df6326f94cf79d22ad..bb2eb755bf80995dd41d9ac564174de5b90262bb 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -327,6 +327,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) { u, cmd = p.chat.Update(msg) p.chat = u.(chat.MessageListCmp) cmds = append(cmds, cmd) + u, cmd = p.editor.Update(msg) + p.editor = u.(editor.Editor) + cmds = append(cmds, cmd) return p, tea.Batch(cmds...) case filepicker.FilePickedMsg, completions.CompletionsClosedMsg, diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 1f2d7f86ef1953bf97e98109cbbe5d791c94122f..e100b6605fceceded84da8cd6cfb16507ddf64a4 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -28,7 +28,6 @@ import ( "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/filetracker" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" "github.com/charmbracelet/crush/internal/home" @@ -118,6 +117,9 @@ type UI struct { session *session.Session sessionFiles []SessionFile + // keeps track of read files while we don't have a session id + sessionFileReads []string + lastUserMessageTime int64 // The width and height of the terminal in cells. @@ -2414,21 +2416,27 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd { return func() tea.Msg { absPath, _ := filepath.Abs(path) - // Skip attachment if file was already read and hasn't been modified. - lastRead := filetracker.LastReadTime(absPath) - if !lastRead.IsZero() { - if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) { - return nil + + if m.hasSession() { + // Skip attachment if file was already read and hasn't been modified. + lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath) + if !lastRead.IsZero() { + if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) { + return nil + } } + } else if slices.Contains(m.sessionFileReads, absPath) { + return nil } + m.sessionFileReads = append(m.sessionFileReads, absPath) + // Add file as attachment. content, err := os.ReadFile(path) if err != nil { // If it fails, let the LLM handle it later. return nil } - filetracker.RecordRead(absPath) return message.Attachment{ FilePath: path, @@ -2555,6 +2563,10 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea. m.setState(uiChat, m.focus) } + for _, path := range m.sessionFileReads { + m.com.App.FileTracker.RecordRead(context.Background(), m.session.ID, path) + } + // Capture session ID to avoid race with main goroutine updating m.session. sessionID := m.session.ID cmds = append(cmds, func() tea.Msg { @@ -2801,6 +2813,7 @@ func (m *UI) newSession() tea.Cmd { m.session = nil m.sessionFiles = nil + m.sessionFileReads = nil m.setState(uiLanding, uiFocusEditor) m.textarea.Focus() m.chat.Blur() From 87fad188fca6f37acea2bc6e6dcdab7ccf8e606d Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 29 Jan 2026 10:29:01 -0300 Subject: [PATCH 24/75] fix: make the commands dialog less taller (#2035) --- internal/ui/dialog/commands.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 2422c39cc79b9ce1b71b5891ad55c2f4107c9295..416f5a0131e2dc7cf36561f118daed248ceebd08 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -27,8 +27,9 @@ type CommandType uint func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] } const ( - sidebarCompactModeBreakpoint = 120 - defaultCommandsDialogMaxWidth = 70 + sidebarCompactModeBreakpoint = 120 + defaultCommandsDialogMaxHeight = 20 + defaultCommandsDialogMaxWidth = 70 ) const ( @@ -240,7 +241,7 @@ func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds boo func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := c.com.Styles width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx())) - height := max(0, min(defaultDialogHeight, area.Dy())) + height := max(0, min(defaultCommandsDialogMaxHeight, area.Dy())) if area.Dx() != c.windowWidth && c.selected == SystemCommands { c.windowWidth = area.Dx() // since some items in the list depend on width (e.g. toggle sidebar command), From aae4c3082281f9233484609f73169e60257da73c Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 29 Jan 2026 10:29:46 -0300 Subject: [PATCH 25/75] fix(ui): fix selection of code blocks with tabs inside markdown (#2039) Yes, this is very specific. You need a code block, inside markdown, that uses tabs instead of spaces for indentation, like Go code. This affected both how the code is present on the TUI as well as the text copied to clipboard. We need to convert tabs into 4 spaces on the highlighter to match how it's shown in the TUI. Centralized this into a function to ensure we're doing the exact same thing everywhere. --- internal/stringext/string.go | 12 ++++++++++++ internal/ui/chat/tools.go | 12 ++++-------- internal/ui/list/highlight.go | 3 +++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/internal/stringext/string.go b/internal/stringext/string.go index 03456db93bc148f7c77e52da3c493c94fa79624f..8be28ccc2096c3d54b9f3106ed30d584503acdf4 100644 --- a/internal/stringext/string.go +++ b/internal/stringext/string.go @@ -1,6 +1,8 @@ package stringext import ( + "strings" + "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -8,3 +10,13 @@ import ( func Capitalize(text string) string { return cases.Title(language.English, cases.Compact).String(text) } + +// NormalizeSpace normalizes whitespace in the given content string. +// It replaces Windows-style line endings with Unix-style line endings, +// converts tabs to four spaces, and trims leading and trailing whitespace. +func NormalizeSpace(content string) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\t", " ") + content = strings.TrimSpace(content) + return content +} diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 8aac1c1401fe299b24bd2cda81e18113bfd6176d..3ae403160b241eca6f5d74fb9841c2b10a7735b9 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/stringext" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" @@ -531,9 +532,7 @@ func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, n // toolOutputPlainContent renders plain text with optional expansion support. func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string { - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\t", " ") - content = strings.TrimSpace(content) + content = stringext.NormalizeSpace(content) lines := strings.Split(content, "\n") maxLines := responseContextHeight @@ -566,8 +565,7 @@ func toolOutputPlainContent(sty *styles.Styles, content string, width int, expan // toolOutputCodeContent renders code with syntax highlighting and line numbers. func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, width int, expanded bool) string { - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\t", " ") + content = stringext.NormalizeSpace(content) lines := strings.Split(content, "\n") maxLines := responseContextHeight @@ -776,9 +774,7 @@ func roundedEnumerator(lPadding, width int) tree.Enumerator { // toolOutputMarkdownContent renders markdown content with optional truncation. func toolOutputMarkdownContent(sty *styles.Styles, content string, width int, expanded bool) string { - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\t", " ") - content = strings.TrimSpace(content) + content = stringext.NormalizeSpace(content) // Cap width for readability. if width > maxTextWidth { diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go index fefe836d110b52496028d21071fffc5262189d92..631181db29ce5bc3a2087de30341342f0374b229 100644 --- a/internal/ui/list/highlight.go +++ b/internal/ui/list/highlight.go @@ -5,6 +5,7 @@ import ( "strings" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/stringext" uv "github.com/charmbracelet/ultraviolet" ) @@ -53,6 +54,8 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin // HighlightBuffer highlights a region of text within the given content and // region, returning a [uv.ScreenBuffer]. func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer { + content = stringext.NormalizeSpace(content) + if startLine < 0 || startCol < 0 { return nil } From e57687f170b9744abfcc7dc83ccca0e0d4272116 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 29 Jan 2026 10:30:07 -0300 Subject: [PATCH 26/75] fix(ui): fix wrong color on selected item info on dialogs (#2041) --- internal/ui/dialog/commands_item.go | 2 +- internal/ui/dialog/models_item.go | 2 +- internal/ui/dialog/reasoning.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index b1977545ded8e8eeb8fc1e59c5a0a31e18ce8610..1099a8b435f0ed31d9f4c81dfbb4cb2b33a3d910 100644 --- a/internal/ui/dialog/commands_item.go +++ b/internal/ui/dialog/commands_item.go @@ -70,7 +70,7 @@ func (c *CommandItem) Render(width int) string { ItemBlurred: c.t.Dialog.NormalItem, ItemFocused: c.t.Dialog.SelectedItem, InfoTextBlurred: c.t.Base, - InfoTextFocused: c.t.Subtle, + InfoTextFocused: c.t.Base, } return renderItem(styles, c.title, c.shortcut, c.focused, width, c.cache, &c.m) } diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go index bfe30c0e3a04c24c71579bfbdbd06b576e1ad033..e61359d065a895ec508083198e5530977091366b 100644 --- a/internal/ui/dialog/models_item.go +++ b/internal/ui/dialog/models_item.go @@ -110,7 +110,7 @@ func (m *ModelItem) Render(width int) string { ItemBlurred: m.t.Dialog.NormalItem, ItemFocused: m.t.Dialog.SelectedItem, InfoTextBlurred: m.t.Base, - InfoTextFocused: m.t.Subtle, + InfoTextFocused: m.t.Base, } return renderItem(styles, m.model.Name, providerInfo, m.focused, width, m.cache, &m.m) } diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go index 4c5dad086bb01eb3dc12f2f6d379c87a5638d297..f11c59e48702ea3bc419afa62e9ee7fce8c52632 100644 --- a/internal/ui/dialog/reasoning.go +++ b/internal/ui/dialog/reasoning.go @@ -297,7 +297,7 @@ func (r *ReasoningItem) Render(width int) string { ItemBlurred: r.t.Dialog.NormalItem, ItemFocused: r.t.Dialog.SelectedItem, InfoTextBlurred: r.t.Base, - InfoTextFocused: r.t.Subtle, + InfoTextFocused: r.t.Base, } return renderItem(styles, r.title, info, r.focused, width, r.cache, &r.m) } From aa2cacd24af953a858dfb17f84ef37ff1db74ff3 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 29 Jan 2026 10:31:05 -0300 Subject: [PATCH 27/75] feat: open commands dialog on pressing `/` (#2034) --- internal/ui/model/keys.go | 5 +++++ internal/ui/model/ui.go | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index cf2fdcaa431b2a9c43a9612ef99ec8ce696216ca..a42b1e7aa0ac9ac474de626b55ceb3a91824cdff 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -10,6 +10,7 @@ type KeyMap struct { Newline key.Binding AddImage key.Binding MentionFile key.Binding + Commands key.Binding // Attachments key maps AttachmentDeleteMode key.Binding @@ -123,6 +124,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("@"), key.WithHelp("@", "mention file"), ) + km.Editor.Commands = key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "commands"), + ) km.Editor.AttachmentDeleteMode = key.NewBinding( key.WithKeys("ctrl+r"), key.WithHelp("ctrl+r+{i}", "delete attachment at index i"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e100b6605fceceded84da8cd6cfb16507ddf64a4..9eb7f01f881e70ad82597820dac8e3161f4cd684 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1542,6 +1542,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { if cmd != nil { cmds = append(cmds, cmd) } + case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "": + if cmd := m.openCommandsDialog(); cmd != nil { + cmds = append(cmds, cmd) + } default: if handleGlobalKeys(msg) { // Handle global keys first before passing to textarea. @@ -1865,7 +1869,7 @@ func (m *UI) ShortHelp() []key.Binding { k := &m.keyMap tab := k.Tab commands := k.Commands - if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 { + if m.focus == uiFocusEditor && m.textarea.Value() == "" { commands.SetHelp("/ or ctrl+p", "commands") } @@ -1941,7 +1945,7 @@ func (m *UI) FullHelp() [][]key.Binding { hasAttachments := len(m.attachments.List()) > 0 hasSession := m.hasSession() commands := k.Commands - if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 { + if m.focus == uiFocusEditor && m.textarea.Value() == "" { commands.SetHelp("/ or ctrl+p", "commands") } From 7643d6ac14d0471ce4d114d8df0d3a5e72064bf2 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 29 Jan 2026 10:48:11 -0300 Subject: [PATCH 28/75] ci: use goreleaser nightly on snapshot build Signed-off-by: Carlos Alexandro Becker --- .github/workflows/snapshot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 0c3d5ce6d437a39471003018545d8546fa220ef6..a5a45d8fdeeaf8f0c1374366e7c1d34839c1acc5 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -27,7 +27,7 @@ jobs: go-version-file: go.mod - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 with: - version: "~> v2" + version: "nightly" distribution: goreleaser-pro args: build --snapshot --clean env: From c0a8c7e8219b39d47ab6400438b61ff2dd357164 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 29 Jan 2026 15:33:52 -0300 Subject: [PATCH 29/75] feat: allow to disable indeterminate progress bar (#2048) Signed-off-by: Carlos Alexandro Becker --- internal/app/app.go | 7 +++++-- internal/config/config.go | 3 ++- internal/ui/model/ui.go | 8 ++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 647d90c9cfe29402b00ef5743f3a84f5e1b681ab..f914600457061056648cb23baa7901ca8d946f24 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -152,6 +152,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, stdoutTTY bool stderrTTY bool stdinTTY bool + progress bool ) if f, ok := output.(*os.File); ok { @@ -160,6 +161,8 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, stderrTTY = term.IsTerminal(os.Stderr.Fd()) stdinTTY = term.IsTerminal(os.Stdin.Fd()) + progress = app.config.Options.Progress == nil || *app.config.Options.Progress + if !quiet && stderrTTY { t := styles.CurrentTheme() @@ -244,7 +247,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, messageReadBytes := make(map[string]int) defer func() { - if stderrTTY { + if progress && stderrTTY { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) } @@ -254,7 +257,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, }() for { - if stderrTTY { + if progress && stderrTTY { // HACK: Reinitialize the terminal progress bar on every iteration // so it doesn't get hidden by the terminal due to inactivity. _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar) diff --git a/internal/config/config.go b/internal/config/config.go index d18d2d9c61d2f791ab9c6f9a0b7cd41029b70e60..b9bc5259d36390d53aa21befd524ea7043261905 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -257,7 +257,8 @@ type Options struct { Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"` DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"` InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"` - AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers"` + AutoLSP *bool `json:"auto_lsp,omitempty" jsonschema:"description=Automatically setup LSPs based on root markers,default=true"` + Progress *bool `json:"progress,omitempty" jsonschema:"description=Show indeterminate progress updates during long operations,default=true"` } type MCPs map[string]MCPConfig diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 9eb7f01f881e70ad82597820dac8e3161f4cd684..eca2cf80a343214babbcfbdb37916a61d07a1ced 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -144,7 +144,8 @@ type UI struct { // sendProgressBar instructs the TUI to send progress bar updates to the // terminal. - sendProgressBar bool + sendProgressBar bool + progressBarEnabled bool // caps hold different terminal capabilities that we query for. caps common.Capabilities @@ -295,6 +296,9 @@ func New(com *common.Common) *UI { // set initial state ui.setState(desiredState, desiredFocus) + // disable indeterminate progress bar + ui.progressBarEnabled = com.Config().Options.Progress == nil || *com.Config().Options.Progress + return ui } @@ -1854,7 +1858,7 @@ func (m *UI) View() tea.View { content = strings.Join(contentLines, "\n") v.Content = content - if m.sendProgressBar && m.isAgentBusy() { + if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() { // HACK: use a random percentage to prevent ghostty from hiding it // after a timeout. v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100)) From 40869ecb5974d716d0f61316aae25f80eda43ab2 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:34:28 +0000 Subject: [PATCH 30/75] chore: auto-update files --- schema.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/schema.json b/schema.json index 47b19589f29cdf6165f0b5c93a97168e3396e6bd..7a32f612e64a20d0393f74471c1fbdb8863c2365 100644 --- a/schema.json +++ b/schema.json @@ -432,7 +432,13 @@ }, "auto_lsp": { "type": "boolean", - "description": "Automatically setup LSPs based on root markers" + "description": "Automatically setup LSPs based on root markers", + "default": true + }, + "progress": { + "type": "boolean", + "description": "Show indeterminate progress updates during long operations", + "default": true } }, "additionalProperties": false, From c3ae2306d5d8163d428685a13e71f08212f7e9e6 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 29 Jan 2026 19:12:27 -0500 Subject: [PATCH 31/75] fix: respect disabled indeterminate progress bar setting on app start (#2054) --- internal/cmd/root.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 577d4ccb4abaa79275a5a556c463cb52b16aab11..b33303d1bbabb408988d50378ea2370896fb929b 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -179,12 +179,19 @@ func supportsProgressBar() bool { } func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) { - if supportsProgressBar() { + app, err := setupApp(cmd) + if err != nil { + return nil, err + } + + // Check if progress bar is enabled in config (defaults to true if nil) + progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress + if progressEnabled && supportsProgressBar() { _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar) defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }() } - return setupApp(cmd) + return app, nil } // setupApp handles the common setup logic for both interactive and non-interactive modes. From 3a2a045c3edb8e53b36cb71951f91213e4c3fb5c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 29 Jan 2026 21:12:43 -0300 Subject: [PATCH 32/75] fix: improve logs, standardize capitalized (#2047) * fix: improve logs, standarize capitalized Signed-off-by: Carlos Alexandro Becker * Update Taskfile.yaml Co-authored-by: Andrey Nering * chore: lint Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: Andrey Nering --- AGENTS.md | 2 ++ Taskfile.yaml | 6 ++++++ internal/agent/agent.go | 28 ++++++++++++++-------------- internal/agent/coordinator.go | 12 ++++++------ internal/agent/hyper/provider.go | 2 +- internal/agent/tools/mcp/init.go | 12 ++++++------ internal/agent/tools/mcp/prompts.go | 2 +- internal/agent/tools/mcp/tools.go | 2 +- internal/app/app.go | 26 +++++++++++++++----------- internal/app/lsp.go | 2 +- internal/cmd/run.go | 12 +++++++++++- internal/config/config.go | 4 ++-- internal/fsext/ls.go | 12 ++++++------ internal/home/home.go | 2 +- internal/lsp/client.go | 8 ++++---- internal/session/session.go | 2 +- internal/ui/image/image.go | 2 +- internal/ui/model/history.go | 2 +- internal/ui/model/ui.go | 6 +++--- scripts/check_log_capitalization.sh | 5 +++++ 20 files changed, 88 insertions(+), 61 deletions(-) create mode 100644 scripts/check_log_capitalization.sh diff --git a/AGENTS.md b/AGENTS.md index 7fab72afb836136020500b7f27e905f3dcfc72da..654f1cd0a7fe1cbb50a3026f86f31b68e04f8043 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,8 @@ need of a temporary directory. This directory does not need to be removed. - **JSON tags**: Use snake_case for JSON field names - **File permissions**: Use octal notation (0o755, 0o644) for file permissions +- **Log messages**: Log messages must start with a capital letter (e.g., "Failed to save session" not "failed to save session") + - This is enforced by `task lint:log` which runs as part of `task lint` - **Comments**: End comments in periods unless comments are at the end of the line. ## Testing with Mock Providers diff --git a/Taskfile.yaml b/Taskfile.yaml index 9ffe8923d6bbd92caf441d872726de48352b2faa..bf22d6593bfd099972a7a11a806cfee939511df2 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -23,10 +23,16 @@ tasks: lint: desc: Run base linters cmds: + - task: lint:log - golangci-lint run --path-mode=abs --config=".golangci.yml" --timeout=5m env: GOEXPERIMENT: null + lint:log: + desc: Check that log messages start with capital letters + cmds: + - ./scripts/check_log_capitalization.sh + lint:fix: desc: Run base linters and fix issues cmds: diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 74a1a9f0c94483268b4b3558c7d4ca7a9899c7ef..d46f42334f9bb5de656cd2f6e442cb4c414a6968 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -802,22 +802,22 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user resp, err := agent.Stream(ctx, streamCall) if err == nil { // We successfully generated a title with the small model. - slog.Info("generated title with small model") + slog.Debug("Generated title with small model") } else { // It didn't work. Let's try with the big model. - slog.Error("error generating title with small model; trying big model", "err", err) + slog.Error("Error generating title with small model; trying big model", "err", err) model = largeModel agent = newAgent(model.Model, titlePrompt, maxOutputTokens) resp, err = agent.Stream(ctx, streamCall) if err == nil { - slog.Info("generated title with large model") + slog.Debug("Generated title with large model") } else { // Welp, the large model didn't work either. Use the default // session name and return. - slog.Error("error generating title with large model", "err", err) + slog.Error("Error generating title with large model", "err", err) saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0) if saveErr != nil { - slog.Error("failed to save session title and usage", "error", saveErr) + slog.Error("Failed to save session title and usage", "error", saveErr) } return } @@ -826,10 +826,10 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user if resp == nil { // Actually, we didn't get a response so we can't. Use the default // session name and return. - slog.Error("response is nil; can't generate title") + slog.Error("Response is nil; can't generate title") saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, defaultSessionName, 0, 0, 0) if saveErr != nil { - slog.Error("failed to save session title and usage", "error", saveErr) + slog.Error("Failed to save session title and usage", "error", saveErr) } return } @@ -843,7 +843,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user title = strings.TrimSpace(title) if title == "" { - slog.Warn("empty title; using fallback") + slog.Debug("Empty title; using fallback") title = defaultSessionName } @@ -878,7 +878,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user // concurrent session updates. saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost) if saveErr != nil { - slog.Error("failed to save session title and usage", "error", saveErr) + slog.Error("Failed to save session title and usage", "error", saveErr) return } } @@ -921,25 +921,25 @@ func (a *sessionAgent) Cancel(sessionID string) { // fully completes (including error handling that may access the DB). // The defer in processRequest will clean up the entry. if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil { - slog.Info("Request cancellation initiated", "session_id", sessionID) + slog.Debug("Request cancellation initiated", "session_id", sessionID) cancel() } // Also check for summarize requests. if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil { - slog.Info("Summarize cancellation initiated", "session_id", sessionID) + slog.Debug("Summarize cancellation initiated", "session_id", sessionID) cancel() } if a.QueuedPrompts(sessionID) > 0 { - slog.Info("Clearing queued prompts", "session_id", sessionID) + slog.Debug("Clearing queued prompts", "session_id", sessionID) a.messageQueue.Del(sessionID) } } func (a *sessionAgent) ClearQueue(sessionID string) { if a.QueuedPrompts(sessionID) > 0 { - slog.Info("Clearing queued prompts", "session_id", sessionID) + slog.Debug("Clearing queued prompts", "session_id", sessionID) a.messageQueue.Del(sessionID) } } @@ -1099,7 +1099,7 @@ func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Mes if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok { decoded, err := base64.StdEncoding.DecodeString(media.Data) if err != nil { - slog.Warn("failed to decode media data", "error", err) + slog.Warn("Failed to decode media data", "error", err) textParts = append(textParts, part) continue } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index fd65072fd4eb297b8eddcb38aafe50d595601f82..40b7e029e465cc40f285ced7b5b77dd61109a2a0 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -151,7 +151,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg) if providerCfg.OAuthToken != nil && providerCfg.OAuthToken.IsExpired() { - slog.Info("Token needs to be refreshed", "provider", providerCfg.ID) + slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID) if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil { return nil, err } @@ -176,18 +176,18 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, if c.isUnauthorized(originalErr) { switch { case providerCfg.OAuthToken != nil: - slog.Info("Received 401. Refreshing token and retrying", "provider", providerCfg.ID) + slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID) if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil { return nil, originalErr } - slog.Info("Retrying request with refreshed OAuth token", "provider", providerCfg.ID) + slog.Debug("Retrying request with refreshed OAuth token", "provider", providerCfg.ID) return run() case strings.Contains(providerCfg.APIKeyTemplate, "$"): - slog.Info("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID) + slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID) if err := c.refreshApiKeyTemplate(ctx, providerCfg); err != nil { return nil, originalErr } - slog.Info("Retrying request with refreshed API key", "provider", providerCfg.ID) + slog.Debug("Retrying request with refreshed API key", "provider", providerCfg.ID) return run() } } @@ -428,7 +428,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan } if len(agent.AllowedMCP) == 0 { // No MCPs allowed - slog.Debug("no MCPs allowed", "tool", tool.Name(), "agent", agent.Name) + slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name) break } diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go index 03278ae99f87608c65263b0ffef7fb473cd58e31..6194593a719b388d1676f51568e72f45628fdae4 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -49,7 +49,7 @@ var Enabled = sync.OnceValue(func() bool { var Embedded = sync.OnceValue(func() catwalk.Provider { var provider catwalk.Provider if err := json.Unmarshal(embedded, &provider); err != nil { - slog.Error("could not use embedded provider data", "err", err) + slog.Error("Could not use embedded provider data", "err", err) } return provider }) diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index e1e7d609efc86d0dcb510fa5963552f7d487a134..05ac2eaeba29c2ce4411c8acc355d645037a6f55 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -140,7 +140,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config for name, m := range cfg.MCP { if m.Disabled { updateState(name, StateDisabled, nil, nil, Counts{}) - slog.Debug("skipping disabled mcp", "name", name) + slog.Debug("Skipping disabled MCP", "name", name) continue } @@ -162,7 +162,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config err = fmt.Errorf("panic: %v", v) } updateState(name, StateError, err, nil, Counts{}) - slog.Error("panic in mcp client initialization", "error", err, "name", name) + slog.Error("Panic in MCP client initialization", "error", err, "name", name) } }() @@ -174,7 +174,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config tools, err := getTools(ctx, session) if err != nil { - slog.Error("error listing tools", "error", err) + slog.Error("Error listing tools", "error", err) updateState(name, StateError, err, nil, Counts{}) session.Close() return @@ -182,7 +182,7 @@ func Initialize(ctx context.Context, permissions permission.Service, cfg *config prompts, err := getPrompts(ctx, session) if err != nil { - slog.Error("error listing prompts", "error", err) + slog.Error("Error listing prompts", "error", err) updateState(name, StateError, err, nil, Counts{}) session.Close() return @@ -277,7 +277,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve transport, err := createTransport(mcpCtx, m, resolver) if err != nil { updateState(name, StateError, err, nil, Counts{}) - slog.Error("error creating mcp client", "error", err, "name", name) + slog.Error("Error creating MCP client", "error", err, "name", name) cancel() cancelTimer.Stop() return nil, err @@ -319,7 +319,7 @@ func createSession(ctx context.Context, name string, m config.MCPConfig, resolve } cancelTimer.Stop() - slog.Info("MCP client initialized", "name", name) + slog.Debug("MCP client initialized", "name", name) return session, nil } diff --git a/internal/agent/tools/mcp/prompts.go b/internal/agent/tools/mcp/prompts.go index 0bd6e665dd80dad90c844d7d31c61c506ea83803..ea208a57716d2a273fde1b6faa3988ca2e57b012 100644 --- a/internal/agent/tools/mcp/prompts.go +++ b/internal/agent/tools/mcp/prompts.go @@ -49,7 +49,7 @@ func GetPromptMessages(ctx context.Context, clientName, promptName string, args func RefreshPrompts(ctx context.Context, name string) { session, ok := sessions.Get(name) if !ok { - slog.Warn("refresh prompts: no session", "name", name) + slog.Warn("Refresh prompts: no session", "name", name) return } diff --git a/internal/agent/tools/mcp/tools.go b/internal/agent/tools/mcp/tools.go index 779baa55d93bc54523bac81c5094bacee7fc68fb..65ef5a9d8b3e7304a49bd708ecdd53a3cc400b17 100644 --- a/internal/agent/tools/mcp/tools.go +++ b/internal/agent/tools/mcp/tools.go @@ -111,7 +111,7 @@ func RunTool(ctx context.Context, name, toolName string, input string) (ToolResu func RefreshTools(ctx context.Context, name string) { session, ok := sessions.Get(name) if !ok { - slog.Warn("refresh tools: no session", "name", name) + slog.Warn("Refresh tools: no session", "name", name) return } diff --git a/internal/app/app.go b/internal/app/app.go index f914600457061056648cb23baa7901ca8d946f24..88af5345eb55cc7f3e6c3c5923806967cc0a1632 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -135,7 +135,7 @@ func (app *App) Config() *config.Config { // RunNonInteractive runs the application in non-interactive mode with the // given prompt, printing to stdout. -func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error { +func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error { slog.Info("Running in non-interactive mode") ctx, cancel := context.WithCancel(ctx) @@ -160,10 +160,9 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, } stderrTTY = term.IsTerminal(os.Stderr.Fd()) stdinTTY = term.IsTerminal(os.Stdin.Fd()) - progress = app.config.Options.Progress == nil || *app.config.Options.Progress - if !quiet && stderrTTY { + if !hideSpinner && stderrTTY { t := styles.CurrentTheme() // Detect background color to set the appropriate color for the @@ -188,7 +187,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, // Helper function to stop spinner once. stopSpinner := func() { - if !quiet && spinner != nil { + if !hideSpinner && spinner != nil { spinner.Stop() spinner = nil } @@ -245,6 +244,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, messageEvents := app.Messages.Subscribe(ctx) messageReadBytes := make(map[string]int) + var printed bool defer func() { if progress && stderrTTY { @@ -268,7 +268,7 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, stopSpinner() if result.err != nil { if errors.Is(result.err, context.Canceled) || errors.Is(result.err, agent.ErrRequestCancelled) { - slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID) + slog.Debug("Non-interactive: agent processing cancelled", "session_id", sess.ID) return nil } return fmt.Errorf("agent processing failed: %w", result.err) @@ -294,7 +294,11 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, if readBytes == 0 { part = strings.TrimLeft(part, " \t") } - fmt.Fprint(output, part) + // Ignore initial whitespace-only messages. + if printed || strings.TrimSpace(part) != "" { + printed = true + fmt.Fprint(output, part) + } messageReadBytes[msg.ID] = len(content) } @@ -433,20 +437,20 @@ func setupSubscriber[T any]( select { case event, ok := <-subCh: if !ok { - slog.Debug("subscription channel closed", "name", name) + slog.Debug("Subscription channel closed", "name", name) return } var msg tea.Msg = event select { case outputCh <- msg: case <-time.After(2 * time.Second): - slog.Warn("message dropped due to slow consumer", "name", name) + slog.Debug("Message dropped due to slow consumer", "name", name) case <-ctx.Done(): - slog.Debug("subscription cancelled", "name", name) + slog.Debug("Subscription cancelled", "name", name) return } case <-ctx.Done(): - slog.Debug("subscription cancelled", "name", name) + slog.Debug("Subscription cancelled", "name", name) return } } @@ -511,7 +515,7 @@ func (app *App) Subscribe(program *tea.Program) { // Shutdown performs a graceful shutdown of the application. func (app *App) Shutdown() { start := time.Now() - defer func() { slog.Info("Shutdown took " + time.Since(start).String()) }() + defer func() { slog.Debug("Shutdown took " + time.Since(start).String()) }() // First, cancel all agents and wait for them to finish. This must complete // before closing the DB so agents can finish writing their state. diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 39e03d3cb4f2f5a9dc7720f8ce1f7286d4efd6b2..14f1c99587bf4bfe052f9ac2078cdf03d859cfa1 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -140,7 +140,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config updateLSPState(name, lsp.StateReady, nil, lspClient, 0) } - slog.Info("LSP client initialized", "name", name) + slog.Debug("LSP client initialized", "name", name) // Add to map with mutex protection before starting goroutine app.LSPClients.Set(name, lspClient) diff --git a/internal/cmd/run.go b/internal/cmd/run.go index e4d72b41be13684e28ca6c2b85b79bfdcea52fc7..50005a548bad0308bdca3a2afbe17503c1f86c56 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -8,6 +8,7 @@ import ( "os/signal" "strings" + "charm.land/log/v2" "github.com/charmbracelet/crush/internal/event" "github.com/spf13/cobra" ) @@ -29,9 +30,13 @@ crush run "What is this code doing?" <<< prrr.go # Run in quiet mode (hide the spinner) crush run --quiet "Generate a README for this project" + +# Run in verbose mode +crush run --verbose "Generate a README for this project" `, RunE: func(cmd *cobra.Command, args []string) error { quiet, _ := cmd.Flags().GetBool("quiet") + verbose, _ := cmd.Flags().GetBool("verbose") largeModel, _ := cmd.Flags().GetString("model") smallModel, _ := cmd.Flags().GetString("small-model") @@ -49,6 +54,10 @@ crush run --quiet "Generate a README for this project" return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively") } + if verbose { + slog.SetDefault(slog.New(log.New(os.Stderr))) + } + prompt := strings.Join(args, " ") prompt, err = MaybePrependStdin(prompt) @@ -64,7 +73,7 @@ crush run --quiet "Generate a README for this project" event.SetNonInteractive(true) event.AppInitialized() - return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet) + return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose) }, PostRun: func(cmd *cobra.Command, args []string) { event.AppExited() @@ -73,6 +82,7 @@ crush run --quiet "Generate a README for this project" func init() { runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner") + runCmd.Flags().BoolP("verbose", "v", false, "Show logs") runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers") runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider") } diff --git a/internal/config/config.go b/internal/config/config.go index b9bc5259d36390d53aa21befd524ea7043261905..ca585d80e8a9dcdc0f9f7b2d38999ce2cac74243 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -317,7 +317,7 @@ func (m MCPConfig) ResolvedHeaders() map[string]string { var err error m.Headers[e], err = resolver.ResolveValue(v) if err != nil { - slog.Error("error resolving header variable", "error", err, "variable", e, "value", v) + slog.Error("Error resolving header variable", "error", err, "variable", e, "value", v) continue } } @@ -840,7 +840,7 @@ func resolveEnvs(envs map[string]string) []string { var err error envs[e], err = resolver.ResolveValue(v) if err != nil { - slog.Error("error resolving environment variable", "error", err, "variable", e, "value", v) + slog.Error("Error resolving environment variable", "error", err, "variable", e, "value", v) continue } } diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index c22b960ad02a42bf6adac7768b7d99e55a9390ee..b541a4a0fedd78c866fa274fc183fabe4c833edd 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -144,20 +144,20 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo } if commonIgnorePatterns().MatchesPath(relPath) { - slog.Debug("ignoring common pattern", "path", relPath) + slog.Debug("Ignoring common pattern", "path", relPath) return true } parentDir := filepath.Dir(path) ignoreParser := dl.getIgnore(parentDir) if ignoreParser.MatchesPath(relPath) { - slog.Debug("ignoring dir pattern", "path", relPath, "dir", parentDir) + slog.Debug("Ignoring dir pattern", "path", relPath, "dir", parentDir) return true } // For directories, also check with trailing slash (gitignore convention) if ignoreParser.MatchesPath(relPath + "/") { - slog.Debug("ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir) + slog.Debug("Ignoring dir pattern with slash", "path", relPath+"/", "dir", parentDir) return true } @@ -166,7 +166,7 @@ func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bo } if homeIgnore().MatchesPath(relPath) { - slog.Debug("ignoring home dir pattern", "path", relPath) + slog.Debug("Ignoring home dir pattern", "path", relPath) return true } @@ -177,7 +177,7 @@ func (dl *directoryLister) checkParentIgnores(path string) bool { parent := filepath.Dir(filepath.Dir(path)) for parent != "." && path != "." { if dl.getIgnore(parent).MatchesPath(path) { - slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent) + slog.Debug("Ignoring parent dir pattern", "path", path, "dir", parent) return true } if parent == dl.rootPath { @@ -210,7 +210,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, depth, limit int found := csync.NewSlice[string]() dl := NewDirectoryLister(initialPath) - slog.Debug("listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns) + slog.Debug("Listing directory", "path", initialPath, "depth", depth, "limit", limit, "ignorePatterns", ignorePatterns) conf := fastwalk.Config{ Follow: true, diff --git a/internal/home/home.go b/internal/home/home.go index e44649235ff5bb24c8bb644ae90e9002add45237..80fb1ea2e01630597c2547eaa8e4e55150ec6976 100644 --- a/internal/home/home.go +++ b/internal/home/home.go @@ -12,7 +12,7 @@ var homedir, homedirErr = os.UserHomeDir() func init() { if homedirErr != nil { - slog.Error("failed to get user home directory", "error", homedirErr) + slog.Error("Failed to get user home directory", "error", homedirErr) } } diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 98aa75966160ba97af8c431d98c642fb558e5dc7..05ee570b9d5ad7a0d667b48084289bf0fe5d3dde 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -317,12 +317,12 @@ func (c *Client) HandlesFile(path string) bool { // Check if file is within working directory. absPath, err := filepath.Abs(path) if err != nil { - slog.Debug("cannot resolve path", "name", c.name, "file", path, "error", err) + slog.Debug("Cannot resolve path", "name", c.name, "file", path, "error", err) return false } relPath, err := filepath.Rel(c.workDir, absPath) if err != nil || strings.HasPrefix(relPath, "..") { - slog.Debug("file outside workspace", "name", c.name, "file", path, "workDir", c.workDir) + slog.Debug("File outside workspace", "name", c.name, "file", path, "workDir", c.workDir) return false } @@ -339,11 +339,11 @@ func (c *Client) HandlesFile(path string) bool { suffix = "." + suffix } if strings.HasSuffix(name, suffix) || filetype == string(kind) { - slog.Debug("handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind) + slog.Debug("Handles file", "name", c.name, "file", name, "filetype", filetype, "kind", kind) return true } } - slog.Debug("doesn't handle file", "name", c.name, "file", name) + slog.Debug("Doesn't handle file", "name", c.name, "file", name) return false } diff --git a/internal/session/session.go b/internal/session/session.go index 905ee1cf1417b148019d9688985c1f5200209d69..0ef6cfe22bebbf35df48f0db1fbe00c6d128251b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -203,7 +203,7 @@ func (s *service) List(ctx context.Context) ([]Session, error) { func (s service) fromDBItem(item db.Session) Session { todos, err := unmarshalTodos(item.Todos.String) if err != nil { - slog.Error("failed to unmarshal todos", "session_id", item.ID, "error", err) + slog.Error("Failed to unmarshal todos", "session_id", item.ID, "error", err) } return Session{ ID: item.ID, diff --git a/internal/ui/image/image.go b/internal/ui/image/image.go index 5644146fec5b1e4e1e3a96c92a315c0bf986180d..07039433dded1647646704959791dfcad7d3d69f 100644 --- a/internal/ui/image/image.go +++ b/internal/ui/image/image.go @@ -168,7 +168,7 @@ func (e Encoding) Transmit(id string, img image.Image, cs CellSize, cols, rows i return chunk }, }); err != nil { - slog.Error("failed to encode image for kitty graphics", "err", err) + slog.Error("Failed to encode image for kitty graphics", "err", err) return uiutil.InfoMsg{ Type: uiutil.InfoTypeError, Msg: "failed to encode image", diff --git a/internal/ui/model/history.go b/internal/ui/model/history.go index 5acc6ef5feabdab2bcb7a81ba8a60f5f224dab11..5d2284ab1756257cc06b76de4621849f1e3071ba 100644 --- a/internal/ui/model/history.go +++ b/internal/ui/model/history.go @@ -27,7 +27,7 @@ func (m *UI) loadPromptHistory() tea.Cmd { messages, err = m.com.App.Messages.ListAllUserMessages(ctx) } if err != nil { - slog.Error("failed to load prompt history", "error", err) + slog.Error("Failed to load prompt history", "error", err) return promptHistoryLoadedMsg{messages: nil} } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index eca2cf80a343214babbcfbdb37916a61d07a1ced..1b828dffd1ce86db8ae6efb53e1b23465dfa20f0 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -330,7 +330,7 @@ func (m *UI) loadCustomCommands() tea.Cmd { return func() tea.Msg { customCommands, err := commands.LoadCustomCommands(m.com.Config()) if err != nil { - slog.Error("failed to load custom commands", "error", err) + slog.Error("Failed to load custom commands", "error", err) } return userCommandsLoadedMsg{Commands: customCommands} } @@ -341,7 +341,7 @@ func (m *UI) loadMCPrompts() tea.Cmd { return func() tea.Msg { prompts, err := commands.LoadMCPPrompts() if err != nil { - slog.Error("failed to load mcp prompts", "error", err) + slog.Error("Failed to load MCP prompts", "error", err) } if prompts == nil { // flag them as loaded even if there is none or an error @@ -683,7 +683,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case uv.KittyGraphicsEvent: if !bytes.HasPrefix(msg.Payload, []byte("OK")) { - slog.Warn("unexpected Kitty graphics response", + slog.Warn("Unexpected Kitty graphics response", "response", string(msg.Payload), "options", msg.Options) } diff --git a/scripts/check_log_capitalization.sh b/scripts/check_log_capitalization.sh new file mode 100644 index 0000000000000000000000000000000000000000..fa5f651dfb1a7dc53876018029599edd3479d94f --- /dev/null +++ b/scripts/check_log_capitalization.sh @@ -0,0 +1,5 @@ +#!/bin/bash +if grep -rE 'slog\.(Error|Info|Warn|Debug|Fatal|Print|Println|Printf)\(["\"][a-z]' --include="*.go" . 2>/dev/null; then + echo "❌ Log messages must start with a capital letter. Found lowercase logs above." + exit 1 +fi From 02bb76b4098479a3efe3326d550163afba52a924 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 29 Jan 2026 21:13:11 -0300 Subject: [PATCH 33/75] fix: allow HYPER_URL with embedded provider (#2031) Signed-off-by: Carlos Alexandro Becker --- internal/agent/hyper/provider.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go index 6194593a719b388d1676f51568e72f45628fdae4..eaac14c6a8100c548288e66ddb8faabcdffa980b 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -51,6 +51,9 @@ var Embedded = sync.OnceValue(func() catwalk.Provider { if err := json.Unmarshal(embedded, &provider); err != nil { slog.Error("Could not use embedded provider data", "err", err) } + if e := os.Getenv("HYPER_URL"); e != "" { + provider.APIEndpoint = e + "/api/v1/fantasy" + } return provider }) From 7ace8d58f38e755d2d844b983aab5cff1e64ae4e Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 29 Jan 2026 15:30:12 -0500 Subject: [PATCH 34/75] fix: panic when matching titles in session dialogue --- internal/ui/dialog/sessions_item.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 87a2627daa3b63eca309feeb914ec80c33e2ef1f..119d3efb9cba1ee0a700b0b4bc22fee94289af76 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -141,7 +141,7 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width titleWidth := lipgloss.Width(title) gap := strings.Repeat(" ", max(0, lineWidth-titleWidth-infoWidth)) content := title - if matches := len(m.MatchedIndexes); matches > 0 { + if m != nil && len(m.MatchedIndexes) > 0 { var lastPos int parts := make([]string, 0) ranges := matchedRanges(m.MatchedIndexes) From 4228f7506d72a0a381c011539de17635f578f7d7 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 29 Jan 2026 15:33:33 -0500 Subject: [PATCH 35/75] fix: slice string at the grapheme level, not byte level --- internal/ui/dialog/sessions_item.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 119d3efb9cba1ee0a700b0b4bc22fee94289af76..6b1fe27580bba28aa02b06e2738e1882b7144107 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -148,7 +148,7 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width for _, rng := range ranges { start, stop := bytePosToVisibleCharPos(title, rng) if start > lastPos { - parts = append(parts, title[lastPos:start]) + parts = append(parts, ansi.Cut(title, lastPos, start)) } // NOTE: We're using [ansi.Style] here instead of [lipglosStyle] // because we can control the underline start and stop more @@ -157,13 +157,13 @@ func renderItem(t ListIemStyles, title string, info string, focused bool, width // with other style parts = append(parts, ansi.NewStyle().Underline(true).String(), - title[start:stop+1], + ansi.Cut(title, start, stop+1), ansi.NewStyle().Underline(false).String(), ) lastPos = stop + 1 } - if lastPos < len(title) { - parts = append(parts, title[lastPos:]) + if lastPos < ansi.StringWidth(title) { + parts = append(parts, ansi.Cut(title, lastPos, ansi.StringWidth(title))) } content = strings.Join(parts, "") From ac03cb02b28265074bbd001291b793633f822395 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 28 Jan 2026 15:57:11 -0500 Subject: [PATCH 36/75] fix(ui): typo in ListItemStyles type name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Kimi K2.5 via Crush --- internal/ui/dialog/commands_item.go | 2 +- internal/ui/dialog/models_item.go | 2 +- internal/ui/dialog/reasoning.go | 2 +- internal/ui/dialog/sessions_item.go | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index 1099a8b435f0ed31d9f4c81dfbb4cb2b33a3d910..89cd552f8ef5acfb326e9fbcae87b0a542b35022 100644 --- a/internal/ui/dialog/commands_item.go +++ b/internal/ui/dialog/commands_item.go @@ -66,7 +66,7 @@ func (c *CommandItem) Shortcut() string { // Render implements ListItem. func (c *CommandItem) Render(width int) string { - styles := ListIemStyles{ + styles := ListItemStyles{ ItemBlurred: c.t.Dialog.NormalItem, ItemFocused: c.t.Dialog.SelectedItem, InfoTextBlurred: c.t.Base, diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go index e61359d065a895ec508083198e5530977091366b..937ab0cb3ec473ab343837350aa590fbffcb0fc2 100644 --- a/internal/ui/dialog/models_item.go +++ b/internal/ui/dialog/models_item.go @@ -106,7 +106,7 @@ func (m *ModelItem) Render(width int) string { if m.showProvider { providerInfo = string(m.prov.Name) } - styles := ListIemStyles{ + styles := ListItemStyles{ ItemBlurred: m.t.Dialog.NormalItem, ItemFocused: m.t.Dialog.SelectedItem, InfoTextBlurred: m.t.Base, diff --git a/internal/ui/dialog/reasoning.go b/internal/ui/dialog/reasoning.go index f11c59e48702ea3bc419afa62e9ee7fce8c52632..2a333f155cdc1499993f05411d7090793f74f54e 100644 --- a/internal/ui/dialog/reasoning.go +++ b/internal/ui/dialog/reasoning.go @@ -293,7 +293,7 @@ func (r *ReasoningItem) Render(width int) string { if r.isCurrent { info = "current" } - styles := ListIemStyles{ + styles := ListItemStyles{ ItemBlurred: r.t.Dialog.NormalItem, ItemFocused: r.t.Dialog.SelectedItem, InfoTextBlurred: r.t.Base, diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 6b1fe27580bba28aa02b06e2738e1882b7144107..f4e7f061a83ec171940c02832d3b1bfe4d5b7ef7 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -76,7 +76,7 @@ func (s *SessionItem) Cursor() *tea.Cursor { // Render returns the string representation of the session item. func (s *SessionItem) Render(width int) string { info := humanize.Time(time.Unix(s.UpdatedAt, 0)) - styles := ListIemStyles{ + styles := ListItemStyles{ ItemBlurred: s.t.Dialog.NormalItem, ItemFocused: s.t.Dialog.SelectedItem, InfoTextBlurred: s.t.Subtle, @@ -101,14 +101,14 @@ func (s *SessionItem) Render(width int) string { return renderItem(styles, s.Title, info, s.focused, width, s.cache, &s.m) } -type ListIemStyles struct { +type ListItemStyles struct { ItemBlurred lipgloss.Style ItemFocused lipgloss.Style InfoTextBlurred lipgloss.Style InfoTextFocused lipgloss.Style } -func renderItem(t ListIemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { +func renderItem(t ListItemStyles, title string, info string, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { if cache == nil { cache = make(map[int]string) } From 857cc282d54af48d2b85b4e485c3b0829e9272b2 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 28 Jan 2026 16:09:18 -0500 Subject: [PATCH 37/75] chore(ui): string efficiency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: Kimi K2.5 via Crush --- internal/ui/chat/tools.go | 20 ++++++++++---------- internal/ui/styles/styles.go | 32 +++++++++++++++++--------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 3ae403160b241eca6f5d74fb9841c2b10a7735b9..69ba5efff7bbe02c7b322ba940ecfefadf299eea 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -1119,7 +1119,7 @@ func (t *baseToolMessageItem) formatViewResultForCopy() string { var result strings.Builder if lang != "" { - result.WriteString(fmt.Sprintf("```%s\n", lang)) + fmt.Fprintf(&result, "```%s\n", lang) } else { result.WriteString("```\n") } @@ -1155,7 +1155,7 @@ func (t *baseToolMessageItem) formatEditResultForCopy() string { } diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName) - result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals)) + fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals) result.WriteString("```diff\n") result.WriteString(diffContent) result.WriteString("\n```") @@ -1189,7 +1189,7 @@ func (t *baseToolMessageItem) formatMultiEditResultForCopy() string { } diffContent, additions, removals := diff.GenerateDiff(meta.OldContent, meta.NewContent, fileName) - result.WriteString(fmt.Sprintf("Changes: +%d -%d\n", additions, removals)) + fmt.Fprintf(&result, "Changes: +%d -%d\n", additions, removals) result.WriteString("```diff\n") result.WriteString(diffContent) result.WriteString("\n```") @@ -1247,9 +1247,9 @@ func (t *baseToolMessageItem) formatWriteResultForCopy() string { } var result strings.Builder - result.WriteString(fmt.Sprintf("File: %s\n", fsext.PrettyPath(params.FilePath))) + fmt.Fprintf(&result, "File: %s\n", fsext.PrettyPath(params.FilePath)) if lang != "" { - result.WriteString(fmt.Sprintf("```%s\n", lang)) + fmt.Fprintf(&result, "```%s\n", lang) } else { result.WriteString("```\n") } @@ -1272,13 +1272,13 @@ func (t *baseToolMessageItem) formatFetchResultForCopy() string { var result strings.Builder if params.URL != "" { - result.WriteString(fmt.Sprintf("URL: %s\n", params.URL)) + fmt.Fprintf(&result, "URL: %s\n", params.URL) } if params.Format != "" { - result.WriteString(fmt.Sprintf("Format: %s\n", params.Format)) + fmt.Fprintf(&result, "Format: %s\n", params.Format) } if params.Timeout > 0 { - result.WriteString(fmt.Sprintf("Timeout: %ds\n", params.Timeout)) + fmt.Fprintf(&result, "Timeout: %ds\n", params.Timeout) } result.WriteString("\n") @@ -1300,10 +1300,10 @@ func (t *baseToolMessageItem) formatAgenticFetchResultForCopy() string { var result strings.Builder if params.URL != "" { - result.WriteString(fmt.Sprintf("URL: %s\n", params.URL)) + fmt.Fprintf(&result, "URL: %s\n", params.URL) } if params.Prompt != "" { - result.WriteString(fmt.Sprintf("Prompt: %s\n\n", params.Prompt)) + fmt.Fprintf(&result, "Prompt: %s\n\n", params.Prompt) } result.WriteString("```markdown\n") diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 455658e7f4900196f7c03dcc1564ea734f780a64..474a50c9934ce9363d640a4dd95a2a49ea57efc5 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -2,6 +2,7 @@ package styles import ( "image/color" + "strings" "charm.land/bubbles/v2/filepicker" "charm.land/bubbles/v2/help" @@ -1347,35 +1348,36 @@ func boolPtr(b bool) *bool { return &b } func stringPtr(s string) *string { return &s } func uintPtr(u uint) *uint { return &u } func chromaStyle(style ansi.StylePrimitive) string { - var s string + var s strings.Builder if style.Color != nil { - s = *style.Color + s.WriteString(*style.Color) } if style.BackgroundColor != nil { - if s != "" { - s += " " + if s.Len() > 0 { + s.WriteString(" ") } - s += "bg:" + *style.BackgroundColor + s.WriteString("bg:") + s.WriteString(*style.BackgroundColor) } if style.Italic != nil && *style.Italic { - if s != "" { - s += " " + if s.Len() > 0 { + s.WriteString(" ") } - s += "italic" + s.WriteString("italic") } if style.Bold != nil && *style.Bold { - if s != "" { - s += " " + if s.Len() > 0 { + s.WriteString(" ") } - s += "bold" + s.WriteString("bold") } if style.Underline != nil && *style.Underline { - if s != "" { - s += " " + if s.Len() > 0 { + s.WriteString(" ") } - s += "underline" + s.WriteString("underline") } - return s + return s.String() } From 87c2165cd5cd5afa897d4e2c4a95ff590c785ef4 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 30 Jan 2026 12:55:07 -0300 Subject: [PATCH 38/75] chore: `chmod +x scripts/check_log_capitalization.sh` --- scripts/check_log_capitalization.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/check_log_capitalization.sh diff --git a/scripts/check_log_capitalization.sh b/scripts/check_log_capitalization.sh old mode 100644 new mode 100755 From 1696e72e92298d205e74384de36f94143ecabc38 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 30 Jan 2026 12:55:30 -0300 Subject: [PATCH 39/75] chore: update catwalk and its import paths to `charm.land/catwalk` --- Taskfile.yaml | 2 +- go.mod | 2 +- go.sum | 4 ++-- internal/agent/agent.go | 2 +- internal/agent/common_test.go | 2 +- internal/agent/coordinator.go | 2 +- internal/agent/hyper/provider.go | 2 +- internal/app/app.go | 2 +- internal/app/provider_test.go | 2 +- internal/cmd/models.go | 2 +- internal/config/catwalk.go | 4 ++-- internal/config/catwalk_test.go | 2 +- internal/config/config.go | 2 +- internal/config/copilot.go | 2 +- internal/config/hyper.go | 2 +- internal/config/hyper_test.go | 2 +- internal/config/load.go | 2 +- internal/config/load_test.go | 2 +- internal/config/provider.go | 4 ++-- internal/config/provider_empty_test.go | 2 +- internal/config/provider_test.go | 2 +- internal/message/content.go | 2 +- internal/tui/components/chat/messages/messages.go | 2 +- internal/tui/components/chat/splash/splash.go | 2 +- internal/tui/components/dialogs/models/list.go | 2 +- internal/tui/components/dialogs/models/list_recent_test.go | 2 +- internal/tui/components/dialogs/models/models.go | 2 +- internal/ui/chat/messages.go | 2 +- internal/ui/dialog/actions.go | 2 +- internal/ui/dialog/api_key_input.go | 2 +- internal/ui/dialog/models.go | 2 +- internal/ui/dialog/models_item.go | 2 +- internal/ui/dialog/oauth.go | 2 +- internal/ui/dialog/oauth_copilot.go | 2 +- internal/ui/dialog/oauth_hyper.go | 2 +- internal/ui/model/ui.go | 2 +- 36 files changed, 39 insertions(+), 39 deletions(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index bf22d6593bfd099972a7a11a806cfee939511df2..bff27387d6be353ccd02cf6437b4acafb30334c9 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -153,5 +153,5 @@ tasks: desc: Update Fantasy and Catwalk cmds: - go get charm.land/fantasy - - go get github.com/charmbracelet/catwalk + - go get charm.land/catwalk - go mod tidy diff --git a/go.mod b/go.mod index 9281f1b44966d5ce00d19264b6ad29dfc4cb4aa4..4ea501fd125ce2a8ef62b4555229218b5d65ff19 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e + charm.land/catwalk v0.16.0 charm.land/fantasy v0.6.1 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 @@ -19,7 +20,6 @@ require ( github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charlievieth/fastwalk v1.0.14 - github.com/charmbracelet/catwalk v0.15.0 github.com/charmbracelet/colorprofile v0.4.1 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 diff --git a/go.sum b/go.sum index c0d8fdcc25091a334f0f79fcf2e5f91247496fdc..af4bd1eaab7bb4696ef0d344113a435f90a7a4ac 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8= +charm.land/catwalk v0.16.0 h1:NP6lPz086OAsFdyYTRE6x1CyAosX6MpqdY303ntwsX0= +charm.land/catwalk v0.16.0/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64= charm.land/fantasy v0.6.1 h1:v3pavSHpZ5xTw98TpNYoj6DRq4ksCBWwJiZeiG/mVIc= charm.land/fantasy v0.6.1/go.mod h1:Ifj41bNnIXJ1aF6sLKcS9y3MzWbDnObmcHrCaaHfpZ0= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= @@ -96,8 +98,6 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4= -github.com/charmbracelet/catwalk v0.15.0 h1:5oWJdvchTPfF7855A0n40+XbZQz4+vouZ/NhQ661JKI= -github.com/charmbracelet/catwalk v0.15.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index d46f42334f9bb5de656cd2f6e442cb4c414a6968..20ca25f89421b8f1fd2927b1162c412d56becdc4 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -22,6 +22,7 @@ import ( "sync" "time" + "charm.land/catwalk/pkg/catwalk" "charm.land/fantasy" "charm.land/fantasy/providers/anthropic" "charm.land/fantasy/providers/bedrock" @@ -29,7 +30,6 @@ import ( "charm.land/fantasy/providers/openai" "charm.land/fantasy/providers/openrouter" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/agent/tools/mcp" diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index 2bb5e5650bcb3280ddb95bdcea7d588a2eea7643..4f96c3cfbb1728f533c71a7c05b7e1ab85975b45 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -8,13 +8,13 @@ import ( "testing" "time" + "charm.land/catwalk/pkg/catwalk" "charm.land/fantasy" "charm.land/fantasy/providers/anthropic" "charm.land/fantasy/providers/openai" "charm.land/fantasy/providers/openaicompat" "charm.land/fantasy/providers/openrouter" "charm.land/x/vcr" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/config" diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 40b7e029e465cc40f285ced7b5b77dd61109a2a0..60da01e08c668f641c11f79c36c29b5fc2186c78 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -15,8 +15,8 @@ import ( "slices" "strings" + "charm.land/catwalk/pkg/catwalk" "charm.land/fantasy" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/agent/prompt" "github.com/charmbracelet/crush/internal/agent/tools" diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go index eaac14c6a8100c548288e66ddb8faabcdffa980b..8ba3a538e4a97b4691dff4eb9aba46f83b523912 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -21,9 +21,9 @@ import ( "sync" "time" + "charm.land/catwalk/pkg/catwalk" "charm.land/fantasy" "charm.land/fantasy/object" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/event" ) diff --git a/internal/app/app.go b/internal/app/app.go index 88af5345eb55cc7f3e6c3c5923806967cc0a1632..c5294c2ae21f91486861a037b639cb1c00bd531f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,9 +15,9 @@ import ( "time" tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" "charm.land/fantasy" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/config" diff --git a/internal/app/provider_test.go b/internal/app/provider_test.go index c3acae64d1057f3bb8bd8f9a0cb6443dbe9731b7..8430211e0067810523a713a07a343ac546248830 100644 --- a/internal/app/provider_test.go +++ b/internal/app/provider_test.go @@ -3,7 +3,7 @@ package app import ( "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/stretchr/testify/require" ) diff --git a/internal/cmd/models.go b/internal/cmd/models.go index 3267469638ee83463e1785774d37c5d281d37de9..e2aa5c991d5cf49ba78dbff9d3f79c4f6493523d 100644 --- a/internal/cmd/models.go +++ b/internal/cmd/models.go @@ -7,8 +7,8 @@ import ( "sort" "strings" + "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2/tree" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/mattn/go-isatty" "github.com/spf13/cobra" diff --git a/internal/config/catwalk.go b/internal/config/catwalk.go index c3cc2eb69d47e1a85e35164fda09d0f73761b820..0c12c899c7ee34d6515410cccab13ac850a361a7 100644 --- a/internal/config/catwalk.go +++ b/internal/config/catwalk.go @@ -7,8 +7,8 @@ import ( "sync" "sync/atomic" - "github.com/charmbracelet/catwalk/pkg/catwalk" - "github.com/charmbracelet/catwalk/pkg/embedded" + "charm.land/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/embedded" ) type catwalkClient interface { diff --git a/internal/config/catwalk_test.go b/internal/config/catwalk_test.go index 55322b34eb7252f8cae75fb46996f45bd31abe5e..df6aea475811adfe3e4fb8935185842c7c81d145 100644 --- a/internal/config/catwalk_test.go +++ b/internal/config/catwalk_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/stretchr/testify/require" ) diff --git a/internal/config/config.go b/internal/config/config.go index ca585d80e8a9dcdc0f9f7b2d38999ce2cac74243..19133928bd8f7e1da08b54024b4f80d41d01dc1a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,7 @@ import ( "strings" "time" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" hyperp "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/env" diff --git a/internal/config/copilot.go b/internal/config/copilot.go index ee50bec43d6ce5754799adf4bfe99ba9b357d690..d72e7d5048ba4d31c88d7f7152a6b3a9510960a2 100644 --- a/internal/config/copilot.go +++ b/internal/config/copilot.go @@ -6,7 +6,7 @@ import ( "log/slog" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/oauth/copilot" ) diff --git a/internal/config/hyper.go b/internal/config/hyper.go index 5fe6fc5a1ee54bd19902ef4c9cc6034a6b294b6f..6772f27b3bd3be136d001139a8505a7bb3fedef3 100644 --- a/internal/config/hyper.go +++ b/internal/config/hyper.go @@ -11,7 +11,7 @@ import ( "sync/atomic" "time" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/hyper" xetag "github.com/charmbracelet/x/etag" ) diff --git a/internal/config/hyper_test.go b/internal/config/hyper_test.go index 7141eaa1e97888b5ee6f84afc8e9658825547b46..e4b6ac8acdfcdeb19f0d600baf88a337d40c230d 100644 --- a/internal/config/hyper_test.go +++ b/internal/config/hyper_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/stretchr/testify/require" ) diff --git a/internal/config/load.go b/internal/config/load.go index 25139cb5f4b2ba8013525bfde025f04cb267d1b8..3ad4b909cb16cf5672dcadc9322a476854350632 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -16,7 +16,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/env" diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 08c888318724104935b9e92403f09f54f8ae20a4..60a0b7379501a7d766b33c4828c644cdb390bada 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/env" "github.com/stretchr/testify/assert" diff --git a/internal/config/provider.go b/internal/config/provider.go index 253d6f658a567ed5302887ecb87415de0a89c504..6ca981e5a73cbf3e3472b05f55c7b911a4a857c3 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -15,8 +15,8 @@ import ( "sync" "time" - "github.com/charmbracelet/catwalk/pkg/catwalk" - "github.com/charmbracelet/catwalk/pkg/embedded" + "charm.land/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/embedded" "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/home" diff --git a/internal/config/provider_empty_test.go b/internal/config/provider_empty_test.go index 7c37a9afb9694f0ea4352faee1b11d7e40d9480e..9bc62f5c3141d239aaadc3947dce539a4dcf4810 100644 --- a/internal/config/provider_empty_test.go +++ b/internal/config/provider_empty_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/stretchr/testify/require" ) diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index e8790e286c3ffc8db77edb0ef8353e54ad519458..283c18c8ab68c013dadf6f4fc8174f4947210f3a 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -7,7 +7,7 @@ import ( "sync" "testing" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/stretchr/testify/require" ) diff --git a/internal/message/content.go b/internal/message/content.go index 3fed1f06019c855d30af9d5583e6a7b63fcbd508..02f949334b688e4dd40c832d5f68d52523ac9953 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -8,11 +8,11 @@ import ( "strings" "time" + "charm.land/catwalk/pkg/catwalk" "charm.land/fantasy" "charm.land/fantasy/providers/anthropic" "charm.land/fantasy/providers/google" "charm.land/fantasy/providers/openai" - "github.com/charmbracelet/catwalk/pkg/catwalk" ) type MessageRole string diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index b4db149946fe0a1f67c957eeb04da2966e1f5f28..3c91f9f41485b439b8c25ca0692c7265ccafb14a 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -9,8 +9,8 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/ordered" "github.com/google/uuid" diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 517f6d0930c46cf3d2e9f656c22515de4e9785fd..886fe5e530978678246ab120b21e0f943018fd1a 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -8,8 +8,8 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent" hyperp "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/config" diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index 581122525a89dd308bb57a30e6b15a4cd0896708..50469a132aab60c3e63a77d9169c47688d5d9151 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -7,7 +7,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" diff --git a/internal/tui/components/dialogs/models/list_recent_test.go b/internal/tui/components/dialogs/models/list_recent_test.go index 9b738a4b17fbaa2de18de080a769cce41a676007..5afdde98502d3d26d46dce00ab1825ca07f36831 100644 --- a/internal/tui/components/dialogs/models/list_recent_test.go +++ b/internal/tui/components/dialogs/models/list_recent_test.go @@ -9,7 +9,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/log" "github.com/charmbracelet/crush/internal/tui/exp/list" diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index b06b4b475a9ababbda9e0702fc5552b0959741ba..34f91d060cf7b7a7fd0a3a6fe678a23ed8439530 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -9,8 +9,8 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" hyperp "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/tui/components/core" diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 45314347187e7018445d30753ffd05d24dbc716a..ad8aad399cf40809f16779dd277536d9ad47d5e3 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -7,8 +7,8 @@ import ( "time" tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/anim" diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index b5db01692437dbee4b11b77da47b68f258b090e9..7c11cbd91b202cfc16e1988027f9eed657368620 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -7,7 +7,7 @@ import ( "path/filepath" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/message" diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go index 65fe5cfb9cb14eb60f4399b0477d6cd071315750..0ca50b8fe7f8899f16aac8428caa796c5da89610 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -10,7 +10,7 @@ import ( "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 450ee8b99b75f13c1c9885281a1dfd1a0a3d9867..354d02434a6623b5a9833bc010f4eaa8d1efdc7a 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -10,7 +10,7 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/uiutil" diff --git a/internal/ui/dialog/models_item.go b/internal/ui/dialog/models_item.go index 937ab0cb3ec473ab343837350aa590fbffcb0fc2..645b26e987b38baabd27338d43a19a4652144788 100644 --- a/internal/ui/dialog/models_item.go +++ b/internal/ui/dialog/models_item.go @@ -1,8 +1,8 @@ package dialog import ( + "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/styles" diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go index e4f7a521cacb51d215ca405883351558ed7179d6..6fbb039255144ad14b15a39f34942e504dea3f2c 100644 --- a/internal/ui/dialog/oauth.go +++ b/internal/ui/dialog/oauth.go @@ -9,8 +9,8 @@ import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/ui/common" diff --git a/internal/ui/dialog/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go index 4b671852d476578f94653393796056d630ba23a5..8afb0df23134bb9e820ae2385d6b9b6838e07d98 100644 --- a/internal/ui/dialog/oauth_copilot.go +++ b/internal/ui/dialog/oauth_copilot.go @@ -6,7 +6,7 @@ import ( "time" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/oauth/copilot" "github.com/charmbracelet/crush/internal/ui/common" diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go index bddf4d78ef2c920855f21e056e7ee48f985b0b68..d90c385db782478721fd3e9efa49a2984f34304d 100644 --- a/internal/ui/dialog/oauth_hyper.go +++ b/internal/ui/dialog/oauth_hyper.go @@ -6,7 +6,7 @@ import ( "time" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" + "charm.land/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/oauth/hyper" "github.com/charmbracelet/crush/internal/ui/common" diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 1b828dffd1ce86db8ae6efb53e1b23465dfa20f0..d44c96da32630baa086e520c2c10fcef145eb772 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -22,8 +22,8 @@ import ( "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" + "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" - "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/commands" From b51c2e02e52ea23e4b5284b97dbfbc22510dca53 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 30 Jan 2026 13:46:28 -0300 Subject: [PATCH 40/75] fix: do not scroll to bottom if user has scrolled up (#2049) --- internal/ui/list/list.go | 4 +++- internal/ui/model/ui.go | 20 ++++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 0883ab2b56c5bb7ab26073301890c832e7c4e441..33a5087c9ceae3f03bb2c8f78b2cc8089f87057c 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -77,6 +77,8 @@ func (l *List) Gap() int { // AtBottom returns whether the list is showing the last item at the bottom. func (l *List) AtBottom() bool { + const margin = 2 + if len(l.items) == 0 { return true } @@ -92,7 +94,7 @@ func (l *List) AtBottom() bool { totalHeight += itemHeight } - return totalHeight-l.offsetLine <= l.height + return totalHeight-l.offsetLine-margin <= l.height } // SetReverse shows the list in reverse order. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d44c96da32630baa086e520c2c10fcef145eb772..1396cae66f35152a8e9bd3dbce66631125482432 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -830,11 +830,14 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) { // if the message is a tool result it will update the corresponding tool call message func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { var cmds []tea.Cmd + atBottom := m.chat.list.AtBottom() + existing := m.chat.MessageItem(msg.ID) if existing != nil { // message already exists, skip return nil } + switch msg.Role { case message.User: m.lastUserMessageTime = msg.CreatedAt @@ -860,14 +863,18 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } } m.chat.AppendMessages(items...) - if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { - cmds = append(cmds, cmd) + if atBottom { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } } if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0)) m.chat.AppendMessages(infoItem) - if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { - cmds = append(cmds, cmd) + if atBottom { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } } } case message.Tool: @@ -879,6 +886,11 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok { toolMsgItem.SetResult(&tr) + if atBottom { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { + cmds = append(cmds, cmd) + } + } } } } From 6c26f2a97cca5562159c532977147caa7c9deca4 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 30 Jan 2026 13:47:39 -0300 Subject: [PATCH 41/75] fix(ui): switch focus on click (#2055) Ignore sidebar clicks when sidebar is visible. Assisted-by: GPT-5.2 via Crush --- internal/ui/model/ui.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 1396cae66f35152a8e9bd3dbce66631125482432..8eb3fb1b454c81607e7979234076b9cc52c3a5b2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -529,13 +529,18 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dialog.Update(msg) return m, tea.Batch(cmds...) } + + if cmd := m.handleClickFocus(msg); cmd != nil { + cmds = append(cmds, cmd) + } + switch m.state { case uiChat: x, y := msg.X, msg.Y // Adjust for chat area position x -= m.layout.main.Min.X y -= m.layout.main.Min.Y - if m.chat.HandleMouseDown(x, y) { + if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) && m.chat.HandleMouseDown(x, y) { m.lastClickTime = time.Now() } } @@ -897,6 +902,24 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { return tea.Batch(cmds...) } +func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) { + switch { + case m.state != uiChat: + return nil + case image.Pt(msg.X, msg.Y).In(m.layout.sidebar): + return nil + case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor): + m.focus = uiFocusEditor + cmd = m.textarea.Focus() + m.chat.Blur() + case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main): + m.focus = uiFocusMain + m.textarea.Blur() + m.chat.Focus() + } + return cmd +} + // updateSessionMessage updates an existing message in the current session in the chat // when an assistant message is updated it may include updated tool calls as well // that is why we need to handle creating/updating each tool call message too From 230b99c7bd158dee449848fa4a671cfb0c58edbd Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 30 Jan 2026 13:48:10 -0300 Subject: [PATCH 42/75] fix(ui): arrow navigation wasnt working when todo view is open (#2052) Signed-off-by: Carlos Alexandro Becker --- internal/ui/model/ui.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 8eb3fb1b454c81607e7979234076b9cc52c3a5b2..db7f6f26d5dfbae75b94f8e825d37520b1ece818 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1424,14 +1424,14 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return true } case key.Matches(msg, m.keyMap.Chat.PillLeft): - if m.state == uiChat && m.hasSession() && m.pillsExpanded { + if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor { if cmd := m.switchPillSection(-1); cmd != nil { cmds = append(cmds, cmd) } return true } case key.Matches(msg, m.keyMap.Chat.PillRight): - if m.state == uiChat && m.hasSession() && m.pillsExpanded { + if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor { if cmd := m.switchPillSection(1); cmd != nil { cmds = append(cmds, cmd) } From 216f904749612ce82fe078ddbe4b03c73f823144 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 30 Jan 2026 11:57:24 -0500 Subject: [PATCH 43/75] fix(posthog): check correct error; prevent panic (#2036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Assisted-by: GLM 4.7 via Crush --- internal/event/event.go | 10 ++--- internal/event/event_test.go | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 internal/event/event_test.go diff --git a/internal/event/event.go b/internal/event/event.go index 674586b06bee03f22c1bd880a5bd39b740c75f66..10b054ce0b21fb3c0db441746827a20739963315 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -82,18 +82,18 @@ func send(event string, props ...any) { } // Error logs an error event to PostHog with the error type and message. -func Error(err any, props ...any) { +func Error(errToLog any, props ...any) { if client == nil { return } posthogErr := client.Enqueue(posthog.NewDefaultException( time.Now(), distinctId, - reflect.TypeOf(err).String(), - fmt.Sprintf("%v", err), + reflect.TypeOf(errToLog).String(), + fmt.Sprintf("%v", errToLog), )) - if err != nil { - slog.Error("Failed to enqueue PostHog error", "err", err, "props", props, "posthogErr", posthogErr) + if posthogErr != nil { + slog.Error("Failed to enqueue PostHog error", "err", errToLog, "props", props, "posthogErr", posthogErr) return } } diff --git a/internal/event/event_test.go b/internal/event/event_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7cd22248f19ca072853cd4270ae6fc36e4c124f5 --- /dev/null +++ b/internal/event/event_test.go @@ -0,0 +1,74 @@ +package event + +// These tests verify that the Error function correctly handles various +// scenarios. These tests will not log anything. + +import ( + "testing" +) + +func TestError(t *testing.T) { + t.Run("returns early when client is nil", func(t *testing.T) { + // This test verifies that when the PostHog client is not initialized + // the Error function safely returns early without attempting to + // enqueue any events. This is important during initialization or when + // metrics are disabled, as we don't want the error reporting mechanism + // itself to cause panics. + originalClient := client + defer func() { + client = originalClient + }() + + client = nil + Error("test error", "key", "value") + }) + + t.Run("handles nil client without panicking", func(t *testing.T) { + // This test covers various edge cases where the error value might be + // nil, a string, or an error type. + originalClient := client + defer func() { + client = originalClient + }() + + client = nil + Error(nil) + Error("some error") + Error(newDefaultTestError("runtime error"), "key", "value") + }) + + t.Run("handles error with properties", func(t *testing.T) { + // This test verifies that the Error function can handle additional + // key-value properties that provide context about the error. These + // properties are typically passed when recovering from panics (i.e., + // panic name, function name). + // + // Even with these additional properties, the function should handle + // them gracefully without panicking. + originalClient := client + defer func() { + client = originalClient + }() + + client = nil + Error("test error", + "type", "test", + "severity", "high", + "source", "unit-test", + ) + }) +} + +// newDefaultTestError creates a test error that mimics runtime panic +// errors. This helps us testing that the Error function can handle various +// error types, including those that might be passed from a panic recovery +// scenario. +func newDefaultTestError(s string) error { + return testError(s) +} + +type testError string + +func (e testError) Error() string { + return string(e) +} From 7119b7e6981ceff5764bd3903ac8cb995a65759c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 30 Jan 2026 20:06:31 +0300 Subject: [PATCH 45/75] fix(ui): update layout and size after session switch --- internal/ui/model/ui.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index db7f6f26d5dfbae75b94f8e825d37520b1ece818..e26323f551c7099fd579c303b80f1b764a98f242 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -396,6 +396,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reload prompt history for the new session. m.historyReset() cmds = append(cmds, m.loadPromptHistory()) + m.updateLayoutAndSize() case sendMessageMsg: cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...)) From 133cb6f9b03d769e5328e5124506e1c6e321c075 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:52:25 -0300 Subject: [PATCH 46/75] chore(legal): @bittoby has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index e03ad52ee49a9b000bd8cb935f4da628158ed0ef..b4295ce22cbc508fbb7e012ad12f0c1057c02ced 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1143,6 +1143,14 @@ "created_at": "2026-01-29T07:05:12Z", "repoId": 987670088, "pullRequestNo": 2043 + }, + { + "name": "bittoby", + "id": 218712309, + "comment_id": 3824931235, + "created_at": "2026-01-30T17:52:15Z", + "repoId": 987670088, + "pullRequestNo": 2065 } ] } \ No newline at end of file From 77a241fa4ee618cce390d2805c0a6741f942726d Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 2 Feb 2026 00:06:32 -0300 Subject: [PATCH 47/75] chore(legal): @ijt has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index b4295ce22cbc508fbb7e012ad12f0c1057c02ced..027afeb8dba1ea9fdf4e20ef263eb6ed973ea2d1 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1151,6 +1151,14 @@ "created_at": "2026-01-30T17:52:15Z", "repoId": 987670088, "pullRequestNo": 2065 + }, + { + "name": "ijt", + "id": 15530, + "comment_id": 3832667774, + "created_at": "2026-02-02T03:06:23Z", + "repoId": 987670088, + "pullRequestNo": 2080 } ] } \ No newline at end of file From a8b62b11d508b0e4b9bf6619115846dd65a370b0 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 2 Feb 2026 01:04:15 -0300 Subject: [PATCH 48/75] chore(legal): @khalilgharbaoui has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 027afeb8dba1ea9fdf4e20ef263eb6ed973ea2d1..5c18e45ad2a8120191a89d58eb101f84303902b0 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1159,6 +1159,14 @@ "created_at": "2026-02-02T03:06:23Z", "repoId": 987670088, "pullRequestNo": 2080 + }, + { + "name": "khalilgharbaoui", + "id": 8024057, + "comment_id": 3832796060, + "created_at": "2026-02-02T04:04:04Z", + "repoId": 987670088, + "pullRequestNo": 2081 } ] } \ No newline at end of file From 514318dbcae86487260f18ecd4c403d8c5f4d44a Mon Sep 17 00:00:00 2001 From: James Trew <66286082+jamestrew@users.noreply.github.com> Date: Mon, 2 Feb 2026 05:15:30 -0500 Subject: [PATCH 49/75] fix(ui): show auto-discovered LSPs (#2077) The fix changes iteration from configs-only to all cached states, displaying both configured and auto-discovered LSPs. --- internal/ui/model/lsp.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go index de33142d51c720265ad84d317b83f5a997f69fac..c46beb10083b420ec1353c8a2536d45093a899b4 100644 --- a/internal/ui/model/lsp.go +++ b/internal/ui/model/lsp.go @@ -2,6 +2,8 @@ package model import ( "fmt" + "maps" + "slices" "strings" "charm.land/lipgloss/v2" @@ -21,16 +23,14 @@ type LSPInfo struct { // lspInfo renders the LSP status section showing active LSP clients and their // diagnostic counts. func (m *UI) lspInfo(width, maxItems int, isSection bool) string { - var lsps []LSPInfo t := m.com.Styles - lspConfigs := m.com.Config().LSP.Sorted() - for _, cfg := range lspConfigs { - state, ok := m.lspStates[cfg.Name] - if !ok { - continue - } + states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b app.LSPClientInfo) int { + return strings.Compare(a.Name, b.Name) + }) + var lsps []LSPInfo + for _, state := range states { client, ok := m.com.App.LSPClients.Get(state.Name) if !ok { continue From 25f05a6a84f8ee43a675d2d4154d30d18687bb31 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 2 Feb 2026 11:51:51 +0100 Subject: [PATCH 50/75] refactor(chat): handle double click & triple click (#1959) * refactor(chat): handle double click & tripple click this also improves the expand behavior for items that can expand when you click or heighlight them, now they won't expland for double click or while you are highlighting * chore: use uax29 words * fix(ui): chat: simplify word boundary detection in highlighted text * fix(ui): chat: adjust multi-click timing * chore: go mod tidy * chore: change double click to 400ms --------- Co-authored-by: Ayman Bagabas --- go.mod | 4 +- go.sum | 4 +- internal/ui/chat/messages.go | 12 +- internal/ui/chat/tools.go | 4 +- internal/ui/model/chat.go | 260 +++++++++++++++++++++++++++++++++-- internal/ui/model/ui.go | 13 +- 6 files changed, 269 insertions(+), 28 deletions(-) diff --git a/go.mod b/go.mod index 4ea501fd125ce2a8ef62b4555229218b5d65ff19..2ed59fa188f17124c154c2aa60d1496be695b49c 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,8 @@ require ( github.com/charmbracelet/x/exp/strings v0.1.0 github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 github.com/charmbracelet/x/term v0.2.2 + github.com/clipperhouse/displaywidth v0.7.0 + github.com/clipperhouse/uax29/v2 v2.3.1 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/disintegration/imaging v1.6.2 @@ -106,9 +108,7 @@ require ( github.com/charmbracelet/x/json v0.2.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/disintegration/gift v1.1.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect diff --git a/go.sum b/go.sum index af4bd1eaab7bb4696ef0d344113a435f90a7a4ac..661ba0de7ae7187dca0ed4aa690d853e7145305d 100644 --- a/go.sum +++ b/go.sum @@ -134,8 +134,8 @@ github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBw github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw= +github.com/clipperhouse/uax29/v2 v2.3.1/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index ad8aad399cf40809f16779dd277536d9ad47d5e3..b9f16adf3ad7d5b7097e639892b1c6a6f1c22042 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -18,9 +18,9 @@ import ( "github.com/charmbracelet/crush/internal/ui/styles" ) -// this is the total width that is taken up by the border + padding -// we also cap the width so text is readable to the maxTextWidth(120) -const messageLeftPaddingTotal = 2 +// MessageLeftPaddingTotal is the total width that is taken up by the border + +// padding. We also cap the width so text is readable to the maxTextWidth(120). +const MessageLeftPaddingTotal = 2 // maxTextWidth is the maximum width text messages can be const maxTextWidth = 120 @@ -100,7 +100,7 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) { // Adjust columns for the style's left inset (border + padding) since we // highlight the content only. - offset := messageLeftPaddingTotal + offset := MessageLeftPaddingTotal h.startLine = startLine h.startCol = max(0, startCol-offset) h.endLine = endLine @@ -205,7 +205,7 @@ func (a *AssistantInfoItem) ID() string { // RawRender implements MessageItem. func (a *AssistantInfoItem) RawRender(width int) string { - innerWidth := max(0, width-messageLeftPaddingTotal) + innerWidth := max(0, width-MessageLeftPaddingTotal) content, _, ok := a.getCachedRender(innerWidth) if !ok { content = a.renderContent(innerWidth) @@ -245,7 +245,7 @@ func (a *AssistantInfoItem) renderContent(width int) string { // cappedMessageWidth returns the maximum width for message content for readability. func cappedMessageWidth(availableWidth int) int { - return min(availableWidth-messageLeftPaddingTotal, maxTextWidth) + return min(availableWidth-MessageLeftPaddingTotal, maxTextWidth) } // ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 69ba5efff7bbe02c7b322ba940ecfefadf299eea..07c3d98e6f60d319df8eff3699a057ad562771b7 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -292,7 +292,7 @@ func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd { // RawRender implements [MessageItem]. func (t *baseToolMessageItem) RawRender(width int) string { - toolItemWidth := width - messageLeftPaddingTotal + toolItemWidth := width - MessageLeftPaddingTotal if t.hasCappedWidth { toolItemWidth = cappedMessageWidth(width) } @@ -690,7 +690,7 @@ func toolOutputDiffContent(sty *styles.Styles, file, oldContent, newContent stri truncMsg := sty.Tool.DiffTruncation. Width(bodyWidth). Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)) - formatted = truncMsg + "\n" + strings.Join(lines[:maxLines], "\n") + formatted = strings.Join(lines[:maxLines], "\n") + "\n" + truncMsg } return sty.Tool.Body.Render(formatted) diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 3a743edd9d1e87b643076f114b065b2eaa2b2ca5..4abe68f27aa367b5ff81ebefc89633310f93e81d 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -2,6 +2,7 @@ package model import ( "strings" + "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -11,8 +12,24 @@ import ( "github.com/charmbracelet/crush/internal/ui/list" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" + "github.com/clipperhouse/displaywidth" + "github.com/clipperhouse/uax29/v2/words" ) +// Constants for multi-click detection. +const ( + doubleClickThreshold = 400 * time.Millisecond // 0.4s is typical double-click threshold + clickTolerance = 2 // x,y tolerance for double/tripple click +) + +// DelayedClickMsg is sent after the double-click threshold to trigger a +// single-click action (like expansion) if no double-click occurred. +type DelayedClickMsg struct { + ClickID int + ItemIdx int + X, Y int +} + // Chat represents the chat UI model that handles chat interactions and // messages. type Chat struct { @@ -33,6 +50,15 @@ type Chat struct { mouseDragItem int // Current item index being dragged over mouseDragX int // Current X in item content mouseDragY int // Current Y in item + + // Click tracking for double/triple clicks + lastClickTime time.Time + lastClickX int + lastClickY int + clickCount int + + // Pending single click action (delayed to detect double-click) + pendingClickID int // Incremented on each click to invalidate old pending clicks } // NewChat creates a new instance of [Chat] that handles chat interactions and @@ -426,35 +452,97 @@ func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) { } // HandleMouseDown handles mouse down events for the chat component. -func (m *Chat) HandleMouseDown(x, y int) bool { +// It detects single, double, and triple clicks for text selection. +// Returns whether the click was handled and an optional command for delayed +// single-click actions. +func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) { if m.list.Len() == 0 { - return false + return false, nil } itemIdx, itemY := m.list.ItemIndexAtPosition(x, y) if itemIdx < 0 { - return false + return false, nil } if !m.isSelectable(itemIdx) { - return false + return false, nil } - m.mouseDown = true - m.mouseDownItem = itemIdx - m.mouseDownX = x - m.mouseDownY = itemY - m.mouseDragItem = itemIdx - m.mouseDragX = x - m.mouseDragY = itemY + // Increment pending click ID to invalidate any previous pending clicks. + m.pendingClickID++ + clickID := m.pendingClickID + + // Detect multi-click (double/triple) + now := time.Now() + if now.Sub(m.lastClickTime) <= doubleClickThreshold && + abs(x-m.lastClickX) <= clickTolerance && + abs(y-m.lastClickY) <= clickTolerance { + m.clickCount++ + } else { + m.clickCount = 1 + } + m.lastClickTime = now + m.lastClickX = x + m.lastClickY = y // Select the item that was clicked m.list.SetSelected(itemIdx) + var cmd tea.Cmd + + switch m.clickCount { + case 1: + // Single click - start selection and schedule delayed click action. + m.mouseDown = true + m.mouseDownItem = itemIdx + m.mouseDownX = x + m.mouseDownY = itemY + m.mouseDragItem = itemIdx + m.mouseDragX = x + m.mouseDragY = itemY + + // Schedule delayed click action (e.g., expansion) after a short delay. + // If a double-click occurs, the clickID will be invalidated. + cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg { + return DelayedClickMsg{ + ClickID: clickID, + ItemIdx: itemIdx, + X: x, + Y: itemY, + } + }) + case 2: + // Double click - select word (no delayed action) + m.selectWord(itemIdx, x, itemY) + case 3: + // Triple click - select line (no delayed action) + m.selectLine(itemIdx, itemY) + m.clickCount = 0 // Reset after triple click + } + + return true, cmd +} + +// HandleDelayedClick handles a delayed single-click action (like expansion). +// It only executes if the click ID matches (i.e., no double-click occurred) +// and no text selection was made (drag to select). +func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool { + // Ignore if this click was superseded by a newer click (double/triple). + if msg.ClickID != m.pendingClickID { + return false + } + + // Don't expand if user dragged to select text. + if m.HasHighlight() { + return false + } + + // Execute the click action (e.g., expansion). if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok { - return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY) + return clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y) } - return true + return false } // HandleMouseUp handles mouse up events for the chat component. @@ -535,6 +623,11 @@ func (m *Chat) ClearMouse() { m.mouseDown = false m.mouseDownItem = -1 m.mouseDragItem = -1 + m.lastClickTime = time.Time{} + m.lastClickX = 0 + m.lastClickY = 0 + m.clickCount = 0 + m.pendingClickID++ // Invalidate any pending delayed click } // applyHighlightRange applies the current highlight range to the chat items. @@ -612,3 +705,144 @@ func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemId return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol } + +// selectWord selects the word at the given position within an item. +func (m *Chat) selectWord(itemIdx, x, itemY int) { + item := m.list.ItemAt(itemIdx) + if item == nil { + return + } + + // Get the rendered content for this item + var rendered string + if rr, ok := item.(list.RawRenderable); ok { + rendered = rr.RawRender(m.list.Width()) + } else { + rendered = item.Render(m.list.Width()) + } + + lines := strings.Split(rendered, "\n") + if itemY < 0 || itemY >= len(lines) { + return + } + + // Adjust x for the item's left padding (border + padding) to get content column. + // The mouse x is in viewport space, but we need content space for boundary detection. + offset := chat.MessageLeftPaddingTotal + contentX := x - offset + if contentX < 0 { + contentX = 0 + } + + line := ansi.Strip(lines[itemY]) + startCol, endCol := findWordBoundaries(line, contentX) + if startCol == endCol { + // No word found at position, fallback to single click behavior + m.mouseDown = true + m.mouseDownItem = itemIdx + m.mouseDownX = x + m.mouseDownY = itemY + m.mouseDragItem = itemIdx + m.mouseDragX = x + m.mouseDragY = itemY + return + } + + // Set selection to the word boundaries (convert back to viewport space). + // Keep mouseDown true so HandleMouseUp triggers the copy. + m.mouseDown = true + m.mouseDownItem = itemIdx + m.mouseDownX = startCol + offset + m.mouseDownY = itemY + m.mouseDragItem = itemIdx + m.mouseDragX = endCol + offset + m.mouseDragY = itemY +} + +// selectLine selects the entire line at the given position within an item. +func (m *Chat) selectLine(itemIdx, itemY int) { + item := m.list.ItemAt(itemIdx) + if item == nil { + return + } + + // Get the rendered content for this item + var rendered string + if rr, ok := item.(list.RawRenderable); ok { + rendered = rr.RawRender(m.list.Width()) + } else { + rendered = item.Render(m.list.Width()) + } + + lines := strings.Split(rendered, "\n") + if itemY < 0 || itemY >= len(lines) { + return + } + + // Get line length (stripped of ANSI codes) and account for padding. + // SetHighlight will subtract the offset, so we need to add it here. + offset := chat.MessageLeftPaddingTotal + lineLen := ansi.StringWidth(lines[itemY]) + + // Set selection to the entire line. + // Keep mouseDown true so HandleMouseUp triggers the copy. + m.mouseDown = true + m.mouseDownItem = itemIdx + m.mouseDownX = 0 + m.mouseDownY = itemY + m.mouseDragItem = itemIdx + m.mouseDragX = lineLen + offset + m.mouseDragY = itemY +} + +// findWordBoundaries finds the start and end column of the word at the given column. +// Returns (startCol, endCol) where endCol is exclusive. +func findWordBoundaries(line string, col int) (startCol, endCol int) { + if line == "" || col < 0 { + return 0, 0 + } + + i := displaywidth.StringGraphemes(line) + for i.Next() { + } + + // Segment the line into words using UAX#29. + lineCol := 0 // tracks the visited column widths + lastCol := 0 // tracks the start of the current token + iter := words.FromString(line) + for iter.Next() { + token := iter.Value() + tokenWidth := displaywidth.String(token) + + graphemeStart := lineCol + graphemeEnd := lineCol + tokenWidth + lineCol += tokenWidth + + // If clicked before this token, return the previous token boundaries. + if col < graphemeStart { + return lastCol, lastCol + } + + // Update lastCol to the end of this token for next iteration. + lastCol = graphemeEnd + + // If clicked within this token, return its boundaries. + if col >= graphemeStart && col < graphemeEnd { + // If clicked on whitespace, return empty selection. + if strings.TrimSpace(token) == "" { + return col, col + } + return graphemeStart, graphemeEnd + } + } + + return col, col +} + +// abs returns the absolute value of an integer. +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e26323f551c7099fd579c303b80f1b764a98f242..1e81a5625b909598668487b137fb80afce5754da 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -524,6 +524,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case copyChatHighlightMsg: cmds = append(cmds, m.copyChatHighlight()) + case DelayedClickMsg: + // Handle delayed single-click action (e.g., expansion). + m.chat.HandleDelayedClick(msg) case tea.MouseClickMsg: // Pass mouse events to dialogs first if any are open. if m.dialog.HasDialogs() { @@ -541,8 +544,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Adjust for chat area position x -= m.layout.main.Min.X y -= m.layout.main.Min.Y - if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) && m.chat.HandleMouseDown(x, y) { - m.lastClickTime = time.Now() + if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) { + if handled, cmd := m.chat.HandleMouseDown(x, y); handled { + m.lastClickTime = time.Now() + if cmd != nil { + cmds = append(cmds, cmd) + } + } } } @@ -590,7 +598,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dialog.Update(msg) return m, tea.Batch(cmds...) } - const doubleClickThreshold = 500 * time.Millisecond switch m.state { case uiChat: From 66556b5679cf28aada4f4d09abde7fd462ef9839 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 2 Feb 2026 12:09:00 +0100 Subject: [PATCH 51/75] chore: handle hyper config correctly (#2027) --- internal/agent/coordinator.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 60da01e08c668f641c11f79c36c29b5fc2186c78..604b961acfc403a8b577c6ef8175122272ae5083 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -243,7 +243,20 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy. return options } - switch providerCfg.Type { + providerType := providerCfg.Type + if providerType == "hyper" { + if strings.Contains(model.CatwalkCfg.ID, "claude") { + providerType = anthropic.Name + } else if strings.Contains(model.CatwalkCfg.ID, "gpt") { + providerType = openai.Name + } else if strings.Contains(model.CatwalkCfg.ID, "gemini") { + providerType = google.Name + } else { + providerType = openaicompat.Name + } + } + + switch providerType { case openai.Name, azure.Name: _, hasReasoningEffort := mergedOptions["reasoning_effort"] if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" { From 46068b8fbe8f1b2506a633cfb8e34965ad522e65 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 2 Feb 2026 08:52:20 -0300 Subject: [PATCH 52/75] fix(lsp): improve auto discovery (#2086) - ignore .git for autodiscovery - ignore LSPs with only .git as root marker Signed-off-by: Carlos Alexandro Becker --- internal/lsp/client.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 05ee570b9d5ad7a0d667b48084289bf0fe5d3dde..6c0059250c062c01ab3d541f4b0ca55ebf0b0cb6 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -591,12 +591,17 @@ func FilterMatching(dir string, servers map[string]*powernapconfig.ServerConfig) } normalized := make(map[string]serverPatterns, len(servers)) for name, server := range servers { - if len(server.RootMarkers) == 0 { - continue + var patterns []string + for _, p := range server.RootMarkers { + if p == ".git" { + // ignore .git for discovery + continue + } + patterns = append(patterns, filepath.ToSlash(p)) } - patterns := make([]string, len(server.RootMarkers)) - for i, p := range server.RootMarkers { - patterns[i] = filepath.ToSlash(p) + if len(patterns) == 0 { + slog.Debug("ignoring lsp with no root markers", "name", name) + continue } normalized[name] = serverPatterns{server: server, patterns: patterns} } From ad1db46fcf2b00b1354558952a8f86ebc1ad80ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:33:58 -0300 Subject: [PATCH 53/75] chore(deps): bump the all group with 2 updates (#2085) Bumps the all group with 2 updates: [github.com/clipperhouse/displaywidth](https://github.com/clipperhouse/displaywidth) and [github.com/clipperhouse/uax29/v2](https://github.com/clipperhouse/uax29). Updates `github.com/clipperhouse/displaywidth` from 0.7.0 to 0.9.0 - [Release notes](https://github.com/clipperhouse/displaywidth/releases) - [Changelog](https://github.com/clipperhouse/displaywidth/blob/main/CHANGELOG.md) - [Commits](https://github.com/clipperhouse/displaywidth/compare/v0.7.0...v0.9.0) Updates `github.com/clipperhouse/uax29/v2` from 2.3.1 to 2.5.0 - [Release notes](https://github.com/clipperhouse/uax29/releases) - [Commits](https://github.com/clipperhouse/uax29/compare/v2.3.1...v2.5.0) --- updated-dependencies: - dependency-name: github.com/clipperhouse/displaywidth dependency-version: 0.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: github.com/clipperhouse/uax29/v2 dependency-version: 2.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 2ed59fa188f17124c154c2aa60d1496be695b49c..976c2296e34572aa5b0fde98bdc4d13141fba764 100644 --- a/go.mod +++ b/go.mod @@ -33,8 +33,8 @@ require ( github.com/charmbracelet/x/exp/strings v0.1.0 github.com/charmbracelet/x/powernap v0.0.0-20260127155452-b72a9a918687 github.com/charmbracelet/x/term v0.2.2 - github.com/clipperhouse/displaywidth v0.7.0 - github.com/clipperhouse/uax29/v2 v2.3.1 + github.com/clipperhouse/displaywidth v0.9.0 + github.com/clipperhouse/uax29/v2 v2.5.0 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/disintegration/imaging v1.6.2 diff --git a/go.sum b/go.sum index 661ba0de7ae7187dca0ed4aa690d853e7145305d..e240bc22af39306fdb76612c85d53808847ae6e3 100644 --- a/go.sum +++ b/go.sum @@ -130,12 +130,12 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= -github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw= -github.com/clipperhouse/uax29/v2 v2.3.1/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= From 7d9405f405529af914acbf2815f6e243d86b762c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:34:22 -0300 Subject: [PATCH 54/75] chore(deps): bump the all group with 2 updates (#2084) Bumps the all group with 2 updates: [github/codeql-action](https://github.com/github/codeql-action) and [anchore/scan-action](https://github.com/anchore/scan-action). Updates `github/codeql-action` from 4.31.11 to 4.32.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/19b2f06db2b6f5108140aeb04014ef02b648f789...b20883b0cd1f46c72ae0ba6d1090936928f9fa30) Updates `anchore/scan-action` from 7.3.0 to 7.3.1 - [Release notes](https://github.com/anchore/scan-action/releases) - [Changelog](https://github.com/anchore/scan-action/blob/main/RELEASE.md) - [Commits](https://github.com/anchore/scan-action/compare/0d444ed77d83ee2ba7f5ced0d90d640a1281d762...8d2fce09422cd6037e577f4130e9b925e9a37175) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.32.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all - dependency-name: anchore/scan-action dependency-version: 7.3.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/security.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b90761035ecf5d9292c35b567c5b4a3d36efa1b9..7291604a5f34c4e1565d5c1a454860c6d25892da 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -30,11 +30,11 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: github/codeql-action/init@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 + - uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 - - uses: github/codeql-action/analyze@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 + - uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 + - uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 grype: runs-on: ubuntu-latest @@ -46,13 +46,13 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0 + - uses: anchore/scan-action@8d2fce09422cd6037e577f4130e9b925e9a37175 # v7.3.1 id: scan with: path: "." fail-build: true severity-cutoff: critical - - uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 + - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 with: sarif_file: ${{ steps.scan.outputs.sarif }} @@ -73,7 +73,7 @@ jobs: - name: Run govulncheck run: | govulncheck -C . -format sarif ./... > results.sarif - - uses: github/codeql-action/upload-sarif@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11 + - uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0 with: sarif_file: results.sarif From 552fa171bc6c0ed03b0121d6e98adb11697e8479 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 2 Feb 2026 09:51:56 -0300 Subject: [PATCH 55/75] fix: ensure the commands and models dialogs render with borders (#2068) --- internal/ui/dialog/commands.go | 4 ++-- internal/ui/dialog/models.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 416f5a0131e2dc7cf36561f118daed248ceebd08..0b0185b03a3c992ce55ff9164ceba6115260c174 100644 --- a/internal/ui/dialog/commands.go +++ b/internal/ui/dialog/commands.go @@ -240,8 +240,8 @@ func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds boo // Draw implements [Dialog]. func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := c.com.Styles - width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx())) - height := max(0, min(defaultCommandsDialogMaxHeight, area.Dy())) + width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize())) + height := max(0, min(defaultCommandsDialogMaxHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize())) if area.Dx() != c.windowWidth && c.selected == SystemCommands { c.windowWidth = area.Dx() // since some items in the list depend on width (e.g. toggle sidebar command), diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 354d02434a6623b5a9833bc010f4eaa8d1efdc7a..44ff42a23c5eb722e4baa764346f631292799b30 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -251,8 +251,8 @@ func (m *Models) modelTypeRadioView() string { // Draw implements [Dialog]. func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { t := m.com.Styles - width := max(0, min(defaultModelsDialogMaxWidth, area.Dx())) - height := max(0, min(defaultDialogHeight, area.Dy())) + width := max(0, min(defaultModelsDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize())) + height := max(0, min(defaultDialogHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize())) innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight + t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight + From 47f5f429313ad1ab8694daa22611272df3397ce8 Mon Sep 17 00:00:00 2001 From: BitToby Date: Mon, 2 Feb 2026 14:55:41 +0200 Subject: [PATCH 56/75] fix: ensure all tools work when behind a http proxy (#2065) Replace custom `http.Transport` with cloned `DefaultTransport` to inherit proxy configuration from environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`). Affects `fetch`, `web_fetch`, `web_search`, `sourcegraph`, `download`, and `agentic_fetch` tools. Fixes enterprise environment compatibility where proxy configuration is required for external HTTP requests. --- internal/agent/agentic_fetch_tool.go | 13 +++++++------ internal/agent/tools/download.go | 13 +++++++------ internal/agent/tools/fetch.go | 13 +++++++------ internal/agent/tools/sourcegraph.go | 13 +++++++------ internal/agent/tools/web_fetch.go | 13 +++++++------ internal/agent/tools/web_search.go | 13 +++++++------ 6 files changed, 42 insertions(+), 36 deletions(-) diff --git a/internal/agent/agentic_fetch_tool.go b/internal/agent/agentic_fetch_tool.go index 08da0e870187f537c9c88ac6a2b6ada97ff6fc88..9bf592413b07c651171d10785104294da8fb39a3 100644 --- a/internal/agent/agentic_fetch_tool.go +++ b/internal/agent/agentic_fetch_tool.go @@ -52,13 +52,14 @@ var agenticFetchPromptTmpl []byte func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) { if client == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 100 + transport.MaxIdleConnsPerHost = 10 + transport.IdleConnTimeout = 90 * time.Second + client = &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - }, + Timeout: 30 * time.Second, + Transport: transport, } } diff --git a/internal/agent/tools/download.go b/internal/agent/tools/download.go index 8f3f224b9e5647911d3c7e1cc5a668eea18b1785..def4968cababe0ffabbd88d929a692394bb86b36 100644 --- a/internal/agent/tools/download.go +++ b/internal/agent/tools/download.go @@ -36,13 +36,14 @@ var downloadDescription []byte func NewDownloadTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool { if client == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 100 + transport.MaxIdleConnsPerHost = 10 + transport.IdleConnTimeout = 90 * time.Second + client = &http.Client{ - Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - }, + Timeout: 5 * time.Minute, // Default 5 minute timeout for downloads + Transport: transport, } } return fantasy.NewParallelAgentTool( diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go index fdb63f057958e5e5a67affe0783a452c27febf41..0129fc3a46d264007649be088d843c0ebbf76149 100644 --- a/internal/agent/tools/fetch.go +++ b/internal/agent/tools/fetch.go @@ -23,13 +23,14 @@ var fetchDescription []byte func NewFetchTool(permissions permission.Service, workingDir string, client *http.Client) fantasy.AgentTool { if client == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 100 + transport.MaxIdleConnsPerHost = 10 + transport.IdleConnTimeout = 90 * time.Second + client = &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - }, + Timeout: 30 * time.Second, + Transport: transport, } } diff --git a/internal/agent/tools/sourcegraph.go b/internal/agent/tools/sourcegraph.go index 3cb22652a74554e036a0aaaa7a54b457955cbe2e..72ecf2d6edb924594bc0c8700d88b6d8db256b50 100644 --- a/internal/agent/tools/sourcegraph.go +++ b/internal/agent/tools/sourcegraph.go @@ -33,13 +33,14 @@ var sourcegraphDescription []byte func NewSourcegraphTool(client *http.Client) fantasy.AgentTool { if client == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 100 + transport.MaxIdleConnsPerHost = 10 + transport.IdleConnTimeout = 90 * time.Second + client = &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - }, + Timeout: 30 * time.Second, + Transport: transport, } } return fantasy.NewParallelAgentTool( diff --git a/internal/agent/tools/web_fetch.go b/internal/agent/tools/web_fetch.go index 8dc5376861db26ab2a11bac07775a654711c556b..91c326a7b8671d4cdff9b7b04329371075c5dc94 100644 --- a/internal/agent/tools/web_fetch.go +++ b/internal/agent/tools/web_fetch.go @@ -18,13 +18,14 @@ var webFetchToolDescription []byte // NewWebFetchTool creates a simple web fetch tool for sub-agents (no permissions needed). func NewWebFetchTool(workingDir string, client *http.Client) fantasy.AgentTool { if client == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 100 + transport.MaxIdleConnsPerHost = 10 + transport.IdleConnTimeout = 90 * time.Second + client = &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - }, + Timeout: 30 * time.Second, + Transport: transport, } } diff --git a/internal/agent/tools/web_search.go b/internal/agent/tools/web_search.go index 5ce9280c013cdd100f6d7734c969723b21e7e3bf..e441aeebad9d699bb1fa33330b2d70559ae868ff 100644 --- a/internal/agent/tools/web_search.go +++ b/internal/agent/tools/web_search.go @@ -16,13 +16,14 @@ var webSearchToolDescription []byte // NewWebSearchTool creates a web search tool for sub-agents (no permissions needed). func NewWebSearchTool(client *http.Client) fantasy.AgentTool { if client == nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 100 + transport.MaxIdleConnsPerHost = 10 + transport.IdleConnTimeout = 90 * time.Second + client = &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - }, + Timeout: 30 * time.Second, + Transport: transport, } } From 00bbf45427e278fc8e19e4b6fb4dd03af535ddd5 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 2 Feb 2026 15:24:16 +0100 Subject: [PATCH 57/75] feat: add support for vercel provider (#2090) --- go.mod | 31 ++++++++-------- go.sum | 66 ++++++++++++++++++----------------- internal/agent/agent.go | 4 +++ internal/agent/coordinator.go | 29 +++++++++++++++ 4 files changed, 83 insertions(+), 47 deletions(-) diff --git a/go.mod b/go.mod index 976c2296e34572aa5b0fde98bdc4d13141fba764..2358911b7f6c3633b82b14e589c5db14c02d15d6 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.25.5 require ( charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e - charm.land/catwalk v0.16.0 - charm.land/fantasy v0.6.1 + charm.land/catwalk v0.16.1 + charm.land/fantasy v0.7.0 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da @@ -78,12 +78,12 @@ require ( require ( cloud.google.com/go v0.116.0 // indirect - cloud.google.com/go/auth v0.18.0 // indirect + cloud.google.com/go/auth v0.18.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect - github.com/RealAlexandreAI/json-repair v0.0.14 // indirect + github.com/RealAlexandreAI/json-repair v0.0.15 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect @@ -104,6 +104,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 // indirect github.com/charmbracelet/x/json v0.2.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -119,9 +120,9 @@ require ( github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.19.0 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.3.0 // indirect @@ -132,10 +133,10 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kaptinlin/go-i18n v0.2.2 // indirect - github.com/kaptinlin/jsonpointer v0.4.8 // indirect - github.com/kaptinlin/jsonschema v0.6.6 // indirect - github.com/kaptinlin/messageformat-go v0.4.7 // indirect + github.com/kaptinlin/go-i18n v0.2.3 // indirect + github.com/kaptinlin/jsonpointer v0.4.9 // indirect + github.com/kaptinlin/jsonschema v0.6.9 // indirect + github.com/kaptinlin/messageformat-go v0.4.9 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/pgzip v1.2.6 // indirect @@ -168,12 +169,12 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/crypto v0.47.0 // indirect @@ -184,7 +185,7 @@ require ( golang.org/x/term v0.39.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.239.0 // indirect - google.golang.org/genai v1.41.0 // indirect + google.golang.org/genai v1.44.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/go.sum b/go.sum index e240bc22af39306fdb76612c85d53808847ae6e3..91d0707fd0a5d50c4d64a8c68b606747b743f4c0 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,10 @@ charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e h1:tXwTmgGpwZT7ParKF5xbEQBVjM2e1uKhKi/GpfU3mYQ= charm.land/bubbletea/v2 v2.0.0-rc.2.0.20251216153312-819e2e89c62e/go.mod h1:pDM18flq3Z4njKZPA3zCvyVSSIJbMcoqlE82BdGUtL8= -charm.land/catwalk v0.16.0 h1:NP6lPz086OAsFdyYTRE6x1CyAosX6MpqdY303ntwsX0= -charm.land/catwalk v0.16.0/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64= -charm.land/fantasy v0.6.1 h1:v3pavSHpZ5xTw98TpNYoj6DRq4ksCBWwJiZeiG/mVIc= -charm.land/fantasy v0.6.1/go.mod h1:Ifj41bNnIXJ1aF6sLKcS9y3MzWbDnObmcHrCaaHfpZ0= +charm.land/catwalk v0.16.1 h1:4Z4uCxqdAaVHeSX5dDDOkOg8sm7krFqJSaNBMZhE7Ao= +charm.land/catwalk v0.16.1/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64= +charm.land/fantasy v0.7.0 h1:qsSKJF07B+mimpPaC61Zyu3N+A9l2Lbs6T3txlP5In8= +charm.land/fantasy v0.7.0/go.mod h1:zv8Utaob4b9rSPp2ruH515rx7oN+l66gv6RshvwHnww= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0= charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251205162909-7869489d8971 h1:xZFcNsJMiIDbFtWRyDmkKNk1sjojfaom4Zoe0cyH/8c= @@ -16,8 +16,8 @@ charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q= charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM= cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= -cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -37,8 +37,8 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= -github.com/RealAlexandreAI/json-repair v0.0.14 h1:4kTqotVonDVTio5n2yweRUELVcNe2x518wl0bCsw0t0= -github.com/RealAlexandreAI/json-repair v0.0.14/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI= +github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs= +github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= @@ -94,6 +94,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6 github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICgnWlhAyg= github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw= @@ -179,12 +181,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= -github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -224,14 +226,14 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4= github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kaptinlin/go-i18n v0.2.2 h1:kebVCZme/BrCTqonh/J+VYCl1+Of5C18bvyn3DRPl5M= -github.com/kaptinlin/go-i18n v0.2.2/go.mod h1:MiwkeHryBopAhC/M3zEwIM/2IN8TvTqJQswPw6kceqM= -github.com/kaptinlin/jsonpointer v0.4.8 h1:HocHcXrOBfP/nUJw0YYjed/TlQvuCAY6uRs3Qok7F6g= -github.com/kaptinlin/jsonpointer v0.4.8/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA= -github.com/kaptinlin/jsonschema v0.6.6 h1:UmIF1amA5ijCGSk4tl4ViNlgYL4jzHHvY+Nd5cnkfDI= -github.com/kaptinlin/jsonschema v0.6.6/go.mod h1:EbhSbdxZ4QjzIORdMWOrRXJeCHrLTJqXDA8JzNaeFc8= -github.com/kaptinlin/messageformat-go v0.4.7 h1:HQ/OvFUSU7+fAHWkZnP2ug9y+A/ZyTE8j33jfWr8O3Q= -github.com/kaptinlin/messageformat-go v0.4.7/go.mod h1:DusKpv8CIybczGvwIVn3j13hbR3psr5mOwhFudkiq1c= +github.com/kaptinlin/go-i18n v0.2.3 h1:jyN/YOXXLcnGRBLdU+a8+6782B97fWE5aQqAHtvvk8Q= +github.com/kaptinlin/go-i18n v0.2.3/go.mod h1:O+Ax4HkMO0Jt4OaP4E4WCx0PAADeWkwk8Jgt9bjAU1w= +github.com/kaptinlin/jsonpointer v0.4.9 h1:o//bYf4PCvnMJIIX8bIg77KB6DO3wBPAabRyPRKh680= +github.com/kaptinlin/jsonpointer v0.4.9/go.mod h1:9y0LgXavlmVE5FSHShY5LRlURJJVhbyVJSRWkilrTqA= +github.com/kaptinlin/jsonschema v0.6.9 h1:N6bwMCadb0fA9CYINqQbtPhacIIjXmAjuYnJaWeI1bg= +github.com/kaptinlin/jsonschema v0.6.9/go.mod h1:ZXZ4K5KrRmCCF1i6dgvBsQifl+WTb8XShKj0NpQNrz8= +github.com/kaptinlin/messageformat-go v0.4.9 h1:FR5j5n4aL4nG0afKn9vvANrKxLu7HjmbhJnw5ogIwAQ= +github.com/kaptinlin/messageformat-go v0.4.9/go.mod h1:qZzrGrlvWDz2KyyvN3dOWcK9PVSRV1BnfnNU+zB/RWc= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= @@ -371,22 +373,22 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -494,8 +496,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo= google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= -google.golang.org/genai v1.41.0 h1:ayXl75LjTmqTu0y94yr96d17gIb4zF8gWVzX2TgioEY= -google.golang.org/genai v1.41.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genai v1.44.0 h1:+nn8oXANzrpHsWxGfZz2IySq0cFPiepqFvgMFofK8vw= +google.golang.org/genai v1.44.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 20ca25f89421b8f1fd2927b1162c412d56becdc4..7ccd503ad1f0dce0d922c35df4f91873523ecd9c 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -29,6 +29,7 @@ import ( "charm.land/fantasy/providers/google" "charm.land/fantasy/providers/openai" "charm.land/fantasy/providers/openrouter" + "charm.land/fantasy/providers/vercel" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/agent/tools" @@ -681,6 +682,9 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions { bedrock.Name: &anthropic.ProviderCacheControlOptions{ CacheControl: anthropic.CacheControl{Type: "ephemeral"}, }, + vercel.Name: &anthropic.ProviderCacheControlOptions{ + CacheControl: anthropic.CacheControl{Type: "ephemeral"}, + }, } } diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 604b961acfc403a8b577c6ef8175122272ae5083..780327089b3390d088e858fe26c5eec205aedf1e 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -39,6 +39,7 @@ import ( "charm.land/fantasy/providers/openai" "charm.land/fantasy/providers/openaicompat" "charm.land/fantasy/providers/openrouter" + "charm.land/fantasy/providers/vercel" openaisdk "github.com/openai/openai-go/v2/option" "github.com/qjebbs/go-jsons" ) @@ -302,6 +303,18 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy. if err == nil { options[openrouter.Name] = parsed } + case vercel.Name: + _, hasReasoning := mergedOptions["reasoning"] + if !hasReasoning && model.ModelCfg.ReasoningEffort != "" { + mergedOptions["reasoning"] = map[string]any{ + "enabled": true, + "effort": model.ModelCfg.ReasoningEffort, + } + } + parsed, err := vercel.ParseOptions(mergedOptions) + if err == nil { + options[vercel.Name] = parsed + } case google.Name: _, hasReasoning := mergedOptions["thinking_config"] if !hasReasoning { @@ -604,6 +617,20 @@ func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[stri return openrouter.New(opts...) } +func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) { + opts := []vercel.Option{ + vercel.WithAPIKey(apiKey), + } + if c.cfg.Options.Debug { + httpClient := log.NewHTTPClient() + opts = append(opts, vercel.WithHTTPClient(httpClient)) + } + if len(headers) > 0 { + opts = append(opts, vercel.WithHeaders(headers)) + } + return vercel.New(opts...) +} + func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) { opts := []openaicompat.Option{ openaicompat.WithBaseURL(baseURL), @@ -761,6 +788,8 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con return c.buildAnthropicProvider(baseURL, apiKey, headers) case openrouter.Name: return c.buildOpenrouterProvider(baseURL, apiKey, headers) + case vercel.Name: + return c.buildVercelProvider(baseURL, apiKey, headers) case azure.Name: return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams) case bedrock.Name: From 4a03cbaf3cf5e96cb5f6ac849817de025558e642 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 2 Feb 2026 11:30:51 -0300 Subject: [PATCH 58/75] fix(lsp): improve lsp tools (#2089) With auto discovery, the user configured lsps might be empty, but we might still configure some LSPs. We need to check the proper places, as well as refresh the tool list if LSPs are actually started. This is an alternative implementation to #2079 Signed-off-by: Carlos Alexandro Becker --- internal/agent/coordinator.go | 2 +- internal/agent/tools/mcp/init.go | 1 + internal/app/app.go | 5 +---- internal/app/lsp.go | 20 +++++++++++++++----- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 780327089b3390d088e858fe26c5eec205aedf1e..09313f363d5d692971801354e0f5d609a20015ca 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -435,7 +435,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan tools.NewWriteTool(c.lspClients, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()), ) - if len(c.cfg.LSP) > 0 { + if c.lspClients.Len() > 0 { allTools = append(allTools, tools.NewDiagnosticsTool(c.lspClients), tools.NewReferencesTool(c.lspClients), tools.NewLSPRestartTool(c.lspClients)) } diff --git a/internal/agent/tools/mcp/init.go b/internal/agent/tools/mcp/init.go index 05ac2eaeba29c2ce4411c8acc355d645037a6f55..c37f238e6d915d265153518b6df27f07bb6e456e 100644 --- a/internal/agent/tools/mcp/init.go +++ b/internal/agent/tools/mcp/init.go @@ -135,6 +135,7 @@ func Close() error { // Initialize initializes MCP clients based on the provided configuration. func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Config) { + slog.Info("Initializing MCP clients") var wg sync.WaitGroup // Initialize states for all configured MCPs for name, m := range cfg.MCP { diff --git a/internal/app/app.go b/internal/app/app.go index c5294c2ae21f91486861a037b639cb1c00bd531f..219b66f3cb79abcb6f004d08a6dc07bd539198ec 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -109,10 +109,7 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { // Check for updates in the background. go app.checkForUpdates(ctx) - go func() { - slog.Info("Initializing MCP clients") - mcp.Initialize(ctx, app.Permissions, cfg) - }() + go mcp.Initialize(ctx, app.Permissions, cfg) // cleanup database upon app shutdown app.cleanupFuncs = append(app.cleanupFuncs, conn.Close, mcp.Close) diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 14f1c99587bf4bfe052f9ac2078cdf03d859cfa1..21709bc44128bdda7e93230ab7885d3a96e9f21e 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -5,6 +5,7 @@ import ( "log/slog" "os/exec" "slices" + "sync" "time" "github.com/charmbracelet/crush/internal/config" @@ -58,16 +59,25 @@ func (app *App) initLSPClients(ctx context.Context) { updateLSPState(name, lsp.StateDisabled, nil, nil, 0) } } + + var wg sync.WaitGroup for name, server := range filtered { if app.config.Options.AutoLSP != nil && !*app.config.Options.AutoLSP && !slices.Contains(userConfiguredLSPs, name) { slog.Debug("Ignoring non user-define LSP client due to AutoLSP being disabled", "name", name) continue } - go app.createAndStartLSPClient( - ctx, name, - toOurConfig(server), - slices.Contains(userConfiguredLSPs, name), - ) + wg.Go(func() { + app.createAndStartLSPClient( + ctx, name, + toOurConfig(server), + slices.Contains(userConfiguredLSPs, name), + ) + }) + } + wg.Wait() + + if err := app.AgentCoordinator.UpdateModels(ctx); err != nil { + slog.Error("Failed to refresh tools after LSP startup", "error", err) } } From b7e07a59275cb0e03e80813c44bd8eccb21de67c Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 2 Feb 2026 13:30:15 -0300 Subject: [PATCH 60/75] fix: address potential panic on initialization (#2092) Easily reproducible for a new setup (onboarding). --- internal/app/lsp.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/app/lsp.go b/internal/app/lsp.go index 21709bc44128bdda7e93230ab7885d3a96e9f21e..fb95b7747ff5be1a1c4b56e01befc2b3c5edd70c 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -76,8 +76,10 @@ func (app *App) initLSPClients(ctx context.Context) { } wg.Wait() - if err := app.AgentCoordinator.UpdateModels(ctx); err != nil { - slog.Error("Failed to refresh tools after LSP startup", "error", err) + if app.AgentCoordinator != nil { + if err := app.AgentCoordinator.UpdateModels(ctx); err != nil { + slog.Error("Failed to refresh tools after LSP startup", "error", err) + } } } From 56769bb10c336332d0f950218da7e6c3b9bf967b Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 2 Feb 2026 16:22:58 -0300 Subject: [PATCH 62/75] fix(ui): fix permissions dialog rendering on small windows (#2093) * Ensure the viewport content is at least 3 cells tall, to always be able to render at least 1 line of content + 1 top and 1 bottom margin. * Render in fullscreen as soon as we don't have enough space to render buttons without wrapping. --- internal/ui/dialog/permissions.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/ui/dialog/permissions.go b/internal/ui/dialog/permissions.go index d877d7085afbe8920c96898ce029e059dfa59e46..daabc10b1aea0ee9db6c4e3608be62e7cfcbfd39 100644 --- a/internal/ui/dialog/permissions.go +++ b/internal/ui/dialog/permissions.go @@ -48,7 +48,7 @@ const ( // layoutSpacingLines is the number of empty lines used for layout spacing. layoutSpacingLines = 4 // minWindowWidth is the minimum window width before forcing fullscreen. - minWindowWidth = 60 + minWindowWidth = 77 // minWindowHeight is the minimum window height before forcing fullscreen. minWindowHeight = 20 ) @@ -392,6 +392,7 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { } else { availableHeight = maxHeight - fixedHeight } + availableHeight = max(availableHeight, 3) } else { availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight } From b7e814a36034afad1cc78e707c33bb4d43d0fbb7 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:27:20 -0300 Subject: [PATCH 63/75] chore(legal): @acmacalister has signed the CLA --- .github/cla-signatures.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json index 5c18e45ad2a8120191a89d58eb101f84303902b0..8a4c239de977e86e38dbaf5f6f87061b58b44d2f 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -1167,6 +1167,14 @@ "created_at": "2026-02-02T04:04:04Z", "repoId": 987670088, "pullRequestNo": 2081 + }, + { + "name": "acmacalister", + "id": 1024755, + "comment_id": 3837172797, + "created_at": "2026-02-02T19:27:08Z", + "repoId": 987670088, + "pullRequestNo": 2095 } ] } \ No newline at end of file From 0cbaacdbf0d60dab9d3f2cbc94f062663a50ea8a Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 3 Feb 2026 15:20:17 +0300 Subject: [PATCH 64/75] fix(ui): scroll to expanded item (#2088) Scroll the chat view to ensure that an expanded message item is fully visible after toggling its expanded state. This improves user experience by keeping the context of the expanded content in view. --- internal/ui/chat/messages.go | 4 +++- internal/ui/chat/tools.go | 11 +++++------ internal/ui/model/chat.go | 12 ++++++++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index b9f16adf3ad7d5b7097e639892b1c6a6f1c22042..0c5668a20d52c5975dc63cb37da8090e9aa0ca7f 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -38,7 +38,9 @@ type Animatable interface { // Expandable is an interface for items that can be expanded or collapsed. type Expandable interface { - ToggleExpanded() + // ToggleExpanded toggles the expanded state of the item. It returns + // whether the item is now expanded. + ToggleExpanded() bool } // KeyEventHandler is an interface for items that can handle key events. diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index 07c3d98e6f60d319df8eff3699a057ad562771b7..c53b36a86ad98c4f7e3ca30608cf2fd43e87cf26 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -157,6 +157,8 @@ type baseToolMessageItem struct { expandedContent bool } +var _ Expandable = (*baseToolMessageItem)(nil) + // newBaseToolMessageItem is the internal constructor for base tool message items. func newBaseToolMessageItem( sty *styles.Styles, @@ -398,18 +400,15 @@ func (t *baseToolMessageItem) SetSpinningFunc(fn SpinningFunc) { } // ToggleExpanded toggles the expanded state of the thinking box. -func (t *baseToolMessageItem) ToggleExpanded() { +func (t *baseToolMessageItem) ToggleExpanded() bool { t.expandedContent = !t.expandedContent t.clearCache() + return t.expandedContent } // HandleMouseClick implements MouseClickable. func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { - if btn != ansi.MouseLeft { - return false - } - t.ToggleExpanded() - return true + return btn == ansi.MouseLeft } // HandleKeyEvent implements KeyEventHandler. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 4abe68f27aa367b5ff81ebefc89633310f93e81d..723e97fb76c04d75922a5aec60d9afa970e41d97 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -438,6 +438,7 @@ func (m *Chat) MessageItem(id string) chat.MessageItem { func (m *Chat) ToggleExpandedSelectedItem() { if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok { expandable.ToggleExpanded() + m.list.ScrollToIndex(m.list.Selected()) } } @@ -538,8 +539,15 @@ func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool { } // Execute the click action (e.g., expansion). - if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok { - return clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y) + selectedItem := m.list.SelectedItem() + if clickable, ok := selectedItem.(list.MouseClickable); ok { + handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y) + // Toggle expansion if applicable. + if expandable, ok := selectedItem.(chat.Expandable); ok { + expandable.ToggleExpanded() + } + m.list.ScrollToIndex(m.list.Selected()) + return handled } return false From e6a4896481f0b979d3dff7be2449ddb7fc012b3a Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 3 Feb 2026 09:57:19 -0300 Subject: [PATCH 65/75] fix(ui): ensure `%d Queued` text is visible (#2096) --- internal/ui/model/pills.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/pills.go b/internal/ui/model/pills.go index 7662b10cc61c19b5333f7487747354341e35aa99..9199bc6deece64774343087bc596396b54272f4c 100644 --- a/internal/ui/model/pills.go +++ b/internal/ui/model/pills.go @@ -66,7 +66,8 @@ func queuePill(queue int, focused, panelFocused bool, t *styles.Styles) string { triangles = triangles[:queue] } - content := fmt.Sprintf("%s %d Queued", strings.Join(triangles, ""), queue) + text := t.Base.Render(fmt.Sprintf("%d Queued", queue)) + content := fmt.Sprintf("%s %s", strings.Join(triangles, ""), text) return pillStyle(focused, panelFocused, t).Render(content) } From ea8c39f4cf3baf8f816f82c4a6bdb796a8b3750c Mon Sep 17 00:00:00 2001 From: huaiyuWangh <34158348+huaiyuWangh@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:59:53 +0800 Subject: [PATCH 66/75] feat: add configurable timeout for LSP initialization (#2075) * feat: add configurable timeout for LSP initialization Add a timeout field to LSPConfig to allow users to customize the initialization timeout for LSP servers. This is particularly useful for slow-starting servers like kotlin-language-server that may require more than the default 30 seconds to initialize. Fixes #1865 * refactor: simplify timeout logic with cmp.Or Simplified the timeout handling by using Go's cmp.Or() function instead of manual conditional checks, reducing code from 5 lines to 1 line while maintaining the same functionality. --- internal/app/lsp.go | 10 +++++++--- internal/config/config.go | 1 + schema.json | 9 +++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/app/lsp.go b/internal/app/lsp.go index fb95b7747ff5be1a1c4b56e01befc2b3c5edd70c..a93fadbd1869f46bb153e19fa15428f74293b7fc 100644 --- a/internal/app/lsp.go +++ b/internal/app/lsp.go @@ -1,6 +1,7 @@ package app import ( + "cmp" "context" "log/slog" "os/exec" @@ -69,7 +70,7 @@ func (app *App) initLSPClients(ctx context.Context) { wg.Go(func() { app.createAndStartLSPClient( ctx, name, - toOurConfig(server), + toOurConfig(server, app.config.LSP[name]), slices.Contains(userConfiguredLSPs, name), ) }) @@ -83,7 +84,9 @@ func (app *App) initLSPClients(ctx context.Context) { } } -func toOurConfig(in *powernapconfig.ServerConfig) config.LSPConfig { +// toOurConfig merges powernap default config with user config. +// If user config is zero value, it means no user override exists. +func toOurConfig(in *powernapconfig.ServerConfig, user config.LSPConfig) config.LSPConfig { return config.LSPConfig{ Command: in.Command, Args: in.Args, @@ -92,6 +95,7 @@ func toOurConfig(in *powernapconfig.ServerConfig) config.LSPConfig { RootMarkers: in.RootMarkers, InitOptions: in.InitOptions, Options: in.Settings, + Timeout: user.Timeout, } } @@ -126,7 +130,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, config lspClient.SetDiagnosticsCallback(updateLSPDiagnostics) // Increase initialization timeout as some servers take more time to start. - initCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + initCtx, cancel := context.WithTimeout(ctx, time.Duration(cmp.Or(config.Timeout, 30))*time.Second) defer cancel() // Initialize LSP client. diff --git a/internal/config/config.go b/internal/config/config.go index 19133928bd8f7e1da08b54024b4f80d41d01dc1a..0e475ee89654914722b829aaea4d1b7830618914 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -194,6 +194,7 @@ type LSPConfig struct { RootMarkers []string `json:"root_markers,omitempty" jsonschema:"description=Files or directories that indicate the project root,example=go.mod,example=package.json,example=Cargo.toml"` InitOptions map[string]any `json:"init_options,omitempty" jsonschema:"description=Initialization options passed to the LSP server during initialize request"` Options map[string]any `json:"options,omitempty" jsonschema:"description=LSP server-specific settings passed during initialization"` + Timeout int `json:"timeout,omitempty" jsonschema:"description=Timeout in seconds for LSP server initialization,default=30,example=60,example=120"` } type TUIOptions struct { diff --git a/schema.json b/schema.json index 7a32f612e64a20d0393f74471c1fbdb8863c2365..daf8dc6f29794446ace635b656099150c5b82901 100644 --- a/schema.json +++ b/schema.json @@ -156,6 +156,15 @@ "options": { "type": "object", "description": "LSP server-specific settings passed during initialization" + }, + "timeout": { + "type": "integer", + "description": "Timeout in seconds for LSP server initialization", + "default": 30, + "examples": [ + 60, + 120 + ] } }, "additionalProperties": false, From 0979bd3e2646765dbe4cdd01ca09a8ccb2fa4837 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 3 Feb 2026 08:54:42 -0500 Subject: [PATCH 67/75] fix(styles): increase text contrast in active session deletion item --- internal/ui/styles/styles.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 474a50c9934ce9363d640a4dd95a2a49ea57efc5..2989f6c9f9a13068e782d843cfd07f7519169509 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -1296,7 +1296,7 @@ func DefaultStyles() Styles { s.Dialog.Sessions.DeletingTitleGradientFromColor = red s.Dialog.Sessions.DeletingTitleGradientToColor = s.Primary s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle) - s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red) + s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red).Foreground(charmtone.Butter) s.Dialog.Sessions.UpdatingTitle = s.Dialog.Title.Foreground(charmtone.Zest) s.Dialog.Sessions.UpdatingView = s.Dialog.View.BorderForeground(charmtone.Zest) From d4b9b356f60a9a26fbc22c432b10d8ed9edfddd3 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 3 Feb 2026 09:14:32 -0500 Subject: [PATCH 68/75] chore(style): add specific style for session rename placeholder --- internal/ui/dialog/sessions_item.go | 2 +- internal/ui/styles/styles.go | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index f4e7f061a83ec171940c02832d3b1bfe4d5b7ef7..5d100586ac518b98be377afe4a1558b59ce0c569 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -193,7 +193,7 @@ func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Sessi item.updateTitleInput.SetVirtualCursor(false) item.updateTitleInput.Prompt = "" inputStyle := t.TextInput - inputStyle.Focused.Placeholder = inputStyle.Focused.Placeholder.Foreground(t.FgHalfMuted) + inputStyle.Focused.Placeholder = t.Dialog.Sessions.UpdatingPlaceholder item.updateTitleInput.SetStyles(inputStyle) item.updateTitleInput.Focus() } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 2989f6c9f9a13068e782d843cfd07f7519169509..6525d044af7f60da37f53e220a8c5fb8288bd369 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -387,6 +387,7 @@ type Styles struct { UpdatingMessage lipgloss.Style UpdatingTitleGradientFromColor color.Color UpdatingTitleGradientToColor color.Color + UpdatingPlaceholder lipgloss.Style } } @@ -1305,6 +1306,7 @@ func DefaultStyles() Styles { s.Dialog.Sessions.UpdatingTitleGradientToColor = charmtone.Bok s.Dialog.Sessions.UpdatingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle) s.Dialog.Sessions.UpdatingItemFocused = s.Dialog.SelectedItem.UnsetBackground().UnsetForeground() + s.Dialog.Sessions.UpdatingPlaceholder = base.Foreground(charmtone.Squid) s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") From 7c5d6ca4c52359ddf4883f99764c7d131d268351 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 3 Feb 2026 09:47:43 -0500 Subject: [PATCH 69/75] chore(styles): make rename style definitions match UI language --- internal/ui/dialog/sessions.go | 14 ++++++------- internal/ui/dialog/sessions_item.go | 6 +++--- internal/ui/styles/styles.go | 32 ++++++++++++++--------------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/internal/ui/dialog/sessions.go b/internal/ui/dialog/sessions.go index 4f607ab0e23d43b58eac7784abc3fed658d4bcba..227e060e6c6483644b4ad18bef00153bd4f6ca5f 100644 --- a/internal/ui/dialog/sessions.go +++ b/internal/ui/dialog/sessions.go @@ -261,11 +261,11 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { rc.ViewStyle = t.Dialog.Sessions.DeletingView rc.AddPart(t.Dialog.Sessions.DeletingMessage.Render("Delete this session?")) case sessionsModeUpdating: - rc.TitleStyle = t.Dialog.Sessions.UpdatingTitle - rc.TitleGradientFromColor = t.Dialog.Sessions.UpdatingTitleGradientFromColor - rc.TitleGradientToColor = t.Dialog.Sessions.UpdatingTitleGradientToColor - rc.ViewStyle = t.Dialog.Sessions.UpdatingView - message := t.Dialog.Sessions.UpdatingMessage.Render("Rename this session?") + rc.TitleStyle = t.Dialog.Sessions.RenamingingTitle + rc.TitleGradientFromColor = t.Dialog.Sessions.RenamingTitleGradientFromColor + rc.TitleGradientToColor = t.Dialog.Sessions.RenamingTitleGradientToColor + rc.ViewStyle = t.Dialog.Sessions.RenamingView + message := t.Dialog.Sessions.RenamingingMessage.Render("Rename this session?") rc.AddPart(message) item := s.selectedSessionItem() if item == nil { @@ -279,8 +279,8 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { start, end := s.list.VisibleItemIndices() selectedIndex := s.list.Selected() - titleStyle := t.Dialog.Sessions.UpdatingTitle - dialogStyle := t.Dialog.Sessions.UpdatingView + titleStyle := t.Dialog.Sessions.RenamingingTitle + dialogStyle := t.Dialog.Sessions.RenamingView inputStyle := t.Dialog.InputPrompt // Adjust cursor position to account for dialog layout + message diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index 5d100586ac518b98be377afe4a1558b59ce0c569..2532e8c19a75ef061266afd42d688016ea0ab3c9 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -88,8 +88,8 @@ func (s *SessionItem) Render(width int) string { styles.ItemBlurred = s.t.Dialog.Sessions.DeletingItemBlurred styles.ItemFocused = s.t.Dialog.Sessions.DeletingItemFocused case sessionsModeUpdating: - styles.ItemBlurred = s.t.Dialog.Sessions.UpdatingItemBlurred - styles.ItemFocused = s.t.Dialog.Sessions.UpdatingItemFocused + styles.ItemBlurred = s.t.Dialog.Sessions.RenamingItemBlurred + styles.ItemFocused = s.t.Dialog.Sessions.RenamingingItemFocused if s.focused { inputWidth := width - styles.InfoTextFocused.GetHorizontalFrameSize() s.updateTitleInput.SetWidth(inputWidth) @@ -193,7 +193,7 @@ func sessionItems(t *styles.Styles, mode sessionsMode, sessions ...session.Sessi item.updateTitleInput.SetVirtualCursor(false) item.updateTitleInput.Prompt = "" inputStyle := t.TextInput - inputStyle.Focused.Placeholder = t.Dialog.Sessions.UpdatingPlaceholder + inputStyle.Focused.Placeholder = t.Dialog.Sessions.RenamingPlaceholder item.updateTitleInput.SetStyles(inputStyle) item.updateTitleInput.Focus() } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 6525d044af7f60da37f53e220a8c5fb8288bd369..b06039b5afd1a280fb54eade2fa547a6fcde3d44 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -380,14 +380,14 @@ type Styles struct { DeletingTitleGradientToColor color.Color // styles for when we are in update mode - UpdatingView lipgloss.Style - UpdatingItemFocused lipgloss.Style - UpdatingItemBlurred lipgloss.Style - UpdatingTitle lipgloss.Style - UpdatingMessage lipgloss.Style - UpdatingTitleGradientFromColor color.Color - UpdatingTitleGradientToColor color.Color - UpdatingPlaceholder lipgloss.Style + RenamingView lipgloss.Style + RenamingingItemFocused lipgloss.Style + RenamingItemBlurred lipgloss.Style + RenamingingTitle lipgloss.Style + RenamingingMessage lipgloss.Style + RenamingTitleGradientFromColor color.Color + RenamingTitleGradientToColor color.Color + RenamingPlaceholder lipgloss.Style } } @@ -1299,14 +1299,14 @@ func DefaultStyles() Styles { s.Dialog.Sessions.DeletingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle) s.Dialog.Sessions.DeletingItemFocused = s.Dialog.SelectedItem.Background(red).Foreground(charmtone.Butter) - s.Dialog.Sessions.UpdatingTitle = s.Dialog.Title.Foreground(charmtone.Zest) - s.Dialog.Sessions.UpdatingView = s.Dialog.View.BorderForeground(charmtone.Zest) - s.Dialog.Sessions.UpdatingMessage = s.Base.Padding(1) - s.Dialog.Sessions.UpdatingTitleGradientFromColor = charmtone.Zest - s.Dialog.Sessions.UpdatingTitleGradientToColor = charmtone.Bok - s.Dialog.Sessions.UpdatingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle) - s.Dialog.Sessions.UpdatingItemFocused = s.Dialog.SelectedItem.UnsetBackground().UnsetForeground() - s.Dialog.Sessions.UpdatingPlaceholder = base.Foreground(charmtone.Squid) + s.Dialog.Sessions.RenamingingTitle = s.Dialog.Title.Foreground(charmtone.Zest) + s.Dialog.Sessions.RenamingView = s.Dialog.View.BorderForeground(charmtone.Zest) + s.Dialog.Sessions.RenamingingMessage = s.Base.Padding(1) + s.Dialog.Sessions.RenamingTitleGradientFromColor = charmtone.Zest + s.Dialog.Sessions.RenamingTitleGradientToColor = charmtone.Bok + s.Dialog.Sessions.RenamingItemBlurred = s.Dialog.NormalItem.Foreground(fgSubtle) + s.Dialog.Sessions.RenamingingItemFocused = s.Dialog.SelectedItem.UnsetBackground().UnsetForeground() + s.Dialog.Sessions.RenamingPlaceholder = base.Foreground(charmtone.Squid) s.Status.Help = lipgloss.NewStyle().Padding(0, 1) s.Status.SuccessIndicator = base.Foreground(bgSubtle).Background(green).Padding(0, 1).Bold(true).SetString("OKAY!") From bf1c65ebfc5f2cf09967fec0281dc96aeacef2e0 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 3 Feb 2026 13:12:55 -0300 Subject: [PATCH 70/75] feat: release new ui refactor (#2105) --- internal/cmd/root.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b33303d1bbabb408988d50378ea2370896fb929b..727e4741dbfc607161e425c6b597ed7e28723a1b 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -93,8 +93,13 @@ crush -y // Set up the TUI. var env uv.Environ = os.Environ() + newUI := true + if v, err := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); err == nil { + newUI = v + } + var model tea.Model - if v, _ := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); v { + if newUI { slog.Info("New UI in control!") com := common.DefaultCommon(app) ui := ui.New(com) From d0ed2c508fedb1c67ea4bae1438729f15cdcd8c2 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 3 Feb 2026 14:50:56 -0300 Subject: [PATCH 71/75] feat(ui): transparent mode (#2087) optional transparent mode. this is enabled by default on apple terminal as it doesn't reset properly. refs #1140 refs #1137 Signed-off-by: Carlos Alexandro Becker --- internal/config/config.go | 1 + internal/config/load.go | 7 +++++++ internal/ui/model/ui.go | 12 ++++++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 0e475ee89654914722b829aaea4d1b7830618914..d5f3b8fb65b0d8d7f694fa3368d0263f4c3336a9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -204,6 +204,7 @@ type TUIOptions struct { // Completions Completions `json:"completions,omitzero" jsonschema:"description=Completions UI options"` + Transparent *bool `json:"transparent,omitempty" jsonschema:"description=Enable transparent background for the TUI interface,default=false"` } // Completions defines options for the completions UI. diff --git a/internal/config/load.go b/internal/config/load.go index 3ad4b909cb16cf5672dcadc9322a476854350632..a651f4846307ed9729ba8a10835e98aece486dbd 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -62,6 +62,11 @@ func Load(workingDir, dataDir string, debug bool) (*Config, error) { assignIfNil(&cfg.Options.TUI.Completions.MaxItems, items) } + if isAppleTerminal() { + slog.Warn("Detected Apple Terminal, enabling transparent mode") + assignIfNil(&cfg.Options.TUI.Transparent, true) + } + // Load known providers, this loads the config from catwalk providers, err := Providers(cfg) if err != nil { @@ -792,3 +797,5 @@ func GlobalSkillsDirs() []string { filepath.Join(configBase, "agents", "skills"), } } + +func isAppleTerminal() bool { return os.Getenv("TERM_PROGRAM") == "Apple_Terminal" } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 1e81a5625b909598668487b137fb80afce5754da..6231c82c514ee021e7e8f47272c8f606ec54ff09 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -127,6 +127,8 @@ type UI struct { height int layout layout + isTransparent bool + focus uiFocusState state uiState @@ -296,8 +298,12 @@ func New(com *common.Common) *UI { // set initial state ui.setState(desiredState, desiredFocus) + opts := com.Config().Options + // disable indeterminate progress bar - ui.progressBarEnabled = com.Config().Options.Progress == nil || *com.Config().Options.Progress + ui.progressBarEnabled = opts.Progress == nil || *opts.Progress + // enable transparent mode + ui.isTransparent = opts.TUI.Transparent != nil && *opts.TUI.Transparent return ui } @@ -1884,7 +1890,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { func (m *UI) View() tea.View { var v tea.View v.AltScreen = true - v.BackgroundColor = m.com.Styles.Background + if !m.isTransparent { + v.BackgroundColor = m.com.Styles.Background + } v.MouseMode = tea.MouseModeCellMotion v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir()) From 02ec6827295e060b6781f1ed3274dbf82e84ef47 Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:52:32 +0000 Subject: [PATCH 72/75] chore: auto-update files --- schema.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/schema.json b/schema.json index daf8dc6f29794446ace635b656099150c5b82901..c8d2482079f294b6499810c34c312f0e1729d929 100644 --- a/schema.json +++ b/schema.json @@ -650,6 +650,11 @@ "completions": { "$ref": "#/$defs/Completions", "description": "Completions UI options" + }, + "transparent": { + "type": "boolean", + "description": "Enable transparent background for the TUI interface", + "default": false } }, "additionalProperties": false, From 3c8be6926cda50f4129e358bf78af65e7b315d32 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 3 Feb 2026 16:06:24 -0300 Subject: [PATCH 73/75] fix: fix pasting files on some terminal emulators (#2106) * Check `WT_SESSION` instead of `GOOS` for Windows Terminal. * Be more strict on Windows Terminal: do not allow chars outside quotes (prevents false positives). * Some terminals just paste the literal paths (Rio as separate events, Kitty separated by a line break). If it contains valid path(s) for existing file(s), just use that. * Workaround Rio on Windows that adds NULL chars to the string. --- internal/fsext/paste.go | 36 +++++++++++++++++++++++++++--------- internal/fsext/paste_test.go | 12 ++++++------ internal/ui/model/ui.go | 5 ++++- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/internal/fsext/paste.go b/internal/fsext/paste.go index 7e89a6443e09a2c5831ce8a072945cf7d1c4fd95..4996473acf41355e391ba6e9bf2547abfbbea9cb 100644 --- a/internal/fsext/paste.go +++ b/internal/fsext/paste.go @@ -1,20 +1,36 @@ package fsext import ( - "runtime" + "os" "strings" ) -func PasteStringToPaths(s string) []string { - switch runtime.GOOS { - case "windows": - return windowsPasteStringToPaths(s) +func ParsePastedFiles(s string) []string { + s = strings.TrimSpace(s) + + // NOTE: Rio on Windows adds NULL chars for some reason. + s = strings.ReplaceAll(s, "\x00", "") + + switch { + case attemptStat(s): + return strings.Split(s, "\n") + case os.Getenv("WT_SESSION") != "": + return windowsTerminalParsePastedFiles(s) default: - return unixPasteStringToPaths(s) + return unixParsePastedFiles(s) + } +} + +func attemptStat(s string) bool { + for path := range strings.SplitSeq(s, "\n") { + if info, err := os.Stat(path); err != nil || info.IsDir() { + return false + } } + return true } -func windowsPasteStringToPaths(s string) []string { +func windowsTerminalParsePastedFiles(s string) []string { if strings.TrimSpace(s) == "" { return nil } @@ -42,8 +58,10 @@ func windowsPasteStringToPaths(s string) []string { } case inQuotes: current.WriteByte(ch) + case ch != ' ': + // Text outside quotes is not allowed + return nil } - // Skip characters outside quotes and spaces between quoted sections } // Add any remaining content if quotes were properly closed @@ -59,7 +77,7 @@ func windowsPasteStringToPaths(s string) []string { return paths } -func unixPasteStringToPaths(s string) []string { +func unixParsePastedFiles(s string) []string { if strings.TrimSpace(s) == "" { return nil } diff --git a/internal/fsext/paste_test.go b/internal/fsext/paste_test.go index 09f8ad4d5bebbc993193d38a7ebbb31778aba7f6..c1c4d4adfba0eca44586f55f2a23dd882038522e 100644 --- a/internal/fsext/paste_test.go +++ b/internal/fsext/paste_test.go @@ -6,8 +6,8 @@ import ( "github.com/stretchr/testify/require" ) -func TestPasteStringToPaths(t *testing.T) { - t.Run("Windows", func(t *testing.T) { +func TestParsePastedFiles(t *testing.T) { + t.Run("WindowsTerminal", func(t *testing.T) { tests := []struct { name string input string @@ -24,7 +24,7 @@ func TestPasteStringToPaths(t *testing.T) { expected: []string{`C:\path\my-screenshot-one.png`, `C:\path\my-screenshot-two.png`, `C:\path\my-screenshot-three.png`}, }, { - name: "sigle with spaces", + name: "single with spaces", input: `"C:\path\my screenshot one.png"`, expected: []string{`C:\path\my screenshot one.png`}, }, @@ -46,7 +46,7 @@ func TestPasteStringToPaths(t *testing.T) { { name: "text outside quotes", input: `"C:\path\file.png" some random text "C:\path\file2.png"`, - expected: []string{`C:\path\file.png`, `C:\path\file2.png`}, + expected: nil, }, { name: "multiple spaces between paths", @@ -66,7 +66,7 @@ func TestPasteStringToPaths(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := windowsPasteStringToPaths(tt.input) + result := windowsTerminalParsePastedFiles(tt.input) require.Equal(t, tt.expected, result) }) } @@ -141,7 +141,7 @@ func TestPasteStringToPaths(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := unixPasteStringToPaths(tt.input) + result := unixParsePastedFiles(tt.input) require.Equal(t, tt.expected, result) }) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 6231c82c514ee021e7e8f47272c8f606ec54ff09..806ce0bcdf4bf0217f759aa97d361b1e60a824b7 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2915,7 +2915,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd { // Attempt to parse pasted content as file paths. If possible to parse, // all files exist and are valid, add as attachments. // Otherwise, paste as text. - paths := fsext.PasteStringToPaths(msg.Content) + paths := fsext.ParsePastedFiles(msg.Content) allExistsAndValid := func() bool { for _, path := range paths { if _, err := os.Stat(path); os.IsNotExist(err) { @@ -2956,6 +2956,9 @@ func (m *UI) handleFilePathPaste(path string) tea.Cmd { if err != nil { return uiutil.ReportError(err) } + if fileInfo.IsDir() { + return uiutil.ReportWarn("Cannot attach a directory") + } if fileInfo.Size() > common.MaxAttachmentSize { return uiutil.ReportWarn("File is too big (>5mb)") } From fd35a87b4c1621b3b1338fd95930756f7990a4eb Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 3 Feb 2026 16:55:40 -0300 Subject: [PATCH 75/75] fix(ui): padding in the view (#2107) Signed-off-by: Carlos Alexandro Becker --- internal/ui/chat/tools.go | 10 ++++------ internal/ui/styles/styles.go | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index c53b36a86ad98c4f7e3ca30608cf2fd43e87cf26..f7702cc1fe516bb3dee7d57ce15fed050299019f 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -588,19 +588,17 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid numFmt := fmt.Sprintf("%%%dd", maxDigits) bodyWidth := width - toolBodyLeftPaddingTotal - codeWidth := bodyWidth - maxDigits - 4 // -4 for line number padding + codeWidth := bodyWidth - maxDigits var out []string for i, ln := range highlightedLines { lineNum := sty.Tool.ContentLineNumber.Render(fmt.Sprintf(numFmt, i+1+offset)) - if lipgloss.Width(ln) > codeWidth { - ln = ansi.Truncate(ln, codeWidth, "…") - } + // Truncate accounting for padding that will be added. + ln = ansi.Truncate(ln, codeWidth-sty.Tool.ContentCodeLine.GetHorizontalPadding(), "…") codeLine := sty.Tool.ContentCodeLine. Width(codeWidth). - PaddingLeft(2). Render(ln) out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine)) @@ -609,7 +607,7 @@ func toolOutputCodeContent(sty *styles.Styles, path, content string, offset, wid // Add truncation message if needed. if len(lines) > maxLines && !expanded { out = append(out, sty.Tool.ContentCodeTruncation. - Width(bodyWidth). + Width(width). Render(fmt.Sprintf(assistantMessageTruncateFormat, len(lines)-maxLines)), ) } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index b06039b5afd1a280fb54eade2fa547a6fcde3d44..45aa6dc998226469f800883fb4ff9452cb56481a 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -1115,7 +1115,7 @@ func DefaultStyles() Styles { // Content rendering - prepared styles that accept width parameter s.Tool.ContentLine = s.Muted.Background(bgBaseLighter) s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter) - s.Tool.ContentCodeLine = s.Base.Background(bgBase) + s.Tool.ContentCodeLine = s.Base.Background(bgBase).PaddingLeft(2) s.Tool.ContentCodeTruncation = s.Muted.Background(bgBase).PaddingLeft(2) s.Tool.ContentCodeBg = bgBase s.Tool.Body = base.PaddingLeft(2)