From f6e82a40baf297c757ca9ff7f0c8749f6192ca43 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 31 Oct 2025 10:40:14 -0300 Subject: [PATCH 01/11] fix: address panic due to possible `nil` map (#1348) --- internal/agent/coordinator.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index ffcccf94d1448094f0c76fac5bcd7307b74b97fe..7bf0167fa1cf0de0323e3227823bbc9c4ab99e5c 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -654,6 +654,9 @@ func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool { func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel) (fantasy.Provider, error) { headers := maps.Clone(providerCfg.ExtraHeaders) + if headers == nil { + headers = make(map[string]string) + } // handle special headers for anthropic if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) { From be68f48142105f339594e8ca34873a755bf83c6e Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 31 Oct 2025 16:30:48 +0100 Subject: [PATCH 02/11] List performance improvements (#1325) Co-authored-by: Raphael Amorim --- internal/tui/exp/list/filterable.go | 4 +- internal/tui/exp/list/filterable_group.go | 5 +- internal/tui/exp/list/filterable_test.go | 2 +- internal/tui/exp/list/grouped.go | 13 +- internal/tui/exp/list/list.go | 760 ++++++++++++++-------- internal/tui/exp/list/list_test.go | 64 +- 6 files changed, 533 insertions(+), 315 deletions(-) diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index b93f8cc3309f66fb957c40e0d6b25419ef51d4e7..6f57931bb4334fbbf27ad887852b696b3d9cbe95 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -102,7 +102,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption f.list = New(items, f.listOptions...).(*list[T]) f.updateKeyMaps() - f.items = slices.Collect(f.list.items.Seq()) + f.items = f.list.items if f.inputHidden { return f @@ -243,7 +243,7 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd { } } - f.selectedItem = "" + f.selectedItemIdx = -1 if query == "" || len(f.items) == 0 { return f.list.SetItems(f.items) } diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go index 15084cce28be5190367eba861a491231139af53f..572181d705882e9a3062fcc3aecd8cf043b0750a 100644 --- a/internal/tui/exp/list/filterable_group.go +++ b/internal/tui/exp/list/filterable_group.go @@ -2,7 +2,6 @@ package list import ( "regexp" - "slices" "sort" "strings" @@ -183,7 +182,7 @@ func (f *filterableGroupList[T]) inputHeight() int { func (f *filterableGroupList[T]) clearItemState() []tea.Cmd { var cmds []tea.Cmd - for _, item := range slices.Collect(f.items.Seq()) { + for _, item := range f.items { if i, ok := any(item).(layout.Focusable); ok { cmds = append(cmds, i.Blur()) } @@ -253,7 +252,7 @@ func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string func (f *filterableGroupList[T]) Filter(query string) tea.Cmd { cmds := f.clearItemState() - f.selectedItem = "" + f.selectedItemIdx = -1 if query == "" { return f.groupedList.SetGroups(f.groups) diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go index 13208d393ab1086a48b06ab6e8cfd8a72a849ace..ce61f2c0f4014c9d16c29675eff7ecbb060b2dfd 100644 --- a/internal/tui/exp/list/filterable_test.go +++ b/internal/tui/exp/list/filterable_test.go @@ -29,7 +29,7 @@ func TestFilterableList(t *testing.T) { cmd() } - assert.Equal(t, items[0].ID(), l.selectedItem) + assert.Equal(t, 0, l.selectedItemIdx) golden.RequireEqual(t, []byte(l.View())) }) } diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go index 43223602dbfbeaa0ae60d0368b95a4f455228f96..9e9eabd27e940688db3c8c58c4aa91db0ebcbf81 100644 --- a/internal/tui/exp/list/grouped.go +++ b/internal/tui/exp/list/grouped.go @@ -1,10 +1,7 @@ package list import ( - "slices" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/util" ) @@ -40,9 +37,9 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T keyMap: DefaultKeyMap(), focused: true, }, - items: csync.NewSlice[Item](), - indexMap: csync.NewMap[string, int](), - renderedItems: csync.NewMap[string, renderedItem](), + items: []Item{}, + indexMap: make(map[string]int), + renderedItems: make(map[string]renderedItem), } for _, opt := range opts { opt(list.confOptions) @@ -85,13 +82,13 @@ func (g *groupedList[T]) convertItems() { items = append(items, g) } } - g.items.SetSlice(items) + g.items = items } func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd { g.groups = groups g.convertItems() - return g.SetItems(slices.Collect(g.items.Seq())) + return g.SetItems(g.items) } func (g *groupedList[T]) Groups() []Group[T] { diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index ea04b0c7d640f7801ba320d26071e21eafbbd90c..240176ea6a3a0492d3c1bdff4f774b512dd1e738 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,13 +1,11 @@ package list import ( - "slices" "strings" "sync" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/styles" @@ -19,6 +17,25 @@ import ( "github.com/rivo/uniseg" ) +const maxGapSize = 100 + +var newlineBuffer = strings.Repeat("\n", maxGapSize) + +var ( + specialCharsMap map[string]struct{} + specialCharsOnce sync.Once +) + +func getSpecialCharsMap() map[string]struct{} { + specialCharsOnce.Do(func() { + specialCharsMap = make(map[string]struct{}, len(styles.SelectionIgnoreIcons)) + for _, icon := range styles.SelectionIgnoreIcons { + specialCharsMap[icon] = struct{}{} + } + }) + return specialCharsMap +} + type Item interface { util.Model layout.Sizeable @@ -35,7 +52,6 @@ type List[T Item] interface { layout.Sizeable layout.Focusable - // Just change state MoveUp(int) tea.Cmd MoveDown(int) tea.Cmd GoToTop() tea.Cmd @@ -69,11 +85,10 @@ const ( const ( ItemNotFound = -1 - ViewportDefaultScrollSize = 2 + ViewportDefaultScrollSize = 5 ) type renderedItem struct { - id string view string height int start int @@ -81,16 +96,16 @@ type renderedItem struct { } type confOptions struct { - width, height int - gap int - // if you are at the last item and go down it will wrap to the top - wrap bool - keyMap KeyMap - direction direction - selectedItem string - focused bool - resize bool - enableMouse bool + width, height int + gap int + wrap bool + keyMap KeyMap + direction direction + selectedItemIdx int // Index of selected item (-1 if none) + selectedItemID string // Temporary storage for WithSelectedItem (resolved in New()) + focused bool + resize bool + enableMouse bool } type list[T Item] struct { @@ -98,19 +113,24 @@ type list[T Item] struct { offset int - indexMap *csync.Map[string, int] - items *csync.Slice[T] + indexMap map[string]int + items []T + renderedItems map[string]renderedItem - renderedItems *csync.Map[string, renderedItem] + rendered string + renderedHeight int // cached height of rendered content + lineOffsets []int // cached byte offsets for each line (for fast slicing) - renderMu sync.Mutex - rendered string + cachedView string + cachedViewOffset int + cachedViewDirty bool - movingByItem bool - selectionStartCol int - selectionStartLine int - selectionEndCol int - selectionEndLine int + movingByItem bool + prevSelectedItemIdx int // Index of previously selected item (-1 if none) + selectionStartCol int + selectionStartLine int + selectionEndCol int + selectionEndLine int selectionActive bool } @@ -149,7 +169,7 @@ func WithDirectionBackward() ListOption { // WithSelectedItem sets the initially selected item in the list. func WithSelectedItem(id string) ListOption { return func(l *confOptions) { - l.selectedItem = id + l.selectedItemID = id // Will be resolved to index in New() } } @@ -186,17 +206,19 @@ func WithEnableMouse() ListOption { func New[T Item](items []T, opts ...ListOption) List[T] { list := &list[T]{ confOptions: &confOptions{ - direction: DirectionForward, - keyMap: DefaultKeyMap(), - focused: true, + direction: DirectionForward, + keyMap: DefaultKeyMap(), + focused: true, + selectedItemIdx: -1, }, - items: csync.NewSliceFrom(items), - indexMap: csync.NewMap[string, int](), - renderedItems: csync.NewMap[string, renderedItem](), - selectionStartCol: -1, - selectionStartLine: -1, - selectionEndLine: -1, - selectionEndCol: -1, + items: items, + indexMap: make(map[string]int, len(items)), + renderedItems: make(map[string]renderedItem), + prevSelectedItemIdx: -1, + selectionStartCol: -1, + selectionStartLine: -1, + selectionEndLine: -1, + selectionEndCol: -1, } for _, opt := range opts { opt(list.confOptions) @@ -206,8 +228,17 @@ func New[T Item](items []T, opts ...ListOption) List[T] { if i, ok := any(item).(Indexable); ok { i.SetIndex(inx) } - list.indexMap.Set(item.ID(), inx) + list.indexMap[item.ID()] = inx + } + + // Resolve selectedItemID to selectedItemIdx if specified + if list.selectedItemID != "" { + if idx, ok := list.indexMap[list.selectedItemID]; ok { + list.selectedItemIdx = idx + } + list.selectedItemID = "" // Clear temporary storage } + return list } @@ -225,10 +256,25 @@ func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) { } return l, nil case anim.StepMsg: + // Fast path: if no items, skip processing + if len(l.items) == 0 { + return l, nil + } + + // Fast path: check if ANY items are actually spinning before processing + if !l.hasSpinningItems() { + return l, nil + } + var cmds []tea.Cmd - for _, item := range slices.Collect(l.items.Seq()) { - if i, ok := any(item).(HasAnim); ok && i.Spinning() { - updated, cmd := i.Update(msg) + itemsLen := len(l.items) + for i := range itemsLen { + if i >= len(l.items) { + continue + } + item := l.items[i] + if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() { + updated, cmd := animItem.Update(msg) cmds = append(cmds, cmd) if u, ok := updated.(T); ok { cmds = append(cmds, l.UpdateItem(u.ID(), u)) @@ -288,8 +334,16 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd) return l, cmd } -// selectionView renders the highlighted selection in the view and returns it -// as a string. If textOnly is true, it won't render any styles. +func (l *list[T]) hasSpinningItems() bool { + for i := range l.items { + item := l.items[i] + if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() { + return true + } + } + return false +} + func (l *list[T]) selectionView(view string, textOnly bool) string { t := styles.CurrentTheme() area := uv.Rect(0, 0, l.width, l.height) @@ -302,10 +356,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string { } selArea = selArea.Canon() - specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons)) - for _, icon := range styles.SelectionIgnoreIcons { - specialChars[icon] = true - } + specialChars := getSpecialCharsMap() isNonWhitespace := func(r rune) bool { return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r' @@ -366,7 +417,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string { } char := rune(cellStr[0]) - isSpecial := specialChars[cellStr] + _, isSpecial := specialChars[cellStr] if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil { if bounds.start == -1 { @@ -409,7 +460,10 @@ func (l *list[T]) selectionView(view string, textOnly bool) string { } cellStr := cell.String() - if len(cellStr) > 0 && !specialChars[cellStr] { + if len(cellStr) > 0 { + if _, isSpecial := specialChars[cellStr]; isSpecial { + continue + } if textOnly { // Collect selected text without styles selectedText.WriteString(cell.String()) @@ -439,33 +493,40 @@ func (l *list[T]) selectionView(view string, textOnly bool) string { return scr.Render() } -// View implements List. func (l *list[T]) View() string { if l.height <= 0 || l.width <= 0 { return "" } + + if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" { + return l.cachedView + } + t := styles.CurrentTheme() - view := l.rendered - lines := strings.Split(view, "\n") start, end := l.viewPosition() viewStart := max(0, start) - viewEnd := min(len(lines), end+1) + viewEnd := end if viewStart > viewEnd { - viewStart = viewEnd + return "" } - lines = lines[viewStart:viewEnd] + + view := l.getLines(viewStart, viewEnd) if l.resize { - return strings.Join(lines, "\n") + return view } + view = t.S().Base. Height(l.height). Width(l.width). - Render(strings.Join(lines, "\n")) + Render(view) if !l.hasSelection() { + l.cachedView = view + l.cachedViewOffset = l.offset + l.cachedViewDirty = false return view } @@ -474,7 +535,7 @@ func (l *list[T]) View() string { func (l *list[T]) viewPosition() (int, int) { start, end := 0, 0 - renderedLines := lipgloss.Height(l.rendered) - 1 + renderedLines := l.renderedHeight - 1 if l.direction == DirectionForward { start = max(0, l.offset) end = min(l.offset+l.height-1, renderedLines) @@ -486,22 +547,114 @@ func (l *list[T]) viewPosition() (int, int) { return start, end } +func (l *list[T]) setRendered(rendered string) { + l.rendered = rendered + l.renderedHeight = lipgloss.Height(rendered) + l.cachedViewDirty = true // Mark view cache as dirty + + if len(rendered) > 0 { + l.lineOffsets = make([]int, 0, l.renderedHeight) + l.lineOffsets = append(l.lineOffsets, 0) + + offset := 0 + for { + idx := strings.IndexByte(rendered[offset:], '\n') + if idx == -1 { + break + } + offset += idx + 1 + l.lineOffsets = append(l.lineOffsets, offset) + } + } else { + l.lineOffsets = nil + } +} + +func (l *list[T]) getLines(start, end int) string { + if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) { + return "" + } + + if end >= len(l.lineOffsets) { + end = len(l.lineOffsets) - 1 + } + if start > end { + return "" + } + + startOffset := l.lineOffsets[start] + var endOffset int + if end+1 < len(l.lineOffsets) { + endOffset = l.lineOffsets[end+1] - 1 + } else { + endOffset = len(l.rendered) + } + + if startOffset >= len(l.rendered) { + return "" + } + endOffset = min(endOffset, len(l.rendered)) + + return l.rendered[startOffset:endOffset] +} + +// getLine returns a single line from the rendered content using lineOffsets. +// This avoids allocating a new string for each line like strings.Split does. +func (l *list[T]) getLine(index int) string { + if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) { + return "" + } + + startOffset := l.lineOffsets[index] + var endOffset int + if index+1 < len(l.lineOffsets) { + endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline + } else { + endOffset = len(l.rendered) + } + + if startOffset >= len(l.rendered) { + return "" + } + endOffset = min(endOffset, len(l.rendered)) + + return l.rendered[startOffset:endOffset] +} + +// lineCount returns the number of lines in the rendered content. +func (l *list[T]) lineCount() int { + return len(l.lineOffsets) +} + func (l *list[T]) recalculateItemPositions() { - currentContentHeight := 0 - for _, item := range slices.Collect(l.items.Seq()) { - rItem, ok := l.renderedItems.Get(item.ID()) + l.recalculateItemPositionsFrom(0) +} + +func (l *list[T]) recalculateItemPositionsFrom(startIdx int) { + var currentContentHeight int + + if startIdx > 0 && startIdx <= len(l.items) { + prevItem := l.items[startIdx-1] + if rItem, ok := l.renderedItems[prevItem.ID()]; ok { + currentContentHeight = rItem.end + 1 + l.gap + } + } + + for i := startIdx; i < len(l.items); i++ { + item := l.items[i] + rItem, ok := l.renderedItems[item.ID()] if !ok { continue } rItem.start = currentContentHeight rItem.end = currentContentHeight + rItem.height - 1 - l.renderedItems.Set(item.ID(), rItem) + l.renderedItems[item.ID()] = rItem currentContentHeight = rItem.end + 1 + l.gap } } func (l *list[T]) render() tea.Cmd { - if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 { + if l.width <= 0 || l.height <= 0 || len(l.items) == 0 { return nil } l.setDefaultSelected() @@ -512,51 +665,38 @@ func (l *list[T]) render() tea.Cmd { } else { focusChangeCmd = l.blurSelectedItem() } - // we are not rendering the first time if l.rendered != "" { - // rerender everything will mostly hit cache - l.renderMu.Lock() - l.rendered, _ = l.renderIterator(0, false, "") - l.renderMu.Unlock() + rendered, _ := l.renderIterator(0, false, "") + l.setRendered(rendered) if l.direction == DirectionBackward { l.recalculateItemPositions() } - // in the end scroll to the selected item if l.focused { l.scrollToSelection() } return focusChangeCmd } - l.renderMu.Lock() rendered, finishIndex := l.renderIterator(0, true, "") - l.rendered = rendered - l.renderMu.Unlock() - // recalculate for the initial items + l.setRendered(rendered) if l.direction == DirectionBackward { l.recalculateItemPositions() } - renderCmd := func() tea.Msg { - l.offset = 0 - // render the rest - l.renderMu.Lock() - l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered) - l.renderMu.Unlock() - // needed for backwards - if l.direction == DirectionBackward { - l.recalculateItemPositions() - } - // in the end scroll to the selected item - if l.focused { - l.scrollToSelection() - } - return nil + l.offset = 0 + rendered, _ = l.renderIterator(finishIndex, false, l.rendered) + l.setRendered(rendered) + if l.direction == DirectionBackward { + l.recalculateItemPositions() } - return tea.Batch(focusChangeCmd, renderCmd) + if l.focused { + l.scrollToSelection() + } + + return focusChangeCmd } func (l *list[T]) setDefaultSelected() { - if l.selectedItem == "" { + if l.selectedItemIdx < 0 { if l.direction == DirectionForward { l.selectFirstItem() } else { @@ -566,27 +706,29 @@ func (l *list[T]) setDefaultSelected() { } func (l *list[T]) scrollToSelection() { - rItem, ok := l.renderedItems.Get(l.selectedItem) + if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) { + l.selectedItemIdx = -1 + l.setDefaultSelected() + return + } + item := l.items[l.selectedItemIdx] + rItem, ok := l.renderedItems[item.ID()] if !ok { - l.selectedItem = "" + l.selectedItemIdx = -1 l.setDefaultSelected() return } start, end := l.viewPosition() - // item bigger or equal to the viewport do nothing if rItem.start <= start && rItem.end >= end { return } - // if we are moving by item we want to move the offset so that the - // whole item is visible not just portions of it if l.movingByItem { if rItem.start >= start && rItem.end <= end { return } defer func() { l.movingByItem = false }() } else { - // item already in view do nothing if rItem.start >= start && rItem.start <= end { return } @@ -599,14 +741,13 @@ func (l *list[T]) scrollToSelection() { if l.direction == DirectionForward { l.offset = rItem.start } else { - l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height)) + l.offset = max(0, l.renderedHeight-(rItem.start+l.height)) } return } - renderedLines := lipgloss.Height(l.rendered) - 1 + renderedLines := l.renderedHeight - 1 - // If item is above the viewport, make it the first item if rItem.start < start { if l.direction == DirectionForward { l.offset = rItem.start @@ -614,7 +755,6 @@ func (l *list[T]) scrollToSelection() { l.offset = max(0, renderedLines-rItem.start-l.height+1) } } else if rItem.end > end { - // If item is below the viewport, make it the last item if l.direction == DirectionForward { l.offset = max(0, rItem.end-l.height+1) } else { @@ -624,7 +764,11 @@ func (l *list[T]) scrollToSelection() { } func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { - rItem, ok := l.renderedItems.Get(l.selectedItem) + if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) { + return nil + } + item := l.items[l.selectedItemIdx] + rItem, ok := l.renderedItems[item.ID()] if !ok { return nil } @@ -643,64 +787,60 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { if itemMiddle < start { // select the first item in the viewport // the item is most likely an item coming after this item - inx, ok := l.indexMap.Get(rItem.id) - if !ok { - return nil - } + inx := l.selectedItemIdx for { inx = l.firstSelectableItemBelow(inx) if inx == ItemNotFound { return nil } - item, ok := l.items.Get(inx) - if !ok { + if inx < 0 || inx >= len(l.items) { continue } - renderedItem, ok := l.renderedItems.Get(item.ID()) + + item := l.items[inx] + renderedItem, ok := l.renderedItems[item.ID()] if !ok { continue } // If the item is bigger than the viewport, select it if renderedItem.start <= start && renderedItem.end >= end { - l.selectedItem = renderedItem.id + l.selectedItemIdx = inx return l.render() } // item is in the view if renderedItem.start >= start && renderedItem.start <= end { - l.selectedItem = renderedItem.id + l.selectedItemIdx = inx return l.render() } } } else if itemMiddle > end { // select the first item in the viewport // the item is most likely an item coming after this item - inx, ok := l.indexMap.Get(rItem.id) - if !ok { - return nil - } + inx := l.selectedItemIdx for { inx = l.firstSelectableItemAbove(inx) if inx == ItemNotFound { return nil } - item, ok := l.items.Get(inx) - if !ok { + if inx < 0 || inx >= len(l.items) { continue } - renderedItem, ok := l.renderedItems.Get(item.ID()) + + item := l.items[inx] + renderedItem, ok := l.renderedItems[item.ID()] if !ok { continue } // If the item is bigger than the viewport, select it if renderedItem.start <= start && renderedItem.end >= end { - l.selectedItem = renderedItem.id + l.selectedItemIdx = inx return l.render() } // item is in the view if renderedItem.end >= start && renderedItem.end <= end { - l.selectedItem = renderedItem.id + l.selectedItemIdx = inx return l.render() } } @@ -711,46 +851,42 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { func (l *list[T]) selectFirstItem() { inx := l.firstSelectableItemBelow(-1) if inx != ItemNotFound { - item, ok := l.items.Get(inx) - if ok { - l.selectedItem = item.ID() - } + l.selectedItemIdx = inx } } func (l *list[T]) selectLastItem() { - inx := l.firstSelectableItemAbove(l.items.Len()) + inx := l.firstSelectableItemAbove(len(l.items)) if inx != ItemNotFound { - item, ok := l.items.Get(inx) - if ok { - l.selectedItem = item.ID() - } + l.selectedItemIdx = inx } } func (l *list[T]) firstSelectableItemAbove(inx int) int { for i := inx - 1; i >= 0; i-- { - item, ok := l.items.Get(i) - if !ok { + if i < 0 || i >= len(l.items) { continue } + + item := l.items[i] if _, ok := any(item).(layout.Focusable); ok { return i } } if inx == 0 && l.wrap { - return l.firstSelectableItemAbove(l.items.Len()) + return l.firstSelectableItemAbove(len(l.items)) } return ItemNotFound } func (l *list[T]) firstSelectableItemBelow(inx int) int { - itemsLen := l.items.Len() + itemsLen := len(l.items) for i := inx + 1; i < itemsLen; i++ { - item, ok := l.items.Get(i) - if !ok { + if i < 0 || i >= len(l.items) { continue } + + item := l.items[i] if _, ok := any(item).(layout.Focusable); ok { return i } @@ -762,38 +898,52 @@ func (l *list[T]) firstSelectableItemBelow(inx int) int { } func (l *list[T]) focusSelectedItem() tea.Cmd { - if l.selectedItem == "" || !l.focused { + if l.selectedItemIdx < 0 || !l.focused { return nil } - var cmds []tea.Cmd - for _, item := range slices.Collect(l.items.Seq()) { - if f, ok := any(item).(layout.Focusable); ok { - if item.ID() == l.selectedItem && !f.IsFocused() { - cmds = append(cmds, f.Focus()) - l.renderedItems.Del(item.ID()) - } else if item.ID() != l.selectedItem && f.IsFocused() { - cmds = append(cmds, f.Blur()) - l.renderedItems.Del(item.ID()) - } + // Pre-allocate with expected capacity + cmds := make([]tea.Cmd, 0, 2) + + // Blur the previously selected item if it's different + if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) { + prevItem := l.items[l.prevSelectedItemIdx] + if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() { + cmds = append(cmds, f.Blur()) + // Mark cache as needing update, but don't delete yet + // This allows the render to potentially reuse it + delete(l.renderedItems, prevItem.ID()) } } + + // Focus the currently selected item + if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) { + item := l.items[l.selectedItemIdx] + if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() { + cmds = append(cmds, f.Focus()) + // Mark for re-render + delete(l.renderedItems, item.ID()) + } + } + + l.prevSelectedItemIdx = l.selectedItemIdx return tea.Batch(cmds...) } func (l *list[T]) blurSelectedItem() tea.Cmd { - if l.selectedItem == "" || l.focused { + if l.selectedItemIdx < 0 || l.focused { return nil } - var cmds []tea.Cmd - for _, item := range slices.Collect(l.items.Seq()) { - if f, ok := any(item).(layout.Focusable); ok { - if item.ID() == l.selectedItem && f.IsFocused() { - cmds = append(cmds, f.Blur()) - l.renderedItems.Del(item.ID()) - } + + // Blur the currently selected item + if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) { + item := l.items[l.selectedItemIdx] + if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() { + delete(l.renderedItems, item.ID()) + return f.Blur() } } - return tea.Batch(cmds...) + + return nil } // renderFragment holds updated rendered view fragments @@ -806,10 +956,15 @@ type renderFragment struct { // returns the last index and the rendered content so far // we pass the rendered content around and don't use l.rendered to prevent jumping of the content func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) { - var fragments []renderFragment + // Pre-allocate fragments with expected capacity + itemsLen := len(l.items) + expectedFragments := itemsLen - startInx + if limitHeight && l.height > 0 { + expectedFragments = min(expectedFragments, l.height) + } + fragments := make([]renderFragment, 0, expectedFragments) currentContentHeight := lipgloss.Height(rendered) - 1 - itemsLen := l.items.Len() finalIndex := itemsLen // first pass: accumulate all fragments to render until the height limit is @@ -826,19 +981,20 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string inx = (itemsLen - 1) - i } - item, ok := l.items.Get(inx) - if !ok { + if inx < 0 || inx >= len(l.items) { continue } + item := l.items[inx] + var rItem renderedItem - if cache, ok := l.renderedItems.Get(item.ID()); ok { + if cache, ok := l.renderedItems[item.ID()]; ok { rItem = cache } else { rItem = l.renderItem(item) rItem.start = currentContentHeight rItem.end = currentContentHeight + rItem.height - 1 - l.renderedItems.Set(item.ID(), rItem) + l.renderedItems[item.ID()] = rItem } gap := l.gap + 1 @@ -853,12 +1009,26 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string // second pass: build rendered string efficiently var b strings.Builder + + // Pre-size the builder to reduce allocations + estimatedSize := len(rendered) + for _, f := range fragments { + estimatedSize += len(f.view) + f.gap + } + b.Grow(estimatedSize) + if l.direction == DirectionForward { b.WriteString(rendered) - for _, f := range fragments { + for i := range fragments { + f := &fragments[i] b.WriteString(f.view) - for range f.gap { - b.WriteByte('\n') + // Optimized gap writing using pre-allocated buffer + if f.gap > 0 { + if f.gap <= maxGapSize { + b.WriteString(newlineBuffer[:f.gap]) + } else { + b.WriteString(strings.Repeat("\n", f.gap)) + } } } @@ -867,10 +1037,15 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string // iterate backwards as fragments are in reversed order for i := len(fragments) - 1; i >= 0; i-- { - f := fragments[i] + f := &fragments[i] b.WriteString(f.view) - for range f.gap { - b.WriteByte('\n') + // Optimized gap writing using pre-allocated buffer + if f.gap > 0 { + if f.gap <= maxGapSize { + b.WriteString(newlineBuffer[:f.gap]) + } else { + b.WriteString(strings.Repeat("\n", f.gap)) + } } } b.WriteString(rendered) @@ -881,7 +1056,6 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string func (l *list[T]) renderItem(item Item) renderedItem { view := item.View() return renderedItem{ - id: item.ID(), view: view, height: lipgloss.Height(view), } @@ -889,17 +1063,17 @@ func (l *list[T]) renderItem(item Item) renderedItem { // AppendItem implements List. func (l *list[T]) AppendItem(item T) tea.Cmd { - var cmds []tea.Cmd + // Pre-allocate with expected capacity + cmds := make([]tea.Cmd, 0, 4) cmd := item.Init() if cmd != nil { cmds = append(cmds, cmd) } - l.items.Append(item) - l.indexMap = csync.NewMap[string, int]() - for inx, item := range slices.Collect(l.items.Seq()) { - l.indexMap.Set(item.ID(), inx) - } + newIndex := len(l.items) + l.items = append(l.items, item) + l.indexMap[item.ID()] = newIndex + if l.width > 0 && l.height > 0 { cmd = item.SetSize(l.width, l.height) if cmd != nil { @@ -917,13 +1091,13 @@ func (l *list[T]) AppendItem(item T) tea.Cmd { cmds = append(cmds, cmd) } } else { - newItem, ok := l.renderedItems.Get(item.ID()) + newItem, ok := l.renderedItems[item.ID()] if ok { newLines := newItem.height - if l.items.Len() > 1 { + if len(l.items) > 1 { newLines += l.gap } - l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) + l.offset = min(l.renderedHeight-1, l.offset+newLines) } } } @@ -938,35 +1112,41 @@ func (l *list[T]) Blur() tea.Cmd { // DeleteItem implements List. func (l *list[T]) DeleteItem(id string) tea.Cmd { - inx, ok := l.indexMap.Get(id) + inx, ok := l.indexMap[id] if !ok { return nil } - l.items.Delete(inx) - l.renderedItems.Del(id) - for inx, item := range slices.Collect(l.items.Seq()) { - l.indexMap.Set(item.ID(), inx) + l.items = append(l.items[:inx], l.items[inx+1:]...) + delete(l.renderedItems, id) + delete(l.indexMap, id) + + // Only update indices for items after the deleted one + itemsLen := len(l.items) + for i := inx; i < itemsLen; i++ { + if i >= 0 && i < len(l.items) { + item := l.items[i] + l.indexMap[item.ID()] = i + } } - if l.selectedItem == id { + // Adjust selectedItemIdx if the deleted item was selected or before it + if l.selectedItemIdx == inx { + // Deleted item was selected, select the previous item if possible if inx > 0 { - item, ok := l.items.Get(inx - 1) - if ok { - l.selectedItem = item.ID() - } else { - l.selectedItem = "" - } + l.selectedItemIdx = inx - 1 } else { - l.selectedItem = "" + l.selectedItemIdx = -1 } + } else if l.selectedItemIdx > inx { + // Selected item is after the deleted one, shift index down + l.selectedItemIdx-- } cmd := l.render() if l.rendered != "" { - renderedHeight := lipgloss.Height(l.rendered) - if renderedHeight <= l.height { + if l.renderedHeight <= l.height { l.offset = 0 } else { - maxOffset := renderedHeight - l.height + maxOffset := l.renderedHeight - l.height if l.offset > maxOffset { l.offset = maxOffset } @@ -989,7 +1169,7 @@ func (l *list[T]) GetSize() (int, int) { // GoToBottom implements List. func (l *list[T]) GoToBottom() tea.Cmd { l.offset = 0 - l.selectedItem = "" + l.selectedItemIdx = -1 l.direction = DirectionBackward return l.render() } @@ -997,7 +1177,7 @@ func (l *list[T]) GoToBottom() tea.Cmd { // GoToTop implements List. func (l *list[T]) GoToTop() tea.Cmd { l.offset = 0 - l.selectedItem = "" + l.selectedItemIdx = -1 l.direction = DirectionForward return l.render() } @@ -1009,21 +1189,29 @@ func (l *list[T]) IsFocused() bool { // Items implements List. func (l *list[T]) Items() []T { - return slices.Collect(l.items.Seq()) + itemsLen := len(l.items) + result := make([]T, 0, itemsLen) + for i := range itemsLen { + if i >= 0 && i < len(l.items) { + item := l.items[i] + result = append(result, item) + } + } + return result } func (l *list[T]) incrementOffset(n int) { - renderedHeight := lipgloss.Height(l.rendered) // no need for offset - if renderedHeight <= l.height { + if l.renderedHeight <= l.height { return } - maxOffset := renderedHeight - l.height + maxOffset := l.renderedHeight - l.height n = min(n, maxOffset-l.offset) if n <= 0 { return } l.offset += n + l.cachedViewDirty = true } func (l *list[T]) decrementOffset(n int) { @@ -1035,6 +1223,7 @@ func (l *list[T]) decrementOffset(n int) { if l.offset < 0 { l.offset = 0 } + l.cachedViewDirty = true } // MoveDown implements List. @@ -1105,14 +1294,26 @@ func (l *list[T]) MoveUp(n int) tea.Cmd { // PrependItem implements List. func (l *list[T]) PrependItem(item T) tea.Cmd { - cmds := []tea.Cmd{ - item.Init(), + // Pre-allocate with expected capacity + cmds := make([]tea.Cmd, 0, 4) + cmds = append(cmds, item.Init()) + + l.items = append([]T{item}, l.items...) + + // Shift selectedItemIdx since all items moved down by 1 + if l.selectedItemIdx >= 0 { + l.selectedItemIdx++ } - l.items.Prepend(item) - l.indexMap = csync.NewMap[string, int]() - for inx, item := range slices.Collect(l.items.Seq()) { - l.indexMap.Set(item.ID(), inx) + + // Update index map incrementally: shift all existing indices up by 1 + // This is more efficient than rebuilding from scratch + newIndexMap := make(map[string]int, len(l.indexMap)+1) + for id, idx := range l.indexMap { + newIndexMap[id] = idx + 1 // All existing items shift down by 1 } + newIndexMap[item.ID()] = 0 // New item is at index 0 + l.indexMap = newIndexMap + if l.width > 0 && l.height > 0 { cmds = append(cmds, item.SetSize(l.width, l.height)) } @@ -1124,13 +1325,13 @@ func (l *list[T]) PrependItem(item T) tea.Cmd { cmds = append(cmds, cmd) } } else { - newItem, ok := l.renderedItems.Get(item.ID()) + newItem, ok := l.renderedItems[item.ID()] if ok { newLines := newItem.height - if l.items.Len() > 1 { + if len(l.items) > 1 { newLines += l.gap } - l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) + l.offset = min(l.renderedHeight-1, l.offset+newLines) } } } @@ -1139,17 +1340,17 @@ func (l *list[T]) PrependItem(item T) tea.Cmd { // SelectItemAbove implements List. func (l *list[T]) SelectItemAbove() tea.Cmd { - inx, ok := l.indexMap.Get(l.selectedItem) - if !ok { + if l.selectedItemIdx < 0 { return nil } - newIndex := l.firstSelectableItemAbove(inx) + newIndex := l.firstSelectableItemAbove(l.selectedItemIdx) if newIndex == ItemNotFound { // no item above return nil } - var cmds []tea.Cmd + // Pre-allocate with expected capacity + cmds := make([]tea.Cmd, 0, 2) if newIndex == 1 { peakAboveIndex := l.firstSelectableItemAbove(newIndex) if peakAboveIndex == ItemNotFound { @@ -1160,11 +1361,11 @@ func (l *list[T]) SelectItemAbove() tea.Cmd { } } } - item, ok := l.items.Get(newIndex) - if !ok { + if newIndex < 0 || newIndex >= len(l.items) { return nil } - l.selectedItem = item.ID() + l.prevSelectedItemIdx = l.selectedItemIdx + l.selectedItemIdx = newIndex l.movingByItem = true renderCmd := l.render() if renderCmd != nil { @@ -1175,46 +1376,38 @@ func (l *list[T]) SelectItemAbove() tea.Cmd { // SelectItemBelow implements List. func (l *list[T]) SelectItemBelow() tea.Cmd { - inx, ok := l.indexMap.Get(l.selectedItem) - if !ok { + if l.selectedItemIdx < 0 { return nil } - newIndex := l.firstSelectableItemBelow(inx) + newIndex := l.firstSelectableItemBelow(l.selectedItemIdx) if newIndex == ItemNotFound { // no item above return nil } - item, ok := l.items.Get(newIndex) - if !ok { + if newIndex < 0 || newIndex >= len(l.items) { return nil } - l.selectedItem = item.ID() + l.prevSelectedItemIdx = l.selectedItemIdx + l.selectedItemIdx = newIndex l.movingByItem = true return l.render() } // SelectedItem implements List. func (l *list[T]) SelectedItem() *T { - inx, ok := l.indexMap.Get(l.selectedItem) - if !ok { - return nil - } - if inx > l.items.Len()-1 { - return nil - } - item, ok := l.items.Get(inx) - if !ok { + if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) { return nil } + item := l.items[l.selectedItemIdx] return &item } // SetItems implements List. func (l *list[T]) SetItems(items []T) tea.Cmd { - l.items.SetSlice(items) + l.items = items var cmds []tea.Cmd - for inx, item := range slices.Collect(l.items.Seq()) { + for inx, item := range items { if i, ok := any(item).(Indexable); ok { i.SetIndex(inx) } @@ -1226,23 +1419,44 @@ func (l *list[T]) SetItems(items []T) tea.Cmd { // SetSelected implements List. func (l *list[T]) SetSelected(id string) tea.Cmd { - l.selectedItem = id + l.prevSelectedItemIdx = l.selectedItemIdx + if idx, ok := l.indexMap[id]; ok { + l.selectedItemIdx = idx + } else { + l.selectedItemIdx = -1 + } return l.render() } -func (l *list[T]) reset(selectedItem string) tea.Cmd { +func (l *list[T]) reset(selectedItemID string) tea.Cmd { var cmds []tea.Cmd l.rendered = "" + l.renderedHeight = 0 l.offset = 0 - l.selectedItem = selectedItem - l.indexMap = csync.NewMap[string, int]() - l.renderedItems = csync.NewMap[string, renderedItem]() - for inx, item := range slices.Collect(l.items.Seq()) { - l.indexMap.Set(item.ID(), inx) + l.indexMap = make(map[string]int) + l.renderedItems = make(map[string]renderedItem) + itemsLen := len(l.items) + for i := range itemsLen { + if i < 0 || i >= len(l.items) { + continue + } + + item := l.items[i] + l.indexMap[item.ID()] = i if l.width > 0 && l.height > 0 { cmds = append(cmds, item.SetSize(l.width, l.height)) } } + // Convert selectedItemID to index after rebuilding indexMap + if selectedItemID != "" { + if idx, ok := l.indexMap[selectedItemID]; ok { + l.selectedItemIdx = idx + } else { + l.selectedItemIdx = -1 + } + } else { + l.selectedItemIdx = -1 + } cmds = append(cmds, l.render()) return tea.Batch(cmds...) } @@ -1253,7 +1467,13 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd { l.width = width l.height = height if oldWidth != width { - cmd := l.reset(l.selectedItem) + // Get current selected item ID before reset + selectedID := "" + if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) { + item := l.items[l.selectedItemIdx] + selectedID = item.ID() + } + cmd := l.reset(selectedID) return cmd } return nil @@ -1261,16 +1481,17 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd { // UpdateItem implements List. func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { - var cmds []tea.Cmd - if inx, ok := l.indexMap.Get(id); ok { - l.items.Set(inx, item) - oldItem, hasOldItem := l.renderedItems.Get(id) + // Pre-allocate with expected capacity + cmds := make([]tea.Cmd, 0, 1) + if inx, ok := l.indexMap[id]; ok { + l.items[inx] = item + oldItem, hasOldItem := l.renderedItems[id] oldPosition := l.offset if l.direction == DirectionBackward { - oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset + oldPosition = (l.renderedHeight - 1) - l.offset } - l.renderedItems.Del(id) + delete(l.renderedItems, id) cmd := l.render() // need to check for nil because of sequence not handling nil @@ -1281,17 +1502,17 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { // if we are the last item and there is no offset // make sure to go to the bottom if oldPosition < oldItem.end { - newItem, ok := l.renderedItems.Get(item.ID()) + newItem, ok := l.renderedItems[item.ID()] if ok { newLines := newItem.height - oldItem.height - l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1) } } } else if hasOldItem && l.offset > oldItem.start { - newItem, ok := l.renderedItems.Get(item.ID()) + newItem, ok := l.renderedItems[item.ID()] if ok { newLines := newItem.height - oldItem.height - l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1) } } } @@ -1333,13 +1554,10 @@ func (l *list[T]) SelectionClear() { } func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) { - lines := strings.Split(l.rendered, "\n") - for i, l := range lines { - lines[i] = ansi.Strip(l) - } + numLines := l.lineCount() - if l.direction == DirectionBackward && len(lines) > l.height { - line = ((len(lines) - 1) - l.height) + line + 1 + if l.direction == DirectionBackward && numLines > l.height { + line = ((numLines - 1) - l.height) + line + 1 } if l.offset > 0 { @@ -1350,11 +1568,11 @@ func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) { } } - if line < 0 || line >= len(lines) { + if line < 0 || line >= numLines { return 0, 0 } - currentLine := lines[line] + currentLine := ansi.Strip(l.getLine(line)) gr := uniseg.NewGraphemes(currentLine) startCol = -1 upTo := col @@ -1377,15 +1595,19 @@ func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) { } func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) { - lines := strings.Split(l.rendered, "\n") - for i, l := range lines { - lines[i] = ansi.Strip(l) + // Helper function to get a line with ANSI stripped and icons replaced + getCleanLine := func(index int) string { + rawLine := l.getLine(index) + cleanLine := ansi.Strip(rawLine) for _, icon := range styles.SelectionIgnoreIcons { - lines[i] = strings.ReplaceAll(lines[i], icon, " ") + cleanLine = strings.ReplaceAll(cleanLine, icon, " ") } + return cleanLine } - if l.direction == DirectionBackward && len(lines) > l.height { - line = (len(lines) - 1) - l.height + line + 1 + + numLines := l.lineCount() + if l.direction == DirectionBackward && numLines > l.height { + line = (numLines - 1) - l.height + line + 1 } if l.offset > 0 { @@ -1397,30 +1619,30 @@ func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, fou } // Ensure line is within bounds - if line < 0 || line >= len(lines) { + if line < 0 || line >= numLines { return 0, 0, false } - if strings.TrimSpace(lines[line]) == "" { + if strings.TrimSpace(getCleanLine(line)) == "" { return 0, 0, false } // Find start of paragraph (search backwards for empty line or start of text) startLine = line - for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" { + for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" { startLine-- } // Find end of paragraph (search forwards for empty line or end of text) endLine = line - for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" { + for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" { endLine++ } // revert the line numbers if we are in backward direction - if l.direction == DirectionBackward && len(lines) > l.height { - startLine = startLine - (len(lines) - 1) + l.height - 1 - endLine = endLine - (len(lines) - 1) + l.height - 1 + if l.direction == DirectionBackward && numLines > l.height { + startLine = startLine - (numLines - 1) + l.height - 1 + endLine = endLine - (numLines - 1) + l.height - 1 } if l.offset > 0 { if l.direction == DirectionBackward { diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index 4e6d8e3110d8c585b26293b7ef1f1e80e06c8b50..234f2a32cbec813355db040df37c87d166409d62 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -28,18 +28,18 @@ func TestList(t *testing.T) { execCmd(l, l.Init()) // should select the last item - assert.Equal(t, items[0].ID(), l.selectedItem) + assert.Equal(t, 0, l.selectedItemIdx) assert.Equal(t, 0, l.offset) - require.Equal(t, 5, l.indexMap.Len()) - require.Equal(t, 5, l.items.Len()) - require.Equal(t, 5, l.renderedItems.Len()) + require.Equal(t, 5, len(l.indexMap)) + require.Equal(t, 5, len(l.items)) + require.Equal(t, 5, len(l.renderedItems)) assert.Equal(t, 5, lipgloss.Height(l.rendered)) assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") start, end := l.viewPosition() assert.Equal(t, 0, start) assert.Equal(t, 4, end) for i := range 5 { - item, ok := l.renderedItems.Get(items[i].ID()) + item, ok := l.renderedItems[items[i].ID()] require.True(t, ok) assert.Equal(t, i, item.start) assert.Equal(t, i, item.end) @@ -58,18 +58,18 @@ func TestList(t *testing.T) { execCmd(l, l.Init()) // should select the last item - assert.Equal(t, items[4].ID(), l.selectedItem) + assert.Equal(t, 4, l.selectedItemIdx) assert.Equal(t, 0, l.offset) - require.Equal(t, 5, l.indexMap.Len()) - require.Equal(t, 5, l.items.Len()) - require.Equal(t, 5, l.renderedItems.Len()) + require.Equal(t, 5, len(l.indexMap)) + require.Equal(t, 5, len(l.items)) + require.Equal(t, 5, len(l.renderedItems)) assert.Equal(t, 5, lipgloss.Height(l.rendered)) assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") start, end := l.viewPosition() assert.Equal(t, 0, start) assert.Equal(t, 4, end) for i := range 5 { - item, ok := l.renderedItems.Get(items[i].ID()) + item, ok := l.renderedItems[items[i].ID()] require.True(t, ok) assert.Equal(t, i, item.start) assert.Equal(t, i, item.end) @@ -89,18 +89,18 @@ func TestList(t *testing.T) { execCmd(l, l.Init()) // should select the last item - assert.Equal(t, items[0].ID(), l.selectedItem) + assert.Equal(t, 0, l.selectedItemIdx) assert.Equal(t, 0, l.offset) - require.Equal(t, 30, l.indexMap.Len()) - require.Equal(t, 30, l.items.Len()) - require.Equal(t, 30, l.renderedItems.Len()) + require.Equal(t, 30, len(l.indexMap)) + require.Equal(t, 30, len(l.items)) + require.Equal(t, 30, len(l.renderedItems)) assert.Equal(t, 30, lipgloss.Height(l.rendered)) assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") start, end := l.viewPosition() assert.Equal(t, 0, start) assert.Equal(t, 9, end) for i := range 30 { - item, ok := l.renderedItems.Get(items[i].ID()) + item, ok := l.renderedItems[items[i].ID()] require.True(t, ok) assert.Equal(t, i, item.start) assert.Equal(t, i, item.end) @@ -119,18 +119,18 @@ func TestList(t *testing.T) { execCmd(l, l.Init()) // should select the last item - assert.Equal(t, items[29].ID(), l.selectedItem) + assert.Equal(t, 29, l.selectedItemIdx) assert.Equal(t, 0, l.offset) - require.Equal(t, 30, l.indexMap.Len()) - require.Equal(t, 30, l.items.Len()) - require.Equal(t, 30, l.renderedItems.Len()) + require.Equal(t, 30, len(l.indexMap)) + require.Equal(t, 30, len(l.items)) + require.Equal(t, 30, len(l.renderedItems)) assert.Equal(t, 30, lipgloss.Height(l.rendered)) assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") start, end := l.viewPosition() assert.Equal(t, 20, start) assert.Equal(t, 29, end) for i := range 30 { - item, ok := l.renderedItems.Get(items[i].ID()) + item, ok := l.renderedItems[items[i].ID()] require.True(t, ok) assert.Equal(t, i, item.start) assert.Equal(t, i, item.end) @@ -152,11 +152,11 @@ func TestList(t *testing.T) { execCmd(l, l.Init()) // should select the last item - assert.Equal(t, items[0].ID(), l.selectedItem) + assert.Equal(t, 0, l.selectedItemIdx) assert.Equal(t, 0, l.offset) - require.Equal(t, 30, l.indexMap.Len()) - require.Equal(t, 30, l.items.Len()) - require.Equal(t, 30, l.renderedItems.Len()) + require.Equal(t, 30, len(l.indexMap)) + require.Equal(t, 30, len(l.items)) + require.Equal(t, 30, len(l.renderedItems)) expectedLines := 0 for i := range 30 { expectedLines += (i + 1) * 1 @@ -168,7 +168,7 @@ func TestList(t *testing.T) { assert.Equal(t, 9, end) currentPosition := 0 for i := range 30 { - rItem, ok := l.renderedItems.Get(items[i].ID()) + rItem, ok := l.renderedItems[items[i].ID()] require.True(t, ok) assert.Equal(t, currentPosition, rItem.start) assert.Equal(t, currentPosition+i, rItem.end) @@ -190,11 +190,11 @@ func TestList(t *testing.T) { execCmd(l, l.Init()) // should select the last item - assert.Equal(t, items[29].ID(), l.selectedItem) + assert.Equal(t, 29, l.selectedItemIdx) assert.Equal(t, 0, l.offset) - require.Equal(t, 30, l.indexMap.Len()) - require.Equal(t, 30, l.items.Len()) - require.Equal(t, 30, l.renderedItems.Len()) + require.Equal(t, 30, len(l.indexMap)) + require.Equal(t, 30, len(l.items)) + require.Equal(t, 30, len(l.renderedItems)) expectedLines := 0 for i := range 30 { expectedLines += (i + 1) * 1 @@ -206,7 +206,7 @@ func TestList(t *testing.T) { assert.Equal(t, expectedLines-1, end) currentPosition := 0 for i := range 30 { - rItem, ok := l.renderedItems.Get(items[i].ID()) + rItem, ok := l.renderedItems[items[i].ID()] require.True(t, ok) assert.Equal(t, currentPosition, rItem.start) assert.Equal(t, currentPosition+i, rItem.end) @@ -229,7 +229,7 @@ func TestList(t *testing.T) { execCmd(l, l.Init()) // should select the last item - assert.Equal(t, items[10].ID(), l.selectedItem) + assert.Equal(t, 10, l.selectedItemIdx) golden.RequireEqual(t, []byte(l.View())) }) @@ -247,7 +247,7 @@ func TestList(t *testing.T) { execCmd(l, l.Init()) // should select the last item - assert.Equal(t, items[10].ID(), l.selectedItem) + assert.Equal(t, 10, l.selectedItemIdx) golden.RequireEqual(t, []byte(l.View())) }) From 07eb36c9a8b3467915909efdca28c606a0590fd4 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 31 Oct 2025 08:41:56 -0700 Subject: [PATCH 03/11] chore: gramatical edit (#1350) --- internal/tui/components/chat/messages/renderer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/chat/messages/renderer.go b/internal/tui/components/chat/messages/renderer.go index 4169b2b0646342c087dc87eb1b67fb2c75653876..7feab5f8b864a0e483d8eb101c549c25c808b9df 100644 --- a/internal/tui/components/chat/messages/renderer.go +++ b/internal/tui/components/chat/messages/renderer.go @@ -736,7 +736,7 @@ func earlyState(header string, v *toolCallCmp) (string, bool) { message = t.S().Base.Foreground(t.FgSubtle).Render("Canceled.") case v.result.ToolCallID == "": if v.permissionRequested && !v.permissionGranted { - message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting for permission...") + message = t.S().Base.Foreground(t.FgSubtle).Render("Requesting permission...") } else { message = t.S().Base.Foreground(t.FgSubtle).Render("Waiting for tool response...") } From c467d135f93d7ea9d9d52cbebf39576dda5a4362 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 31 Oct 2025 17:10:51 +0100 Subject: [PATCH 04/11] chore: improve azure support (#1351) --- go.mod | 2 +- go.sum | 4 ++-- internal/agent/coordinator.go | 13 ++----------- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index 5b9e2b24e520909092e8c5e33e633f49d4821572..79ff03750645aa557fcc524c3db20f2e1eceea81 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/charmbracelet/crush go 1.25.0 require ( - charm.land/fantasy v0.1.4 + charm.land/fantasy v0.1.5 github.com/JohannesKaufmann/html-to-markdown v1.6.0 github.com/MakeNowJust/heredoc v1.0.0 github.com/PuerkitoBio/goquery v1.10.3 diff --git a/go.sum b/go.sum index a82a08657d31b36664df8f87233fd527ff24f0d6..6c07c06a54da857cd6c0feb849254aa5381a8aea 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -charm.land/fantasy v0.1.4 h1:H/l2GfMy6Pon0GcXoj4/kuHKZ0jm//xDe2Got0eF3AU= -charm.land/fantasy v0.1.4/go.mod h1:GT1Y8uYNmmu7OkUxWEiOyzdAf1jYopPJfpWvoDRzGiM= +charm.land/fantasy v0.1.5 h1:7sta5yC+cSU32Kb+cNQb4b/3fyn13tYOgXsnXhdMlX0= +charm.land/fantasy v0.1.5/go.mod h1:GT1Y8uYNmmu7OkUxWEiOyzdAf1jYopPJfpWvoDRzGiM= 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.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go index 7bf0167fa1cf0de0323e3227823bbc9c4ab99e5c..3fd57c69d2b90ecf1477eccda399cbfceb3c0ef6 100644 --- a/internal/agent/coordinator.go +++ b/internal/agent/coordinator.go @@ -193,7 +193,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy. } switch providerCfg.Type { - case openai.Name: + case openai.Name, azure.Name: _, hasReasoningEffort := mergedOptions["reasoning_effort"] if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" { mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort @@ -250,16 +250,6 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy. if err == nil { options[google.Name] = parsed } - case azure.Name: - _, hasReasoningEffort := mergedOptions["reasoning_effort"] - if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" { - mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort - } - // azure uses the same options as openaicompat - parsed, err := openaicompat.ParseOptions(mergedOptions) - if err == nil { - options[azure.Name] = parsed - } case openaicompat.Name: _, hasReasoningEffort := mergedOptions["reasoning_effort"] if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" { @@ -566,6 +556,7 @@ func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[str opts := []azure.Option{ azure.WithBaseURL(baseURL), azure.WithAPIKey(apiKey), + azure.WithUseResponsesAPI(), } if c.cfg.Options.Debug { httpClient := log.NewHTTPClient() From 18555334af1ca7b3e6ac801fc8aae8e8a8657364 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 31 Oct 2025 14:30:00 -0300 Subject: [PATCH 06/11] ci: on schema update commits, use @charmcli as the author (#1344) --- .github/workflows/schema-update.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/schema-update.yml b/.github/workflows/schema-update.yml index 466c3a25fb3698a183ed84436d5dca9813b2dcb6..ecfaf140ecb65d8d59fdc88a810d88c53765e264 100644 --- a/.github/workflows/schema-update.yml +++ b/.github/workflows/schema-update.yml @@ -21,6 +21,6 @@ jobs: with: commit_message: "chore: auto-update generated files" branch: main - commit_user_name: actions-user - commit_user_email: actions@github.com - commit_author: actions-user + commit_user_name: Charm + commit_user_email: 124303983+charmcli@users.noreply.github.com + commit_author: Charm <124303983+charmcli@users.noreply.github.com> From 11471e810772cae557628044294051a18addc68e Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 1 Nov 2025 00:21:12 -0300 Subject: [PATCH 07/11] chore(legal): @heynemann 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 86fe5195f44694bcef0281c5c8e044a65da39381..45f527f19bf37bca39ffbc868deb734027d0edcd 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -791,6 +791,14 @@ "created_at": "2025-10-30T02:04:26Z", "repoId": 987670088, "pullRequestNo": 1335 + }, + { + "name": "heynemann", + "id": 60965, + "comment_id": 3475594747, + "created_at": "2025-11-01T03:21:03Z", + "repoId": 987670088, + "pullRequestNo": 1357 } ] } \ No newline at end of file From 9e7546c87106780e5ee0b7006d7e862acb24490d Mon Sep 17 00:00:00 2001 From: Charm <124303983+charmcli@users.noreply.github.com> Date: Sat, 1 Nov 2025 07:06:14 -0300 Subject: [PATCH 08/11] chore(legal): @niklasschaeffer 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 45f527f19bf37bca39ffbc868deb734027d0edcd..34fd4f0bf87e00334c663acd46e9988150e776bd 100644 --- a/.github/cla-signatures.json +++ b/.github/cla-signatures.json @@ -799,6 +799,14 @@ "created_at": "2025-11-01T03:21:03Z", "repoId": 987670088, "pullRequestNo": 1357 + }, + { + "name": "niklasschaeffer", + "id": 1948226, + "comment_id": 3476119118, + "created_at": "2025-11-01T10:06:05Z", + "repoId": 987670088, + "pullRequestNo": 1358 } ] } \ No newline at end of file From 98780b151393c72c71137cac0e882db71129aea1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 10:44:24 +0000 Subject: [PATCH 09/11] chore(deps): bump the all group with 4 updates (#1366) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 79ff03750645aa557fcc524c3db20f2e1eceea81..2c4b2e01355f31a5706f966ba7e0600b3660bbb7 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/charlievieth/fastwalk v1.0.14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.6 - github.com/charmbracelet/catwalk v0.8.1 + github.com/charmbracelet/catwalk v0.8.2 github.com/charmbracelet/colorprofile v0.3.2 github.com/charmbracelet/fang v0.4.3 github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 @@ -27,21 +27,21 @@ require ( github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 - github.com/charmbracelet/x/term v0.2.1 + github.com/charmbracelet/x/term v0.2.2 github.com/denisbrodbeck/machineid v1.0.1 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/joho/godotenv v1.5.1 github.com/lucasb-eyer/go-colorful v1.3.0 - github.com/modelcontextprotocol/go-sdk v1.0.0 + github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/muesli/termenv v0.16.0 github.com/ncruces/go-sqlite3 v0.29.1 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/nxadm/tail v1.4.11 github.com/openai/openai-go/v2 v2.7.1 github.com/posthog/posthog-go v1.6.12 - github.com/pressly/goose/v3 v3.25.0 + github.com/pressly/goose/v3 v3.26.0 github.com/qjebbs/go-jsons v1.0.0-alpha.4 github.com/rivo/uniseg v0.4.7 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 diff --git a/go.sum b/go.sum index 6c07c06a54da857cd6c0feb849254aa5381a8aea..d1084057c65b658c8a248d8a5036b6177a0acb79 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.6 h1:nXNg4TmtfoQXFdF2BSSjTxFp9bSHQCILkIKK3FXMW/E= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.6/go.mod h1:SUTLq+/pGQ5qntHgt0JswfVJFfgJgWDqyvyiSLVlmbo= -github.com/charmbracelet/catwalk v0.8.1 h1:Okn6EgMSHlNCYQrSQkyAjLuLiSzDmReGLc0MPcG2F9g= -github.com/charmbracelet/catwalk v0.8.1/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= +github.com/charmbracelet/catwalk v0.8.2 h1:J7xq/ft/ZByJCHl3JpgvxlCd59bzZPugy66XuoL4vAs= +github.com/charmbracelet/catwalk v0.8.2/go.mod h1:ReU4SdrLfe63jkEjWMdX2wlZMV3k9r11oQAmzN0m+KY= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/fang v0.4.3 h1:qXeMxnL4H6mSKBUhDefHu8NfikFbP/MBNTfqTrXvzmY= @@ -114,8 +114,8 @@ github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQA github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM= github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc= github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +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= 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= @@ -214,8 +214,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74= -github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs= +github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= +github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= @@ -249,8 +249,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posthog/posthog-go v1.6.12 h1:rsOBL/YdMfLJtOVjKJLgdzYmvaL3aIW6IVbAteSe+aI= github.com/posthog/posthog-go v1.6.12/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY= -github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng= -github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= github.com/qjebbs/go-jsons v1.0.0-alpha.4 h1:Qsb4ohRUHQODIUAsJKdKJ/SIDbsO7oGOzsfy+h1yQZs= github.com/qjebbs/go-jsons v1.0.0-alpha.4/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= From d4d331f3abbc14158adf20b6d9030de13fa7eb7e Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 2 Nov 2025 21:14:55 -0500 Subject: [PATCH 10/11] fix(lint): don't shadow 'env' variable Elsewhere in the package, many things are called env. This change renames the 'env' type used in testing to fix shadow warnings. --- internal/agent/agent_test.go | 2 +- internal/agent/common_test.go | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 0682083ae20daaa1c17747afdfd6b4db358b88fa..527552e28292320a3486cfabd68ce63003322aec 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -33,7 +33,7 @@ func getModels(t *testing.T, r *recorder.Recorder, pair modelPair) (fantasy.Lang return large, small } -func setupAgent(t *testing.T, pair modelPair) (SessionAgent, env) { +func setupAgent(t *testing.T, pair modelPair) (SessionAgent, fakeEnv) { r := newRecorder(t) large, small := getModels(t, r, pair) env := testEnv(t) diff --git a/internal/agent/common_test.go b/internal/agent/common_test.go index f6f564109a32c278f6e127809b9b2ef550c239bd..b668b5dba6d42f24a4ca2eea788f7c8a608452f8 100644 --- a/internal/agent/common_test.go +++ b/internal/agent/common_test.go @@ -30,7 +30,8 @@ import ( _ "github.com/joho/godotenv/autoload" ) -type env struct { +// fakeEnv is an environment for testing. +type fakeEnv struct { workingDir string sessions session.Service messages message.Service @@ -100,7 +101,7 @@ func zAIBuilder(model string) builderFunc { } } -func testEnv(t *testing.T) env { +func testEnv(t *testing.T) fakeEnv { workingDir := filepath.Join("/tmp/crush-test/", t.Name()) os.RemoveAll(workingDir) @@ -123,7 +124,7 @@ func testEnv(t *testing.T) env { os.RemoveAll(workingDir) }) - return env{ + return fakeEnv{ workingDir, sessions, messages, @@ -133,7 +134,7 @@ func testEnv(t *testing.T) env { } } -func testSessionAgent(env env, large, small fantasy.LanguageModel, systemPrompt string, tools ...fantasy.AgentTool) SessionAgent { +func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPrompt string, tools ...fantasy.AgentTool) SessionAgent { largeModel := Model{ Model: large, CatwalkCfg: catwalk.Model{ @@ -152,7 +153,7 @@ func testSessionAgent(env env, large, small fantasy.LanguageModel, systemPrompt return agent } -func coderAgent(r *recorder.Recorder, env env, large, small fantasy.LanguageModel) (SessionAgent, error) { +func coderAgent(r *recorder.Recorder, env fakeEnv, large, small fantasy.LanguageModel) (SessionAgent, error) { fixedTime := func() time.Time { t, _ := time.Parse("1/2/2006", "1/1/2025") return t From ac8017516fab5280d78d4cfef20e24c7e7520019 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 2 Nov 2025 21:33:35 -0500 Subject: [PATCH 11/11] chore: switch from bar cursor to blocky cursor --- internal/tui/styles/theme.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index a81f8484e28c6942d19b6204e3733f27c7adbf8d..e8da5d04e9b8120668edf8f0197588cacf73883b 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -179,7 +179,7 @@ func (t *Theme) buildStyles() *Styles { }, Cursor: textinput.CursorStyle{ Color: t.Secondary, - Shape: tea.CursorBar, + Shape: tea.CursorBlock, Blink: true, }, }, @@ -204,7 +204,7 @@ func (t *Theme) buildStyles() *Styles { }, Cursor: textarea.CursorStyle{ Color: t.Secondary, - Shape: tea.CursorBar, + Shape: tea.CursorBlock, Blink: true, }, },